tripo-cli 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +10 -7
  3. package/dist/ai/experts.js +25 -19
  4. package/dist/ai/plan-card.js +71 -33
  5. package/dist/ai/wizard.js +13 -17
  6. package/dist/commands/account.js +2 -6
  7. package/dist/commands/ai.js +18 -16
  8. package/dist/commands/batch.js +58 -18
  9. package/dist/commands/completion.js +1 -1
  10. package/dist/commands/config-cmd.js +1 -1
  11. package/dist/commands/doctor.js +1 -3
  12. package/dist/commands/generate.js +35 -4
  13. package/dist/commands/home.js +12 -13
  14. package/dist/commands/login.js +72 -187
  15. package/dist/commands/make.js +47 -25
  16. package/dist/commands/mcp.js +2 -1
  17. package/dist/commands/models.js +47 -0
  18. package/dist/commands/process-groups.js +3 -4
  19. package/dist/commands/shared.js +9 -14
  20. package/dist/commands/task.js +10 -11
  21. package/dist/commands/topup.js +6 -10
  22. package/dist/commands/update.js +6 -10
  23. package/dist/commands/view.js +3 -4
  24. package/dist/config/config.js +1 -1
  25. package/dist/core/client.js +4 -13
  26. package/dist/core/errors.js +16 -11
  27. package/dist/core/output.js +0 -4
  28. package/dist/core/requests.js +61 -17
  29. package/dist/core/task-service.js +19 -20
  30. package/dist/knowledge/chains.js +12 -12
  31. package/dist/knowledge/error-catalog.js +32 -32
  32. package/dist/knowledge/models.js +119 -20
  33. package/dist/knowledge/params.js +96 -8
  34. package/dist/knowledge/scenarios.js +32 -55
  35. package/dist/pipeline/make.js +36 -28
  36. package/dist/pipeline/runner.js +7 -12
  37. package/dist/pipeline/stdin.js +40 -3
  38. package/dist/program.js +6 -11
  39. package/package.json +3 -2
  40. package/skill/SKILL.md +35 -8
  41. package/skill/commands/account.md +1 -1
  42. package/skill/commands/batch.md +13 -3
  43. package/skill/commands/generate.md +35 -10
  44. package/skill/commands/make.md +39 -13
  45. package/skill/commands/process.md +2 -1
  46. package/skill/commands/task.md +2 -2
  47. package/skill/commands/view.md +1 -1
  48. package/skill/common-errors.md +6 -3
  49. package/skill/examples/animation.md +3 -1
  50. package/skill/examples/ar-web.md +6 -4
  51. package/skill/examples/game-asset.md +17 -6
  52. package/skill/examples/pipes.md +2 -2
  53. package/skill/examples/print.md +7 -3
  54. package/dist/i18n.js +0 -19
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Dual-model strategy — the single source of truth for 3D model selection.
2
+ * Model routing — the single source of truth for 3D generation selection.
3
3
  *
4
- * Rule (one branch, no exceptions): explicit low-poly intent or a face budget
5
- * <= 20000 selects P1; everything else selects v3.1. Explicit --model wins but
6
- * still goes through constraint validation.
4
+ * H3.1 is the official default (flagship, highest fidelity) and also covers dense
5
+ * geometry and H-only parameters. Real-time, editable and topology-constrained
6
+ * assets route to P2.
7
7
  */
8
8
  /**
9
9
  * Wire values are what the server actually accepts (backend whitelist:
@@ -13,24 +13,28 @@
13
13
  */
14
14
  export const MODEL_V31 = 'v3.1-20260211';
15
15
  export const MODEL_P1 = 'P1-20260311';
16
- /** P-series upgrade (Preview): adds quad output; explicit --model only, never auto-selected. */
16
+ /** P-series model: editable low-poly topology and quad output. */
17
17
  export const MODEL_P2 = 'P2-20260801';
18
- /** User-facing aliases normalized to the wire values above. */
18
+ /** User-facing aliases normalized to the wire values above (matched case-insensitively). */
19
19
  export const MODEL_ALIASES = {
20
+ 'H3.1': MODEL_V31,
21
+ 'P1.0': MODEL_P1,
22
+ 'P2.0': MODEL_P2,
23
+ 'P2.0 Preview': MODEL_P2,
20
24
  'v3.1': MODEL_V31,
21
25
  'v3.1-20260211': MODEL_V31,
22
26
  'tripo-v3.1': MODEL_V31,
23
- p1: MODEL_P1,
24
27
  P1: MODEL_P1,
25
28
  'P1-20260311': MODEL_P1,
26
29
  'tripo-p1': MODEL_P1,
27
- p2: MODEL_P2,
28
30
  P2: MODEL_P2,
29
31
  'P2-20260801': MODEL_P2,
30
32
  'tripo-p2': MODEL_P2,
31
33
  // Older whitelisted versions, alias → wire.
34
+ 'H3.0': 'v3.0-20250812',
32
35
  'v3.0': 'v3.0-20250812',
33
36
  'tripo-v3.0': 'v3.0-20250812',
37
+ 'H2.5': 'v2.5-20250123',
34
38
  'v2.5': 'v2.5-20250123',
35
39
  'tripo-v2.5': 'v2.5-20250123',
36
40
  };
