geometry_quality 预检只拒绝 detailed,standard 按不传处理

- validate_generation_options 对非 v3.1 / v3.0 家族只在取值 detailed 时拒绝,
  与 quad / smart_low_poly / generate_parts「只拒绝启用值」的处理对称
- 新增 wire_geometry_quality:不支持的家族把 standard 收敛成不发(等价于 provider 默认档),
  三个 endpoint 的请求映射都过这一层,避免 provider 因「不支持该字段」整单拒绝
- 新增两组表驱动用例:非 v3 家族 standard 放行 / detailed 拒绝,以及映射层的收敛结果
- 同步技术方案:写明「预检只拒绝启用值」与映射层收敛 standard 的口径
This commit is contained in:
2026-09-23 17:33:51 +08:00
parent 508cf30f5d
commit f6f41f22c3
6 changed files with 101 additions and 9 deletions
@@ -66,7 +66,8 @@ canvasCompletion? 画布占位框回填,只在项目资源落点下生效
**归一在预检之前完成并写回请求**:项目 ID 与素材夹 ID 都 trim,`project` / 旧 `folder-*` 走与图片画布同一个 `normalize_generated_asset_folder_id` 映射到当前 owner 的默认素材夹,素材名走同一个 `resolve_editor_generated_asset_label`trim + 截断 + 缺省「3D 模型」)。入队的就是归一后的请求,因此预检值 = 队列载荷 = 落库值;模块侧落库原有的 `normalize_editor_generation_default_asset_folders` 继续兜底老队列载荷里的 `project`
**定价相关参数在 API 层做组合校验**`texture``quad``smartLowPoly``generateParts` 必须显式给出;`geometryQuality``textureQuality` 允许缺省,缺省按 `standard` 计价 —— provider 对 v2.5 不支持 `geometry_quality`、并拒绝任何 `texture_quality`v3.x 的缺省档也是 `standard`,两边同价,因此只有显式 `detailed` / `extreme` 才命中 `hdGeometry` / `hdTexture` / `ultraTexture` 加价(把这两个字段写死必填只会让定价表里已有的 v2.5 / P1 / P2 永远不可达,或让 `texture=true` 的 v2.5 无论填什么都被 provider 预检拒绝)。`texture=false` 时禁止出现 `textureQuality`,且 `pbr` 必须显式 `false`其余会改价的参数仍必须显式给出:provider 的隐式默认值会直接改变价格,一旦依赖默认值,报价与扣费会在“调用方少传字段”时分叉
**定价相关参数在 API 层做组合校验**`texture``quad``smartLowPoly``generateParts` 必须显式给出;`geometryQuality``textureQuality` 允许缺省,缺省按 `standard` 计价 —— provider 对 v2.5 不支持 `geometry_quality`、并拒绝任何 `texture_quality`v3.x 的缺省档也是 `standard`,两边同价,因此只有显式 `detailed` / `extreme` 才命中 `hdGeometry` / `hdTexture` / `ultraTexture` 加价(把这两个字段写死必填只会让定价表里已有的 v2.5 / P1 / P2 永远不可达,或让 `texture=true` 的 v2.5 无论填什么都被 provider 预检拒绝)。`texture=false` 时禁止出现 `textureQuality`,且 `pbr` 必须显式 `false`**provider 预检只拒绝会启用能力的取值**:`geometry_quality` 对非 v3.1 / v3.0 家族只在取值 `detailed` 时拒绝,`standard` 放行;并且 `platform-tripo` 的请求映射会把「不支持的家族 + standard」收敛成「不发该字段」——不支持的家族传 `standard` 与不传等价(provider 默认档就是它,定价也同价),发出去只会换来「不支持该字段」的整单拒绝
其余会改价的参数仍必须显式给出:provider 的隐式默认值会直接改变价格,一旦依赖默认值,报价与扣费会在“调用方少传字段”时分叉。
两个 submit 都必须携带 `Idempotency-Key`,复用现有头部校验;缺失或格式非法直接 400,不静默生成键。
@@ -19,5 +19,6 @@ pub use types::{
};
pub(crate) use validation::{
TripoGenerationOptions, submitted_task_handle, validate_generation_options, validate_task_id,
wire_geometry_quality,
};
pub(crate) use wire::{wire, wire_option};
@@ -108,13 +108,18 @@ pub(crate) fn validate_generation_options(
));
}
if options.geometry_quality.is_some() && !matches!(family, ModelFamily::H31 | ModelFamily::H30)
// 只拒绝「启用加价」的取值,与 quad / smart_low_poly / generate_parts 的处理对称:
// 非 v3 家族把 standard 传出去只会换来 provider 的「不支持该字段」,它等价于不传。
if options
.geometry_quality
.is_some_and(|quality| matches!(quality, Model3dGeometryQuality::Detailed))
&& !family.supports_geometry_quality()
{
return Err(invalid(
Some(TripoField::GeometryQuality),
TripoValidationReason::InvalidCombination,
&format!(
"geometry_quality is only supported by {}",
"geometry_quality=detailed is only supported by {}",
v3_model_labels()
),
));
@@ -238,6 +243,32 @@ impl From<Model3dModelVersion> for ModelFamily {
}
}
impl ModelFamily {
/// 是否支持 `geometry_quality`:只有 v3.x 家族有这一档。
const fn supports_geometry_quality(self) -> bool {
matches!(self, Self::H31 | Self::H30)
}
}
/// 把「等于 provider 默认档」的几何质量收敛成「不发」。
///
/// 不支持的家族传 `standard` 与不传完全等价(两边的默认档都是 standard,定价也同价),
/// 但传出去会让 provider 以「不支持该字段」拒绝整次提交,所以这里直接不发。
/// 非默认档(`detailed`)在预检里已经被拒,不会走到这里。
pub(crate) fn wire_geometry_quality(
model: Model3dModelVersion,
quality: Option<Model3dGeometryQuality>,
) -> Option<Model3dGeometryQuality> {
match quality {
Some(Model3dGeometryQuality::Standard)
if !ModelFamily::from(model).supports_geometry_quality() =>
{
None
}
other => other,
}
}
fn face_limit_bounds(family: ModelFamily, options: &TripoGenerationOptions) -> (i64, i64) {
if options.smart_low_poly == Some(true) {
return if options.quad == Some(true) {
@@ -538,6 +569,60 @@ mod tests {
}
}
/// 几何质量只拒绝「会启用高清几何」的 detailed:非 v3 家族的 standard 必须放行,
/// 否则定价表里已定价的 v2.5 / P1 / P2 又会被这条规则挡回去。
#[test]
fn geometry_quality_only_rejects_the_enabling_value_for_non_v3_families() {
for model in Model3dModelVersion::ALL {
let supported = matches!(model, Model3dModelVersion::H31 | Model3dModelVersion::H30);
let mut options = minimal_options(*model);
options.geometry_quality = Some(Model3dGeometryQuality::Standard);
assert!(
validate_generation_options(&options).is_ok(),
"{model:?} 的 geometry_quality=standard 必须放行"
);
options.geometry_quality = Some(Model3dGeometryQuality::Detailed);
let result = validate_generation_options(&options);
assert_eq!(
result.is_ok(),
supported,
"{model:?} detailed 的期望结果不符"
);
if let Err(error) = result {
assert_eq!(
rejection(error),
Some((
Some(TripoField::GeometryQuality),
TripoValidationReason::InvalidCombination
)),
"{model:?}"
);
}
}
}
/// 不支持该参数的家族把 standard 收敛成「不发」:传出去只会换来 provider 的
/// 「不支持该字段」,而它与不传完全等价(两边默认档都是 standard,定价也同价)。
#[test]
fn wire_geometry_quality_drops_the_default_for_families_without_the_flag() {
for model in Model3dModelVersion::ALL {
let supported = matches!(model, Model3dModelVersion::H31 | Model3dModelVersion::H30);
assert_eq!(
wire_geometry_quality(*model, Some(Model3dGeometryQuality::Standard)),
supported.then_some(Model3dGeometryQuality::Standard),
"{model:?}"
);
assert_eq!(wire_geometry_quality(*model, None), None, "{model:?}");
assert_eq!(
wire_geometry_quality(*model, Some(Model3dGeometryQuality::Detailed)),
Some(Model3dGeometryQuality::Detailed),
"{model:?} 非默认档不在这里处理,交给预检拒绝"
);
}
}
/// 空白 task id 是 provider 违约,按 OutputSchema 失败而不是参数错误。
#[test]
fn blank_submitted_task_id_is_an_output_schema_error() {
@@ -3,7 +3,7 @@ use tripo3d_sdk::{models::FileInput, params::ImageToModelParams};
use crate::common::{
TripoError, TripoField, TripoProviderClient, TripoTaskHandle, TripoValidationReason,
extra_fields, submitted_task_handle, wire, wire_option,
extra_fields, submitted_task_handle, wire, wire_geometry_quality, wire_option,
};
use super::validation::validate_image_to_model_params;
@@ -123,7 +123,9 @@ fn to_sdk_params(
texture_quality: wire_option(params.texture_quality.as_ref())?,
extra: extra_fields(params.texture_version, params.delight)?,
texture_alignment: wire_option(params.texture_alignment.as_ref())?,
geometry_quality: wire_option(params.geometry_quality.as_ref())?,
geometry_quality: wire_option(
wire_geometry_quality(params.model, params.geometry_quality).as_ref(),
)?,
face_limit: params.face_limit,
auto_size: params.auto_size,
orientation: wire_option(params.orientation.as_ref())?,
@@ -6,7 +6,7 @@ use tripo3d_sdk::{models::FileInput, params::MultiviewToModelParams};
use crate::common::{
TripoError, TripoField, TripoGenerationOptions, TripoProviderClient, TripoTaskHandle,
TripoValidationReason, extra_fields, submitted_task_handle, validate_generation_options,
validate_task_id, wire, wire_option,
validate_task_id, wire, wire_geometry_quality, wire_option,
};
impl TripoProviderClient {
@@ -89,7 +89,8 @@ fn to_sdk_params(
params.pbr = request.pbr;
params.texture_quality = wire_option(request.texture_quality.as_ref())?;
params.extra = extra_fields(request.texture_version, request.delight)?;
params.geometry_quality = wire_option(request.geometry_quality.as_ref())?;
params.geometry_quality =
wire_option(wire_geometry_quality(request.model, request.geometry_quality).as_ref())?;
params.texture_alignment = wire_option(request.texture_alignment.as_ref())?;
params.face_limit = request.face_limit;
params.auto_size = request.auto_size;
@@ -1,6 +1,6 @@
use crate::common::{
TripoError, TripoProviderClient, TripoTaskHandle, extra_fields, submitted_task_handle, wire,
wire_option,
wire_geometry_quality, wire_option,
};
use shared_contracts::model3d::text_to_model::Model3dTextToModelParams;
@@ -37,7 +37,9 @@ fn to_sdk_params(
pbr: params.pbr,
texture_quality: wire_option(params.texture_quality.as_ref())?,
extra: extra_fields(params.texture_version, params.delight)?,
geometry_quality: wire_option(params.geometry_quality.as_ref())?,
geometry_quality: wire_option(
wire_geometry_quality(params.model, params.geometry_quality).as_ref(),
)?,
face_limit: params.face_limit,
auto_size: params.auto_size,
quad: params.quad,