@@ -105,7 +109,6 @@ export const TEXT_TO_IMAGE_MODELS = [
105
109
  'banana',
106
110
  'banana_pro',
107
111
  'banana2',
108
- 'chat_image_1',
109
112
  'chat_image_1.5',
110
113
  'chat_image_2',
111
114
  'chat_image_2.5_flare',
@@ -116,7 +119,6 @@ export const IMAGE_TO_IMAGE_MODELS = [
116
119
  'banana',
117
120
  'banana_pro',
118
121
  'banana2',
119
- 'chat_image_1',
120
122
  'chat_image_1.5',
121
123
  'chat_image_2',
122
124
  'chat_image_2.5_flare',
@@ -161,9 +163,10 @@ export const FBX_PRESETS = ['blender', '3dsmax', 'mixamo', 'bake_scale'];
161
163
  export const EXPORT_ORIENTATIONS = ['+x', '-x', '+y', '-y'];
162
164
  /** `fast` only works with texture model v3.5 (other versions → 1004). */
163
165
  export const TEXTURE_QUALITIES = ['fast', 'standard', 'detailed', 'extreme'];
164
- /** Normalize any accepted alias to the canonical wire value; unknown values pass through. */
166
+ const MODEL_ALIAS_LOOKUP = new Map(Object.entries(MODEL_ALIASES).map(([alias, wire]) => [alias.toLowerCase(), wire]));
167
+ /** Normalize any accepted alias to the canonical wire value; unknown values pass through for validation to reject. */
165
168
  export function normalizeModel(input) {
166
- return MODEL_ALIASES[input] ?? input;
169
+ return MODEL_ALIAS_LOOKUP.get(input.trim().toLowerCase()) ?? input;
167
170
  }
168
171
  export function isP1(model) {
169
172
  return normalizeModel(model) === MODEL_P1;
@@ -171,21 +174,117 @@ export function isP1(model) {
171
174
  export function isP2(model) {
172
175
  return normalizeModel(model) === MODEL_P2;
173
176
  }
174
- /** The one rule. Returns the model plus a human-readable reason. */
177
+ function hRequirement(intent) {
178
+ const params = intent.params ?? {};
179
+ const quad = intent.quad === true || params.quad === true;
180
+ if (intent.denseGeometry)
181
+ return 'dense geometry';
182
+ if (params.geometry_quality !== undefined)
183
+ return 'geometry_quality';
184
+ if (params.smart_low_poly === true)
185
+ return 'smart_low_poly';
186
+ if (params.generate_parts === true)
187
+ return 'generate_parts';
188
+ if (typeof intent.faceLimit === 'number' && intent.faceLimit > (quad ? P2_FACE_LIMIT.quadMax : P2_FACE_LIMIT.triMax)) {
189
+ return `face budget exceeds P2 ${quad ? 'quad' : 'triangle'} limit`;
190
+ }
191
+ if (typeof intent.faceLimit === 'number' && intent.faceLimit > 0 && intent.faceLimit < P2_FACE_LIMIT.min) {
192
+ return 'face budget below P2 minimum';
193
+ }
194
+ return undefined;
195
+ }
196
+ /** Select a generation model from explicit choice, geometry requirements and intended use. */
175
197
  export function chooseModel(intent) {
176
- if (intent.explicit) {
177
- return { model: normalizeModel(intent.explicit), reason: 'explicit --model' };
198
+ const paramModel = typeof intent.params?.model === 'string' ? intent.params.model : undefined;
199
+ const explicit = intent.explicit ?? paramModel;
200
+ if (explicit) {
201
+ return {
202
+ model: normalizeModel(explicit),
203
+ reason: intent.explicit ? 'explicit --model' : 'explicit model parameter',
204
+ };
205
+ }
206
+ const hOnly = hRequirement(intent);
207
+ if (hOnly) {
208
+ return { model: MODEL_V31, reason: `${hOnly} → v3.1` };
178
209
  }
179
- if (intent.lowPoly) {
180
- return { model: MODEL_P1, reason: 'low-poly intent → P1' };
210
+ if (intent.preferred) {
211
+ const preferred = normalizeModel(intent.preferred);
212
+ if (preferred !== MODEL_P1)
213
+ return { model: preferred, reason: 'scenario preference' };
181
214
  }
182
- if (intent.faceLimit !== undefined && intent.faceLimit <= P1_FACE_LIMIT.max) {
183
- return { model: MODEL_P1, reason: `face budget ${intent.faceLimit} ≤ ${P1_FACE_LIMIT.max} → P1` };
215
+ const params = intent.params ?? {};
216
+ if (intent.lowPoly || intent.realtime || intent.editable || intent.quad === true || params.quad === true || intent.faceLimit !== undefined) {
217
+ return { model: MODEL_P2, reason: 'real-time or editable topology → P2' };
184
218
  }
185
- return { model: MODEL_V31, reason: 'default high-fidelity → v3.1' };
219
+ return { model: MODEL_V31, reason: 'official default → v3.1' };
220
+ }
221
+ /**
222
+ * Official list price per generation task in credits, identical on the ov and cn
223
+ * model pages. Tiers: [no texture, standard/fast, detailed, extreme]. P1 and the
224
+ * legacy models publish no price, so they are never estimated.
225
+ */
226
+ const GENERATION_CREDITS = {
227
+ [MODEL_V31]: {
228
+ text_to_model: [10, 20, 30, 40],
229
+ image_to_model: [20, 30, 40, 50],
230
+ multiview_to_model: [20, 30, 40, 50],
231
+ },
232
+ [MODEL_P2]: {
233
+ text_to_model: [100, 110, 120, 130],
234
+ image_to_model: [100, 110, 120, 130],
235
+ multiview_to_model: [100, 110, 120, 130],
236
+ },
237
+ };
238
+ /** List-price credits for one generation task from its validated payload; undefined when no price is published. */
239
+ export function estimateGenerationCredits(model, taskType, params) {
240
+ const wire = normalizeModel(model);
241
+ const tiers = GENERATION_CREDITS[wire]?.[taskType];
242
+ if (!tiers)
243
+ return undefined;
244
+ let credits = tiers[params.texture === false ? 0 : params.texture_quality === 'extreme' ? 3 : params.texture_quality === 'detailed' ? 2 : 1];
245
+ if (wire === MODEL_V31) {
246
+ if (params.geometry_quality === 'detailed')
247
+ credits += 20;
248
+ if (params.quad === true)
249
+ credits += 5;
250
+ if (params.smart_low_poly === true)
251
+ credits += 10;
252
+ if (params.generate_parts === true)
253
+ credits += 20;
254
+ }
255
+ return credits;
186
256
  }
187
257
  /** Keywords that signal low-poly intent in a prompt (used by make/ai). */
188
258
  const LOW_POLY_HINTS = [/low[\s_-]?poly/i, /低模/, /低面/, /低多边形/, /手游/, /voxel/i, /像素风/];
259
+ /** Physical games, product nouns (gaming PC, console) and animation *styles* are not real-time intent. */
260
+ const REALTIME_HINTS = [
261
+ /\b(?:(?<!\b(?:board|card|tabletop|dice|party)[\s_-]?)games?|gameplay|real[\s_-]?time|unity|unreal(?:\s+engine)?|godot|three\.?js|webgl)\b/i,
262
+ /\b(?:editable|edit[\s_-]?friendly|topology|retopo(?:logy)?|quad(?:s)?|rig(?:[\s_-]?ready|ged|ging)?|animat(?:e|ions?)(?![\s_-]?styl))\b/i,
263
+ /(?<!棋盘|桌面)游戏|手游|端游|实时|引擎|可编辑|可修改|拓扑|重拓扑|四边面|四边形|绑定|骨骼|动画(?![风片])|虚幻/,
264
+ ];
265
+ const DENSE_GEOMETRY_HINTS = [
266
+ /\b(?:dense|high[\s_-]?density|high[\s_-]?detail(?:ed)?)\s+(?:mesh|geometry|topology)\b/i,
267
+ /\bhigh[\s_-]?poly(?:gon(?:al)?)?\b/i,
268
+ /\bnanite\b/i,
269
+ /\b(?:million|millions)\s+of\s+(?:faces|triangles|polygons)\b/i,
270
+ /高模|高面数|高多边形|高密度(?:网格|几何)|密集(?:网格|几何)|稠密(?:网格|几何)|高精度(?:网格|几何)|百万(?:面|三角面)/,
271
+ ];
189
272
  export function detectLowPolyIntent(text) {
190
273
  return LOW_POLY_HINTS.some((re) => re.test(text));
191
274
  }
275
+ /** Detect uses that benefit from P-series topology without treating visual quality as mesh density. */
276
+ export function detectRealtimeIntent(text) {
277
+ return REALTIME_HINTS.some((re) => re.test(text));
278
+ }
279
+ /** Detect only explicit dense-geometry requirements; phrases such as "high quality" do not match. */
280
+ export function detectDenseGeometryIntent(text) {
281
+ // Match each mention independently: negating one must not hide a later positive requirement.
282
+ return DENSE_GEOMETRY_HINTS.some((re) => Array.from(text.matchAll(new RegExp(re.source, `${re.flags}g`))).some((match) => {
283
+ const before = text.slice(0, match.index);
284
+ const after = text.slice(match.index + match[0].length);
285
+ const negatedBefore = /(?:\b(?:no|not|without|avoid|exclude)\s+(?:(?:a|an|any|the)\s+)?|\b(?:do\s+not|don't|does\s+not|doesn't)\s+(?:need|want|require|use)\s+(?:(?:a|an|any|the)\s+)?|\bno\s+need\s+for\s+(?:(?:a|an|any|the)\s+)?|(?:不要|不需要|无需|不用|不使用|避免|非)\s*)$/i.test(before);
286
+ const negatedAfter = /^\s*(?:(?:mesh|geometry|topology)\s+)?(?:is\s+not|isn't|not)\s+(?:needed|required|wanted)\b/i.test(after)
287
+ || /^\s*(?:不需要|不要|无需)/.test(after);
288
+ return !negatedBefore && !negatedAfter;
289
+ }));
290
+ }
@@ -5,14 +5,96 @@
5
5
  * Every rule here prevents a guaranteed 400 or a silently wasted credit.
6
6
  * Rules marked [hard] mirror server enforcement; [soft] are CLI smart defaults.
7
7
  */
8
- import { CONVERT_FORMATS, DECIMATE_MODELS, EXPORT_ORIENTATIONS, FBX_PRESETS, MODEL_P2, P1_FACE_LIMIT, P1_FORBIDDEN_PARAMS, P2_FACE_LIMIT, P2_FORBIDDEN_PARAMS, TEXTURE_FORMATS, TEXTURE_MODEL_V35, TEXTURE_QUALITIES, isP1, isP2, normalizeModel, } from './models.js';
8
+ import { CAPABILITY_DEFAULTS, IMAGE_TO_IMAGE_MODELS, TEXT_TO_IMAGE_MODELS, CONVERT_FORMATS, DECIMATE_MODELS, EXPORT_ORIENTATIONS, FBX_PRESETS, LEGACY_3D_MODELS, MODEL_P1, MODEL_P2, MODEL_V31, P1_FACE_LIMIT, P1_FORBIDDEN_PARAMS, P2_FACE_LIMIT, P2_FORBIDDEN_PARAMS, TEXTURE_FORMATS, TEXTURE_MODEL_V35, TEXTURE_QUALITIES, isP1, isP2, normalizeModel, } from './models.js';
9
9
  const PROMPT_MAX = 1024;
10
10
  const NEGATIVE_PROMPT_MAX = 255;
11
+ /** Image endpoint contracts; never silently substitute a paid model. */
12
+ export function validateImageParams(input, editing) {
13
+ const params = { ...input };
14
+ const warnings = [];
15
+ const errors = [];
16
+ const model = params.model === undefined
17
+ ? (editing ? CAPABILITY_DEFAULTS.imageToImage : CAPABILITY_DEFAULTS.textToImage)
18
+ : params.model;
19
+ params.model = model;
20
+ const models = editing ? IMAGE_TO_IMAGE_MODELS : TEXT_TO_IMAGE_MODELS;
21
+ if (model === 'chat_image_1') {
22
+ errors.push('model chat_image_1 retired on 2026-10-23; explicitly choose chat_image_2 or chat_image_2.5_flare/sunburst (no automatic resubmission)');
23
+ }
24
+ else if (!models.includes(model)) {
25
+ errors.push(`unsupported image model "${String(model)}" for ${editing ? 'image-to-image' : 'text-to-image'}`);
26
+ }
27
+ const chat25 = model === 'chat_image_2.5_flare' || model === 'chat_image_2.5_sunburst';
28
+ if (params.size !== undefined && typeof model === 'string' && !validImageSize(model, params.size)) {
29
+ errors.push(`size is unsupported for model ${model}`);
30
+ }
31
+ if ((model === 'seedream_v4' || model === 'seedream_v5') && typeof params.size === 'string' && params.size.includes('x')) {
32
+ warnings.push('Seedream exact pixel-count limits unverified: official MP ranges are rounded; server validation still applies');
33
+ }
34
+ if (params.quality !== undefined) {
35
+ const qualities = chat25 ? ['low', 'medium', 'high', 'xhigh', 'max']
36
+ : model === 'chat_image_2' ? ['low', 'medium', 'high'] : [];
37
+ if (!qualities.includes(params.quality))
38
+ errors.push(`quality is unsupported for model ${String(model)} (auto is not supported)`);
39
+ }
40
+ if (params.output_format !== undefined && !['png', 'jpeg'].includes(params.output_format)) {
41
+ errors.push('output_format must be png or jpeg');
42
+ }
43
+ if (params.background !== undefined) {
44
+ if (!chat25) {
45
+ delete params.background;
46
+ warnings.push('background is ignored outside chat_image_2.5 models (removed)');
47
+ }
48
+ else if (!['auto', 'opaque', 'transparent'].includes(params.background)) {
49
+ errors.push('background must be auto, opaque or transparent');
50
+ }
51
+ else if (params.background === 'transparent' && params.output_format !== undefined && params.output_format !== 'png') {
52
+ errors.push('background=transparent requires output_format=png');
53
+ }
54
+ }
55
+ return { params, warnings, errors };
56
+ }
57
+ /** Only documented size rules; rounded Seedream MP limits are not exact pixel thresholds. */
58
+ function validImageSize(model, size) {
59
+ if (typeof size !== 'string')
60
+ return false;
61
+ const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(size);
62
+ const w = Number(match?.[1]);
63
+ const h = Number(match?.[2]);
64
+ const pixels = !!match && Number.isSafeInteger(w) && Number.isSafeInteger(h);
65
+ if (model === 'banana') {
66
+ return ['1024x1024', '832x1248', '1248x832', '864x1184', '1184x864', '896x1152', '1152x896', '768x1344', '1344x768', '1536x672'].includes(size);
67
+ }
68
+ if (model === 'banana2')
69
+ return ['0.5K', '1K', '2K', '4K'].includes(size);
70
+ if (model === 'banana_pro')
71
+ return ['1K', '2K', '4K'].includes(size) || pixels;
72
+ if (model === 'seedream_v4' || model === 'seedream_v5') {
73
+ return ['2K', model === 'seedream_v5' ? '3K' : '4K'].includes(size) || (pixels && w / h >= 1 / 16 && w / h <= 16);
74
+ }
75
+ if (model === 'chat_image_1.5')
76
+ return ['1024x1024', '1024x1536', '1536x1024', 'auto'].includes(size);
77
+ if (model === 'chat_image_2' || model.startsWith('chat_image_2.5_')) {
78
+ return size === 'auto' || (pixels && w <= 3840 && h <= 3840 && w % 16 === 0 && h % 16 === 0 &&
79
+ Math.max(w, h) <= 3 * Math.min(w, h) && w * h >= 655360 && w * h <= 8294400);
80
+ }
81
+ return true; // Unknown models are rejected separately.
82
+ }
83
+ function validateFaceLimit(params, errors) {
84
+ if (params.face_limit !== undefined &&
85
+ (typeof params.face_limit !== 'number' || !Number.isFinite(params.face_limit) || !Number.isInteger(params.face_limit))) {
86
+ errors.push('face_limit must be a finite integer');
87
+ }
88
+ }
11
89
  /** Validate + auto-fix parameters for 3D generation (text/image/multiview-to-model). */
12
90
  export function validateGenerationParams(model, input) {
13
91
  const params = { ...input };
14
92
  const warnings = [];
15
93
  const errors = [];
94
+ if (![MODEL_V31, MODEL_P1, MODEL_P2, ...LEGACY_3D_MODELS].includes(normalizeModel(model))) {
95
+ errors.push(`unsupported 3D model "${model}"`);
96
+ }
97
+ validateFaceLimit(params, errors);
16
98
  if (typeof params.prompt === 'string' && params.prompt.length > PROMPT_MAX) {
17
99
  errors.push(`prompt exceeds ${PROMPT_MAX} characters (${params.prompt.length})`);
18
100
  }
@@ -44,6 +126,11 @@ export function validateGenerationParams(model, input) {
44
126
  params.texture = true;
45
127
  warnings.push('pbr=true forces texture=true (adjusted)');
46
128
  }
129
+ // [hard] pbr defaults to true server-side, so texture=false alone would still be textured.
130
+ if (params.texture === false && params.pbr === undefined) {
131
+ params.pbr = false;
132
+ warnings.push('texture=false implies pbr=false (adjusted)');
133
+ }
47
134
  // [hard] generate_parts outputs bare geometry: texture/pbr/quad must be off
48
135
  // (texture and pbr default to true server-side, so omitting them counts as on).
49
136
  if (params.generate_parts === true) {
@@ -60,7 +147,7 @@ export function validateGenerationParams(model, input) {
60
147
  if (typeof face === 'number') {
61
148
  if (face < P1_FACE_LIMIT.min || face > P1_FACE_LIMIT.max) {
62
149
  errors.push(`P1 face_limit must be within ${P1_FACE_LIMIT.min}–${P1_FACE_LIMIT.max} (got ${face}); ` +
63
- `use tripo-v3.1 for higher budgets`);
150
+ `use tripo-v3.1 for budgets outside this range`);
64
151
  }
65
152
  }
66
153
  }
@@ -72,7 +159,7 @@ export function validateGenerationParams(model, input) {
72
159
  const max = quad ? P2_FACE_LIMIT.quadMax : P2_FACE_LIMIT.triMax;
73
160
  if (face < P2_FACE_LIMIT.min || face > max) {
74
161
  errors.push(`P2 face_limit must be within ${P2_FACE_LIMIT.min}–${max}${quad ? ' with quad=true' : ''} (got ${face}); ` +
75
- `use tripo-v3.1 for higher budgets`);
162
+ `use tripo-v3.1 for budgets outside this range`);
76
163
  }
77
164
  }
78
165
  }
@@ -85,10 +172,6 @@ export function validateGenerationParams(model, input) {
85
172
  }
86
173
  }
87
174
  }
88
- if (params.texture_quality !== undefined &&
89
- !TEXTURE_QUALITIES.includes(params.texture_quality)) {
90
- errors.push(`texture_quality must be one of ${TEXTURE_QUALITIES.join('/')}`);
91
- }
92
175
  applyTextureV35(params, 'texture_version', warnings, errors);
93
176
  // [hard] quad topology cannot be stored in GLB → output will be FBX.
94
177
  if (params.quad === true) {
@@ -103,6 +186,9 @@ export function validateGenerationParams(model, input) {
103
186
  * generation endpoints and `model` on /v3/models/texture.
104
187
  */
105
188
  export function applyTextureV35(params, key, warnings, errors) {
189
+ if (params.texture_quality !== undefined && !TEXTURE_QUALITIES.includes(params.texture_quality)) {
190
+ errors.push(`texture_quality must be one of ${TEXTURE_QUALITIES.join('/')}`);
191
+ }
106
192
  const feature = params.texture_quality === 'fast' ? 'texture_quality=fast' : params.delight !== undefined ? 'delight' : undefined;
107
193
  if (!feature)
108
194
  return;
@@ -123,6 +209,7 @@ export function validateConvertParams(input) {
123
209
  const params = { ...input };
124
210
  const warnings = [];
125
211
  const errors = [];
212
+ validateFaceLimit(params, errors);
126
213
  const format = typeof params.format === 'string' ? params.format.toUpperCase() : undefined;
127
214
  if (!format) {
128
215
  errors.push(`convert requires format (${CONVERT_FORMATS.join('/')})`);
@@ -199,6 +286,7 @@ export function validateDecimateParams(input) {
199
286
  const params = { ...input };
200
287
  const warnings = [];
201
288
  const errors = [];
289
+ validateFaceLimit(params, errors);
202
290
  const model = typeof params.model === 'string' ? params.model.toLowerCase() : undefined;
203
291
  if (model !== undefined && model !== 'default' && !DECIMATE_MODELS.includes(model)) {
204
292
  errors.push(`decimate model must be ${DECIMATE_MODELS.join('/')} (got "${String(params.model)}")`);
@@ -207,7 +295,7 @@ export function validateDecimateParams(input) {
207
295
  if (params.smart_uv === true) {
208
296
  if (model === undefined) {
209
297
  params.model = 'v3.0';
210
- warnings.push('smart_uv requires decimate v3.0: using model=v3.0 (35 credits tri / 40 quad; v2.0 is 30)');
298
+ warnings.push('smart_uv requires decimate v3.0: using model=v3.0 (cost not estimated; check current regional pricing)');
211
299
  }
212
300
  else if (model !== 'v3.0') {
213
301
  errors.push(`smart_uv only works with decimate model v3.0 (got "${String(params.model)}")`);
@@ -2,105 +2,82 @@
2
2
  * Scenario decision matrix: --for presets and the AI wizard both read this
3
3
  * table. Changing production strategy means editing data here, not logic.
4
4
  */
5
- import { CAPABILITY_DEFAULTS, MODEL_P1, MODEL_V31 } from './models.js';
5
+ import { CAPABILITY_DEFAULTS, MODEL_P2, MODEL_V31 } from './models.js';
6
6
  export const SCENARIOS = [
7
7
  {
8
8
  id: 'game-mobile',
9
- label: { zh: '手游/低模', en: 'Mobile game / low-poly' },
10
- description: { zh: '移动端游戏资产,面数预算严格,干净拓扑', en: 'Mobile game assets with strict face budgets and clean topology' },
11
- model: MODEL_P1,
9
+ label: 'Mobile game / low-poly',
10
+ description: 'Mobile game assets with strict face budgets and clean topology',
11
+ model: MODEL_P2,
12
12
  genParams: { texture: true, pbr: true, texture_quality: 'standard', face_limit: 15000 },
13
13
  chain: [{ step: 'convert', args: { format: 'FBX', texture_size: 2048 } }],
14
14
  deliverables: ['FBX', 'GLB'],
15
- notes: {
16
- zh: ['P1 原生低模,拓扑干净', '2K 贴图适配移动端', '需要骨骼时追加 --then rig'],
17
- en: ['P1 native low-poly with clean topology', '2K textures for mobile budgets', 'append --then rig if you need a skeleton'],
18
- },
15
+ notes: ['P2 editable low-poly topology', '2K textures for mobile budgets', 'P2 costs more credits than H/P1; check credits before running', 'append --then rig if you need a skeleton'],
19
16
  },
20
17
  {
21
18
  id: 'game-pc',
22
- label: { zh: '端游/主机', en: 'PC / console game' },
23
- description: { zh: '高预算引擎资产,PBR 全开', en: 'High-budget engine assets with full PBR' },
24
- model: MODEL_V31,
19
+ label: 'PC / console game',
20
+ description: 'Editable real-time engine assets with full PBR',
21
+ model: MODEL_P2,
25
22
  genParams: { texture: true, pbr: true, texture_quality: 'detailed' },
26
23
  chain: [{ step: 'convert', args: { format: 'FBX' } }],
27
24
  deliverables: ['FBX', 'GLTF'],
28
- notes: {
29
- zh: ['v3.1 高精度 + detailed 贴图', '需要 LOD 时追加 --then decimate:5000', '进 Blender/3dsMax 用 fbx_preset 对齐'],
30
- en: ['v3.1 high fidelity + detailed textures', 'append --then decimate:5000 for LODs', 'use fbx_preset to match Blender/3dsMax'],
31
- },
25
+ notes: ['P2 editable topology + detailed textures', 'append --then decimate:5000 for LODs', 'P2 costs more credits than H/P1; check credits before running', 'use fbx_preset to match Blender/3dsMax'],
32
26
  },
33
27
  {
34
28
  id: 'film',
35
- label: { zh: '影视/离线渲染', en: 'Film / offline rendering' },
36
- description: { zh: '细分友好四边面,规格拉满', en: 'Subdivision-friendly quads, maximum quality' },
29
+ label: 'Film / offline rendering',
30
+ description: 'Subdivision-friendly quads, maximum quality',
37
31
  model: MODEL_V31,
38
32
  genParams: { texture: true, pbr: true, quad: true, geometry_quality: 'detailed', texture_quality: 'detailed' },
39
33
  chain: [],
40
34
  deliverables: ['FBX (quad)', 'USDZ/OBJ via convert'],
41
- notes: {
42
- zh: ['quad=true 输出 FBX(quad 进不了 GLB)', 'geometry detailed 加收 HD credit', '进 USD 管线追加 --then convert:usdz'],
43
- en: ['quad=true outputs FBX (quads cannot be stored in GLB)', 'geometry detailed bills an extra HD credit', 'append --then convert:usdz for USD pipelines'],
44
- },
35
+ notes: ['quad=true outputs FBX (quads cannot be stored in GLB)', 'geometry detailed bills an extra HD credit', 'append --then convert:usdz for USD pipelines'],
45
36
  },
46
37
  {
47
38
  id: 'print',
48
- label: { zh: '3D 打印', en: '3D printing' },
49
- description: { zh: '水密可打印几何,免贴图省积分', en: 'Watertight printable geometry, skipping textures saves credits' },
39
+ label: '3D printing',
40
+ description: 'Watertight printable geometry, skipping textures saves credits',
50
41
  model: MODEL_V31,
51
42
  genParams: { texture: false, pbr: false },
52
43
  chain: [{ step: 'convert', args: { format: 'STL', flatten_bottom: true } }],
53
- deliverables: ['STL', '3MF (colored)'],
54
- notes: {
55
- zh: ['白模生成,不花贴图积分', 'flatten_bottom 平底利于 FDM', '彩色打印改 --then convert:3mf,export_vertex_colors=true'],
56
- en: ['bare geometry: no texture credits spent', 'flatten_bottom helps FDM adhesion', 'for color prints use --then convert:3mf,export_vertex_colors=true'],
57
- },
44
+ deliverables: ['STL'],
45
+ notes: ['bare geometry: no texture credits spent', 'flatten_bottom helps FDM adhesion', 'for color prints add -p texture=true and --then convert:format=OBJ,export_vertex_colors=true (API 3MF output is single color)'],
58
46
  },
59
47
  {
60
48
  id: 'ar-web',
61
- label: { zh: 'AR/Web/电商', en: 'AR / Web / e-commerce' },
62
- description: { zh: '移动端性能预算,glb+usdz 双交付', en: 'Mobile performance budgets with dual glb+usdz delivery' },
63
- model: MODEL_V31,
64
- genParams: { texture: true, pbr: true },
65
- chain: [
66
- { step: 'decimate', args: { face_limit: 20000 } },
67
- { step: 'convert', args: { format: 'USDZ' } },
68
- ],
49
+ label: 'AR / Web / e-commerce',
50
+ description: 'Mobile performance budgets with dual glb+usdz delivery',
51
+ model: MODEL_P2,
52
+ genParams: { texture: true, pbr: true, face_limit: 20000 },
53
+ chain: [{ step: 'convert', args: { format: 'USDZ' } }],
54
+ downloadGeneration: true,
69
55
  deliverables: ['GLB', 'USDZ'],
70
- notes: {
71
- zh: ['v3.1 生成后减面到 2 万内', 'iOS QuickLook 需要 USDZ', 'decimate 默认 bake 烘焙贴图到低模'],
72
- en: ['generate with v3.1, then decimate under 20k faces', 'iOS QuickLook requires USDZ', 'decimate bakes textures onto the low-poly by default'],
73
- },
56
+ notes: ['P2 generates under 20k faces directly (no decimate step)', 'iOS QuickLook requires USDZ', 'P2 costs more credits than H/P1; check credits before running', 'downloads both the generated GLB and the USDZ'],
74
57
  },
75
58
  {
76
59
  id: 'anim',
77
- label: { zh: '角色动画', en: 'Character animation' },
78
- description: { zh: '绑骨+动作重定向全链路', en: 'Full rig + retarget pipeline' },
79
- model: MODEL_V31,
60
+ label: 'Character animation',
61
+ description: 'Full rig + retarget pipeline',
62
+ model: MODEL_P2,
80
63
  genParams: { texture: true, pbr: true },
81
64
  chain: [
82
65
  { step: 'rig-check', args: {} },
83
- { step: 'rig', args: { model: CAPABILITY_DEFAULTS.rig, spec: 'tripo', out_format: 'glb' } },
66
+ { step: 'rig', args: { model: CAPABILITY_DEFAULTS.rig, out_format: 'glb' } },
84
67
  { step: 'retarget', args: { animations: ['preset:idle', 'preset:walk'] } },
85
68
  ],
86
69
  deliverables: ['GLB', 'FBX'],
87
- notes: {
88
- zh: ['先 rig-check 预检,不可绑骨即止损', '接 Mixamo 用 rig:spec=mixamo,Unity Humanoid / Unreal 用 spec=unity / spec=unreal', '提示词建议 T-pose 站姿,或先 text-to-image template=t_pose'],
89
- en: ['rig-check first: stop early if not riggable', 'use rig:spec=mixamo for Mixamo, spec=unity / spec=unreal for Unity Humanoid / Unreal', 'prompt for a T-pose, or pre-generate with text-to-image template=t_pose'],
90
- },
70
+ notes: ['P2 costs more credits than H/P1; check credits before running', 'rig-check first: stop early if not riggable', 'use rig:spec=mixamo for Mixamo, spec=unity / spec=unreal for Unity Humanoid / Unreal', 'prompt for a T-pose, or pre-generate with text-to-image template=t_pose'],
91
71
  },
92
72
  {
93
73
  id: 'toy',
94
- label: { zh: '风格化摆件', en: 'Stylized toy / desk piece' },
95
- description: { zh: '低模可爱风摆件', en: 'Cute low-poly desk piece' },
96
- model: MODEL_P1,
74
+ label: 'Stylized toy / desk piece',
75
+ description: 'Cute low-poly desk piece',
76
+ model: MODEL_P2,
97
77
  genParams: { texture: true, pbr: true, face_limit: 20000 },
98
78
  chain: [],
99
79
  deliverables: ['GLB', 'STL via convert'],
100
- notes: {
101
- zh: ['P1 出干净低模', '要打印实物追加 --then convert:stl'],
102
- en: ['P1 produces clean low-poly', 'append --then convert:stl to print it'],
103
- },
80
+ notes: ['P2 produces editable low-poly', 'P2 costs more credits than H/P1; check credits before running', 'append --then convert:stl to print it'],
104
81
  },
105
82
  ];
106
83
  const byId = new Map(SCENARIOS.map((s) => [s.id, s]));