@forgeax-extension/character-3d 0.1.2

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 (110) hide show
  1. package/SOURCE_PROVENANCE.md +8 -0
  2. package/dist/assets/index-CIx3aXo1.js +4336 -0
  3. package/dist/assets/index-CIx3aXo1.js.map +1 -0
  4. package/dist/assets/index-CmhOHLEB.css +1 -0
  5. package/dist/hdr/README.md +28 -0
  6. package/dist/hdr/presets.json +8 -0
  7. package/dist/index.html +13 -0
  8. package/forgeax-extension.json +428 -0
  9. package/marketplace-card.json +56 -0
  10. package/package.json +59 -0
  11. package/packages/external-asset-meta/package.json +24 -0
  12. package/packages/external-asset-meta/src/cook.test.ts +211 -0
  13. package/packages/external-asset-meta/src/cook.ts +188 -0
  14. package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
  15. package/packages/external-asset-meta/src/index.ts +18 -0
  16. package/packages/external-asset-meta/src/normalize.test.ts +36 -0
  17. package/packages/external-asset-meta/src/normalize.ts +101 -0
  18. package/packages/external-asset-meta/src/types.ts +82 -0
  19. package/schemas/adopt-playable-character.args.json +26 -0
  20. package/schemas/adopt-playable-character.returns.json +31 -0
  21. package/schemas/apply-motion.args.json +47 -0
  22. package/schemas/apply-motion.returns.json +23 -0
  23. package/schemas/auto-rig.args.json +35 -0
  24. package/schemas/auto-rig.returns.json +23 -0
  25. package/schemas/delete-asset.args.json +18 -0
  26. package/schemas/delete-asset.returns.json +18 -0
  27. package/schemas/engine-import-status.args.json +18 -0
  28. package/schemas/engine-import-status.returns.json +50 -0
  29. package/schemas/export-playable-character.args.json +21 -0
  30. package/schemas/export-playable-character.returns.json +47 -0
  31. package/schemas/gen3d-asset-manifest.json +281 -0
  32. package/schemas/generate-meshy-text-mock.args.json +42 -0
  33. package/schemas/generate-meshy-text-mock.returns.json +301 -0
  34. package/schemas/get-credentials.args.json +7 -0
  35. package/schemas/get-credentials.returns.json +45 -0
  36. package/schemas/get-playable-profile.args.json +11 -0
  37. package/schemas/get-playable-profile.returns.json +188 -0
  38. package/schemas/image-to-3d.args.json +47 -0
  39. package/schemas/image-to-3d.returns.json +306 -0
  40. package/schemas/import-to-engine.args.json +18 -0
  41. package/schemas/import-to-engine.returns.json +35 -0
  42. package/schemas/list-assets.args.json +22 -0
  43. package/schemas/list-assets.returns.json +296 -0
  44. package/schemas/list-motions.args.json +29 -0
  45. package/schemas/list-motions.returns.json +41 -0
  46. package/schemas/pose-standardization.args.json +28 -0
  47. package/schemas/pose-standardization.returns.json +29 -0
  48. package/schemas/provider-status.args.json +7 -0
  49. package/schemas/provider-status.returns.json +70 -0
  50. package/schemas/refine-mesh.args.json +33 -0
  51. package/schemas/refine-mesh.returns.json +306 -0
  52. package/schemas/rename-asset.args.json +22 -0
  53. package/schemas/rename-asset.returns.json +14 -0
  54. package/schemas/retopo-lowpoly.args.json +34 -0
  55. package/schemas/retopo-lowpoly.returns.json +24 -0
  56. package/schemas/score-quality.args.json +36 -0
  57. package/schemas/score-quality.returns.json +12 -0
  58. package/schemas/set-credentials.args.json +32 -0
  59. package/schemas/set-credentials.returns.json +45 -0
  60. package/schemas/set-playable-motion-mapping.args.json +25 -0
  61. package/schemas/set-playable-motion-mapping.returns.json +33 -0
  62. package/schemas/set-playable-profile.args.json +32 -0
  63. package/schemas/set-playable-profile.returns.json +67 -0
  64. package/schemas/text-to-3d.args.json +51 -0
  65. package/schemas/text-to-3d.returns.json +306 -0
  66. package/schemas/upload-image.args.json +19 -0
  67. package/schemas/upload-image.returns.json +21 -0
  68. package/schemas/upload-video.args.json +14 -0
  69. package/schemas/upload-video.returns.json +14 -0
  70. package/schemas/views-to-3d.args.json +58 -0
  71. package/schemas/views-to-3d.returns.json +306 -0
  72. package/server/adopt-playable-character.ts +291 -0
  73. package/server/asset-storage.ts +136 -0
  74. package/server/asset-upload.ts +123 -0
  75. package/server/audit.ts +58 -0
  76. package/server/cache.ts +75 -0
  77. package/server/cos-uploader.ts +141 -0
  78. package/server/credentials-store.ts +186 -0
  79. package/server/engine-import.ts +238 -0
  80. package/server/env.ts +287 -0
  81. package/server/export-playable-character.ts +364 -0
  82. package/server/generate.ts +89 -0
  83. package/server/merge-playable-character.ts +259 -0
  84. package/server/motion-catalog.ts +137 -0
  85. package/server/per-game-store.ts +1016 -0
  86. package/server/providers/gateway-client.ts +171 -0
  87. package/server/providers/gateway-data.ts +182 -0
  88. package/server/providers/gateway-models.ts +49 -0
  89. package/server/providers/hunyuan-rest.ts +317 -0
  90. package/server/providers/hunyuan-workflow.ts +225 -0
  91. package/server/providers/meshy-direct-client.ts +223 -0
  92. package/server/providers/meshy.ts +548 -0
  93. package/server/providers/rodin-gateway.ts +193 -0
  94. package/server/providers/rodin.ts +253 -0
  95. package/server/providers/visvise.ts +329 -0
  96. package/server/rate-guard.ts +29 -0
  97. package/server/tool-handlers.ts +2193 -0
  98. package/shared/catalog.ts +307 -0
  99. package/shared/manifest.ts +378 -0
  100. package/shared/meshy-actions.ts +690 -0
  101. package/shared/playable-preview-url.test.ts +14 -0
  102. package/shared/playable-preview-url.ts +13 -0
  103. package/shared/playable-profile.test.ts +127 -0
  104. package/shared/playable-profile.ts +171 -0
  105. package/shared/provider-params.test.ts +172 -0
  106. package/shared/provider-params.ts +389 -0
  107. package/shared/quality/heuristics.test.ts +84 -0
  108. package/shared/quality/heuristics.ts +100 -0
  109. package/shared/rodin-image.test.ts +51 -0
  110. package/shared/rodin-image.ts +125 -0
@@ -0,0 +1,389 @@
1
+ import type { GenerationMode } from './manifest';
2
+
3
+ export type ProviderKey = 'hunyuan_workflow' | 'meshy' | 'rodin' | 'visvise';
4
+
5
+ /** Official Weaver demo names (docs §3.4 / §3.5). Gateway requires the field; empty 400s. */
6
+ export const VISVISE_DEFAULT_ALGORITHM_MODEL = {
7
+ standard: 'hunyuan3D-3.5-0515',
8
+ mid: 'VISVISE-MeshGen-V1.0.0',
9
+ } as const;
10
+
11
+ export function defaultVisviseAlgorithmModel(quality?: string): string {
12
+ return quality === 'mid'
13
+ ? VISVISE_DEFAULT_ALGORITHM_MODEL.mid
14
+ : VISVISE_DEFAULT_ALGORITHM_MODEL.standard;
15
+ }
16
+
17
+ export type ParamType = 'enum' | 'bool' | 'int' | 'text';
18
+
19
+ export interface ParamOption {
20
+ value: string;
21
+ label: string;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ export interface ParamField {
26
+ key: string;
27
+ label: string;
28
+ type: ParamType;
29
+ options?: ParamOption[];
30
+ min?: number;
31
+ max?: number;
32
+ default?: string | number | boolean;
33
+ help?: string;
34
+ appliesToModes: GenerationMode[];
35
+ verified: boolean;
36
+ }
37
+
38
+ export const providerParamSpec: Record<ProviderKey, ParamField[]> = {
39
+ meshy: [
40
+ {
41
+ key: 'ai_model',
42
+ label: '模型版本',
43
+ type: 'enum',
44
+ options: [
45
+ { value: 'meshy-6', label: 'Meshy 6(标准)' },
46
+ { value: 'meshy-5', label: 'Meshy 5(标准)' },
47
+ { value: 'meshy-t2', label: 'Meshy T2(Smart Topology)' },
48
+ { value: 'meshy-t1', label: 'Meshy T1(Smart Topology 旧)' },
49
+ ],
50
+ default: 'meshy-6',
51
+ help: '选 Smart Topology 网格类型时用 T2/T1;标准网格用 Meshy 5/6',
52
+ appliesToModes: ['text', 'image', 'views'],
53
+ verified: true,
54
+ },
55
+ {
56
+ key: 'model_type',
57
+ label: '网格类型',
58
+ type: 'enum',
59
+ options: [
60
+ { value: 'standard', label: '标准' },
61
+ { value: 'smart-topology', label: 'Smart Topology(控面)' },
62
+ { value: 'lowpoly', label: '低面数(已弃用)' },
63
+ ],
64
+ help: 'Smart Topology:用上方目标面数档控面(约 100–15k,推荐 T2);会忽略重建网格/拓扑。lowpoly 已弃用。',
65
+ appliesToModes: ['image'],
66
+ verified: true,
67
+ },
68
+ {
69
+ key: 'hd_texture',
70
+ label: '4K 贴图 (hd_texture)',
71
+ type: 'bool',
72
+ help: '仅 Meshy 6 / latest;贴图 4096²,更清晰也更耗额度',
73
+ appliesToModes: ['text', 'image', 'views'],
74
+ verified: true,
75
+ },
76
+ {
77
+ key: 'texture_prompt',
78
+ label: '贴图提示词',
79
+ type: 'text',
80
+ help: '文生走 refine;图/多视图直接贴图。留空则文生沿用主 prompt',
81
+ appliesToModes: ['text', 'image', 'views'],
82
+ verified: true,
83
+ },
84
+ {
85
+ key: 'texture_image_url',
86
+ label: '贴图参考图 URL',
87
+ type: 'text',
88
+ help: '公开可访问的贴图参考图(PNG/JPG);文生 refine 或图/多视图贴图引导',
89
+ appliesToModes: ['text', 'image', 'views'],
90
+ verified: true,
91
+ },
92
+ {
93
+ key: 'remove_lighting',
94
+ label: '去除贴图光照 (remove_lighting)',
95
+ type: 'bool',
96
+ help: 'Meshy 6:去掉高光/阴影,便于引擎重打光;官方默认开启,此处显式可控',
97
+ appliesToModes: ['text', 'image', 'views'],
98
+ verified: true,
99
+ },
100
+ {
101
+ key: 'should_remesh',
102
+ label: '重建网格',
103
+ type: 'bool',
104
+ appliesToModes: ['text', 'image', 'views'],
105
+ verified: true,
106
+ },
107
+ {
108
+ key: 'topology',
109
+ label: '拓扑',
110
+ type: 'enum',
111
+ options: [
112
+ { value: 'triangle', label: '三角面' },
113
+ { value: 'quad', label: '四边面' },
114
+ ],
115
+ help: '仅在「重建网格」开启时生效;Smart Topology 下忽略',
116
+ appliesToModes: ['text', 'image', 'views'],
117
+ verified: true,
118
+ },
119
+ {
120
+ key: 'decimation_mode',
121
+ label: '自适应减面',
122
+ type: 'enum',
123
+ options: [
124
+ { value: '1', label: '超高' },
125
+ { value: '2', label: '高' },
126
+ { value: '3', label: '中' },
127
+ { value: '4', label: '低' },
128
+ ],
129
+ help: '设置后覆盖「目标面数」;Smart Topology 下勿用',
130
+ appliesToModes: ['text', 'image', 'views'],
131
+ verified: true,
132
+ },
133
+ {
134
+ key: 'pose_mode',
135
+ label: '姿态模式',
136
+ type: 'enum',
137
+ options: [
138
+ { value: 'a-pose', label: 'A-pose' },
139
+ { value: 't-pose', label: 'T-pose' },
140
+ ],
141
+ appliesToModes: ['text', 'image', 'views'],
142
+ verified: true,
143
+ },
144
+ ],
145
+ // Gateway body stays { prompt, image_url }. Volcengine knobs are appended
146
+ // to prompt as --flags (colleague 2026-08-16). Keys match the official flags
147
+ // so the same providerParams bag works for the sidebar and future agents.
148
+ rodin: [
149
+ {
150
+ key: 'mesh_mode',
151
+ label: '网格',
152
+ type: 'enum',
153
+ options: [
154
+ { value: 'Quad', label: '四边面' },
155
+ { value: 'Raw', label: '三角面' },
156
+ ],
157
+ help: '不选则用官方默认四边面。',
158
+ appliesToModes: ['text', 'image', 'views'],
159
+ verified: true,
160
+ },
161
+ {
162
+ key: 'subdivisionlevel',
163
+ label: '面数档',
164
+ type: 'enum',
165
+ options: [
166
+ { value: 'high', label: '高' },
167
+ { value: 'medium', label: '中' },
168
+ { value: 'low', label: '低' },
169
+ ],
170
+ help: '一般选中。选了网格后会显示大约多少面。',
171
+ appliesToModes: ['text', 'image', 'views'],
172
+ verified: true,
173
+ },
174
+ {
175
+ key: 'material',
176
+ label: '材质',
177
+ type: 'enum',
178
+ options: [
179
+ { value: 'PBR', label: 'PBR' },
180
+ { value: 'Shaded', label: '风格化' },
181
+ { value: 'All', label: '两种都要' },
182
+ { value: 'None', label: '白模' },
183
+ ],
184
+ help: '不选则用官方默认 PBR。',
185
+ appliesToModes: ['text', 'image', 'views'],
186
+ verified: true,
187
+ },
188
+ {
189
+ key: 'TAPose',
190
+ label: '标准绑定姿态',
191
+ type: 'bool',
192
+ help: '勾选后强制 T/A 姿态(具体哪种由模型定)。适合之后要绑骨的角色。',
193
+ appliesToModes: ['text', 'image', 'views'],
194
+ verified: true,
195
+ },
196
+ {
197
+ key: 'hd_texture',
198
+ label: '高清纹理',
199
+ type: 'bool',
200
+ help: '对应 --hd_texture。勾选才传。',
201
+ appliesToModes: ['text', 'image', 'views'],
202
+ verified: true,
203
+ },
204
+ {
205
+ key: 'addons',
206
+ label: '4K 贴图',
207
+ type: 'bool',
208
+ help: '对应 --addons HighPack。勾选才传。Agent 也可传 addons: "HighPack"。',
209
+ appliesToModes: ['text', 'image', 'views'],
210
+ verified: true,
211
+ },
212
+ {
213
+ key: 'use_original_alpha',
214
+ label: '保留原图透明',
215
+ type: 'bool',
216
+ help: '只对图生 / 多视图有意义。勾选后按原图透明轮廓裁。',
217
+ appliesToModes: ['image', 'views'],
218
+ verified: true,
219
+ },
220
+ ],
221
+ // Hunyuan advanced knobs are mostly top-level (face_count / enable_pbr /
222
+ // enableFbxUrl). Keep this empty so the Meshy-style adv panel stays clean;
223
+ // enableFbxUrl is a dedicated SetupSidebar checkbox (typed on LiteLLM).
224
+ hunyuan_workflow: [],
225
+ visvise: [
226
+ {
227
+ key: 'quality',
228
+ label: 'VISVISE 质量档',
229
+ type: 'enum',
230
+ options: [
231
+ { value: 'standard', label: '标准' },
232
+ { value: 'mid', label: '中(仅图生)' },
233
+ { value: 'low', label: '低(暂未开放)', disabled: true },
234
+ ],
235
+ default: 'standard',
236
+ help: '标准=图生高模。中档网关要正/背/左/右四张图(或 360/拆分资产),不是单图降质量。低档官网未开放。',
237
+ appliesToModes: ['text', 'image'],
238
+ verified: true,
239
+ },
240
+ {
241
+ key: 'algorithm_model',
242
+ label: '算法模型',
243
+ type: 'text',
244
+ default: VISVISE_DEFAULT_ALGORITHM_MODEL.standard,
245
+ help: '网关必填。留空时标准档发 hunyuan3D-3.5-0515,中档发 VISVISE-MeshGen-V1.0.0(官网示例)。可改成你账号 list_algorithm_model 里的名字。',
246
+ appliesToModes: ['text', 'image'],
247
+ verified: true,
248
+ },
249
+ {
250
+ key: 'enable_a_pose',
251
+ label: '生成 A-pose',
252
+ type: 'bool',
253
+ help: '官网 enable_a_pose。勾选才传。',
254
+ appliesToModes: ['text', 'image'],
255
+ verified: true,
256
+ },
257
+ {
258
+ key: 'face_type',
259
+ label: '面类型',
260
+ type: 'enum',
261
+ options: [
262
+ { value: '1', label: '三角面' },
263
+ { value: '2', label: '四边面' },
264
+ ],
265
+ help: '官网 face_type。不选就不传。',
266
+ appliesToModes: ['text', 'image'],
267
+ verified: true,
268
+ },
269
+ {
270
+ key: 'face_num',
271
+ label: '目标面数',
272
+ type: 'int',
273
+ min: 1000,
274
+ max: 1_500_000,
275
+ help: '官网 face_num,仅标准档(高模)。中档传了无效,不会下发。',
276
+ appliesToModes: ['text', 'image'],
277
+ verified: true,
278
+ },
279
+ {
280
+ key: 'enable_pbr',
281
+ label: 'PBR 材质',
282
+ type: 'bool',
283
+ help: '官网 enable_pbr,仅标准档,且只对混元算法高模有效。',
284
+ appliesToModes: ['text', 'image'],
285
+ verified: true,
286
+ },
287
+ ],
288
+ };
289
+
290
+ const clampInt = (n: number, min?: number, max?: number): number => {
291
+ let v = Math.round(n);
292
+ if (min !== undefined) v = Math.max(min, v);
293
+ if (max !== undefined) v = Math.min(max, v);
294
+ return v;
295
+ };
296
+
297
+ export function filterProviderParams(
298
+ provider: ProviderKey,
299
+ mode: GenerationMode,
300
+ raw: Record<string, unknown> | undefined,
301
+ ): Record<string, string | number | boolean> {
302
+ const out: Record<string, string | number | boolean> = {};
303
+ if (!raw) return out;
304
+ for (const f of providerParamSpec[provider] ?? []) {
305
+ if (!f.verified) continue;
306
+ if (!f.appliesToModes.includes(mode)) continue;
307
+ const v = raw[f.key];
308
+ if (v === undefined || v === null) continue;
309
+ if (f.type === 'bool') {
310
+ if (typeof v === 'boolean') out[f.key] = v;
311
+ else if (f.key === 'addons' && v === 'HighPack') out[f.key] = true;
312
+ } else if (f.type === 'int') {
313
+ const n = typeof v === 'number' ? v : Number(v);
314
+ if (Number.isFinite(n)) out[f.key] = clampInt(n, f.min, f.max);
315
+ } else if (f.type === 'enum') {
316
+ if (typeof v === 'string' && (f.options ?? []).some((o) => o.value === v && !o.disabled)) out[f.key] = v;
317
+ } else if (f.type === 'text') {
318
+ if (typeof v === 'string' && v.trim()) out[f.key] = v.trim();
319
+ }
320
+ }
321
+ // Official mid-model ignores face_num; enable_pbr is high+Hunyuan only.
322
+ if (provider === 'visvise' && out.quality === 'mid') {
323
+ delete out.face_num;
324
+ delete out.enable_pbr;
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /** One short hint under 高/中/低. Numbers only after mesh is chosen. */
330
+ export function rodinSubdivisionLevelHelp(meshMode?: string): string {
331
+ if (meshMode === 'Quad') return '大约 5万 / 1.8万 / 8千 面';
332
+ if (meshMode === 'Raw') return '大约 50万 / 15万 / 2万 面';
333
+ return '一般选中。选了网格后会显示大约多少面。';
334
+ }
335
+
336
+ /** Volcengine prompt budget, flags included. */
337
+ export const RODIN_PROMPT_CHAR_LIMIT = 400;
338
+ export const RODIN_IMAGE_FLAG_STUB = 'Generate a 3D model.';
339
+
340
+ const RODIN_MESH_MODES = new Set(['Quad', 'Raw']);
341
+ const RODIN_LEVELS = new Set(['high', 'medium', 'low']);
342
+ const RODIN_MATERIALS = new Set(['PBR', 'Shaded', 'All', 'None']);
343
+
344
+ /** Build `--flag` tokens from filtered Rodin providerParams. Unset keys omitted. */
345
+ export function buildRodinPromptFlags(
346
+ params: Record<string, string | number | boolean>,
347
+ mode: GenerationMode,
348
+ ): string[] {
349
+ const flags: string[] = [];
350
+ if (typeof params.mesh_mode === 'string' && RODIN_MESH_MODES.has(params.mesh_mode)) {
351
+ flags.push(`--mesh_mode ${params.mesh_mode}`);
352
+ }
353
+ if (typeof params.subdivisionlevel === 'string' && RODIN_LEVELS.has(params.subdivisionlevel)) {
354
+ flags.push(`--sl ${params.subdivisionlevel}`);
355
+ }
356
+ if (typeof params.material === 'string' && RODIN_MATERIALS.has(params.material)) {
357
+ flags.push(`--material ${params.material}`);
358
+ }
359
+ if (params.hd_texture === true) flags.push('--hd_texture true');
360
+ if (params.addons === true || params.addons === 'HighPack') flags.push('--addons HighPack');
361
+ if (params.TAPose === true) flags.push('--TAPose true');
362
+ if ((mode === 'image' || mode === 'views') && params.use_original_alpha === true) {
363
+ flags.push('--use_original_alpha true');
364
+ }
365
+ return flags;
366
+ }
367
+
368
+ /**
369
+ * Append Rodin --flags to a user/agent prompt. Image with flags and no text
370
+ * gets a short English stub (DMXAPI example). Over-budget text is trimmed;
371
+ * flags stay.
372
+ */
373
+ export function applyRodinPromptFlags(
374
+ prompt: string,
375
+ params: Record<string, string | number | boolean>,
376
+ mode: GenerationMode,
377
+ ): string {
378
+ const flags = buildRodinPromptFlags(params, mode);
379
+ const flagStr = flags.join(' ');
380
+ const trimmed = prompt.trim();
381
+ if (!flagStr) return trimmed;
382
+ const base = trimmed || (mode === 'image' || mode === 'views' ? RODIN_IMAGE_FLAG_STUB : '');
383
+ const sep = base ? ' ' : '';
384
+ const combined = `${base}${sep}${flagStr}`;
385
+ if (combined.length <= RODIN_PROMPT_CHAR_LIMIT) return combined;
386
+ const budget = RODIN_PROMPT_CHAR_LIMIT - sep.length - flagStr.length;
387
+ if (budget < 1) return flagStr.slice(0, RODIN_PROMPT_CHAR_LIMIT);
388
+ return `${base.slice(0, budget).trimEnd()}${sep}${flagStr}`;
389
+ }
@@ -0,0 +1,84 @@
1
+ import { test, expect } from 'bun:test';
2
+ import {
3
+ scoreObjective,
4
+ weightedTotal,
5
+ DEFAULT_WEIGHTS,
6
+ type ObjectiveMetrics,
7
+ } from './heuristics';
8
+
9
+ const base: ObjectiveMetrics = {
10
+ faces: 30000,
11
+ vertices: 15000,
12
+ degenerateFaceRatio: 0,
13
+ meshCount: 1,
14
+ missingNormals: false,
15
+ bboxAspectExtreme: false,
16
+ targetFaceCount: 30000,
17
+ hasUV: true,
18
+ maxTextureSize: 2048,
19
+ hasBaseColorMap: true,
20
+ hasMetalRoughMap: true,
21
+ hasNormalMap: true,
22
+ hasOcclusionMap: true,
23
+ hasEmissiveMap: true,
24
+ pbrApplicable: true,
25
+ };
26
+
27
+ test('clean on-budget PBR mesh scores high on all four objective dims', () => {
28
+ const s = scoreObjective(base);
29
+ expect(s.geometry).toBe(100);
30
+ expect(s.topology).toBe(100);
31
+ expect(s.texture).toBe(100);
32
+ expect(s.pbr).toBe(100);
33
+ });
34
+
35
+ test('degenerate triangles + missing normals tank geometry', () => {
36
+ const s = scoreObjective({ ...base, degenerateFaceRatio: 0.5, missingNormals: true });
37
+ // 100 - 0.5*60 - 20 = 50
38
+ expect(s.geometry).toBe(50);
39
+ });
40
+
41
+ test('topology is null without a target face count', () => {
42
+ expect(scoreObjective({ ...base, targetFaceCount: null }).topology).toBeNull();
43
+ });
44
+
45
+ test('topology penalizes budget overshoot', () => {
46
+ // faces double the target → fit = 1 - clamp(30000/30000) = 0 → 0
47
+ expect(scoreObjective({ ...base, faces: 60000 }).topology).toBe(0);
48
+ // 50% over → fit = 0.5 → 50
49
+ expect(scoreObjective({ ...base, faces: 45000 }).topology).toBe(50);
50
+ });
51
+
52
+ test('texture is null without UV or without a texture', () => {
53
+ expect(scoreObjective({ ...base, hasUV: false }).texture).toBeNull();
54
+ expect(scoreObjective({ ...base, maxTextureSize: 0 }).texture).toBeNull();
55
+ });
56
+
57
+ test('texture tiers by max resolution', () => {
58
+ expect(scoreObjective({ ...base, maxTextureSize: 1024 }).texture).toBe(80);
59
+ expect(scoreObjective({ ...base, maxTextureSize: 256 }).texture).toBe(40);
60
+ });
61
+
62
+ test('pbr is null when not applicable, weighted by maps otherwise', () => {
63
+ expect(scoreObjective({ ...base, pbrApplicable: false }).pbr).toBeNull();
64
+ // only base color (35) + normal (20) = 55
65
+ const s = scoreObjective({
66
+ ...base,
67
+ hasMetalRoughMap: false,
68
+ hasOcclusionMap: false,
69
+ hasEmissiveMap: false,
70
+ });
71
+ expect(s.pbr).toBe(55);
72
+ });
73
+
74
+ test('weightedTotal skips nulls and renormalizes', () => {
75
+ // two dims at 100 and 50, others null → (100+50)/2 = 75
76
+ const t = weightedTotal([
77
+ { value: 100, weight: DEFAULT_WEIGHTS.geometry },
78
+ { value: 50, weight: DEFAULT_WEIGHTS.topology },
79
+ { value: null, weight: DEFAULT_WEIGHTS.texture },
80
+ { value: null, weight: DEFAULT_WEIGHTS.pbr },
81
+ { value: null, weight: DEFAULT_WEIGHTS.prompt_fidelity },
82
+ ]);
83
+ expect(t).toBe(75);
84
+ });
@@ -0,0 +1,100 @@
1
+ // Pure objective quality heuristics (Phase A, no LLM, no DOM, no three import).
2
+ // Scale 0–100. The client extracts ObjectiveMetrics from a loaded GLB (see
3
+ // src/lib/objectiveMetrics.ts) and calls scoreObjective(); the score-quality tool
4
+ // only persists the result. Thresholds are centralized here (ADR-0004).
5
+
6
+ export interface ObjectiveMetrics {
7
+ faces: number;
8
+ vertices: number;
9
+ degenerateFaceRatio: number;
10
+ meshCount: number;
11
+ missingNormals: boolean;
12
+ bboxAspectExtreme: boolean;
13
+ targetFaceCount: number | null;
14
+ hasUV: boolean;
15
+ maxTextureSize: number;
16
+ hasBaseColorMap: boolean;
17
+ hasMetalRoughMap: boolean;
18
+ hasNormalMap: boolean;
19
+ hasOcclusionMap: boolean;
20
+ hasEmissiveMap: boolean;
21
+ pbrApplicable: boolean;
22
+ }
23
+
24
+ export interface ObjectiveScores {
25
+ geometry: number | null;
26
+ topology: number | null;
27
+ texture: number | null;
28
+ pbr: number | null;
29
+ }
30
+
31
+ export const DEFAULT_WEIGHTS = {
32
+ geometry: 0.2,
33
+ topology: 0.2,
34
+ texture: 0.2,
35
+ pbr: 0.2,
36
+ prompt_fidelity: 0.2,
37
+ } as const;
38
+
39
+ const DEGENERATE_PENALTY = 60;
40
+ const MISSING_NORMALS_PENALTY = 20;
41
+ const ASPECT_PENALTY = 15;
42
+ const ISLAND_SOFT_CAP = 8;
43
+
44
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
45
+ const round = (v: number) => Math.round(v);
46
+
47
+ export function scoreGeometry(m: ObjectiveMetrics): number {
48
+ let s = 100;
49
+ s -= clamp(m.degenerateFaceRatio, 0, 1) * DEGENERATE_PENALTY;
50
+ if (m.missingNormals) s -= MISSING_NORMALS_PENALTY;
51
+ if (m.bboxAspectExtreme) s -= ASPECT_PENALTY;
52
+ if (m.meshCount > ISLAND_SOFT_CAP) s -= Math.min(15, (m.meshCount - ISLAND_SOFT_CAP) * 2);
53
+ return clamp(round(s), 0, 100);
54
+ }
55
+
56
+ export function scoreTopology(m: ObjectiveMetrics): number | null {
57
+ if (m.targetFaceCount === null || m.targetFaceCount <= 0) return null;
58
+ const fit = 1 - clamp(Math.abs(m.faces - m.targetFaceCount) / m.targetFaceCount, 0, 1);
59
+ return clamp(round(100 * fit), 0, 100);
60
+ }
61
+
62
+ export function scoreTexture(m: ObjectiveMetrics): number | null {
63
+ if (!m.hasUV || m.maxTextureSize <= 0) return null;
64
+ if (m.maxTextureSize >= 2048) return 100;
65
+ if (m.maxTextureSize >= 1024) return 80;
66
+ if (m.maxTextureSize >= 512) return 60;
67
+ return 40;
68
+ }
69
+
70
+ export function scorePbr(m: ObjectiveMetrics): number | null {
71
+ if (!m.pbrApplicable) return null;
72
+ let s = 0;
73
+ if (m.hasBaseColorMap) s += 35;
74
+ if (m.hasMetalRoughMap) s += 30;
75
+ if (m.hasNormalMap) s += 20;
76
+ if (m.hasOcclusionMap) s += 10;
77
+ if (m.hasEmissiveMap) s += 5;
78
+ return clamp(round(s), 0, 100);
79
+ }
80
+
81
+ export function scoreObjective(m: ObjectiveMetrics): ObjectiveScores {
82
+ return {
83
+ geometry: scoreGeometry(m),
84
+ topology: scoreTopology(m),
85
+ texture: scoreTexture(m),
86
+ pbr: scorePbr(m),
87
+ };
88
+ }
89
+
90
+ export function weightedTotal(dims: { value: number | null; weight: number }[]): number | null {
91
+ let sum = 0;
92
+ let w = 0;
93
+ for (const d of dims) {
94
+ if (d.value !== null) {
95
+ sum += d.value * d.weight;
96
+ w += d.weight;
97
+ }
98
+ }
99
+ return w > 0 ? round(sum / w) : null;
100
+ }
@@ -0,0 +1,51 @@
1
+ import { expect, test } from 'bun:test';
2
+ import {
3
+ RODIN_ASPECT_USER_MESSAGE,
4
+ clampRodinEdge,
5
+ explainRodinGatewayError,
6
+ fitsRodinAspect,
7
+ paddedRodinSize,
8
+ readImageSize,
9
+ rodinAspectRejectMessage,
10
+ } from './rodin-image';
11
+
12
+ test('fitsRodinAspect: official 0.40–2.50 band', () => {
13
+ expect(fitsRodinAspect(1000, 1000)).toBe(true);
14
+ expect(fitsRodinAspect(400, 1000)).toBe(true);
15
+ expect(fitsRodinAspect(2500, 1000)).toBe(true);
16
+ expect(fitsRodinAspect(100, 1000)).toBe(false);
17
+ expect(fitsRodinAspect(3000, 800)).toBe(false);
18
+ });
19
+
20
+ test('paddedRodinSize: tall and wide land inside the band', () => {
21
+ const tall = paddedRodinSize(100, 1000);
22
+ expect(fitsRodinAspect(tall.width, tall.height)).toBe(true);
23
+ expect(tall.height).toBe(1000);
24
+ expect(tall.width).toBeGreaterThan(100);
25
+ const wide = paddedRodinSize(3000, 800);
26
+ expect(fitsRodinAspect(wide.width, wide.height)).toBe(true);
27
+ expect(wide.width).toBe(3000);
28
+ expect(clampRodinEdge(8000, 1000)).toEqual({ width: 4096, height: 512 });
29
+ });
30
+
31
+ test('readImageSize: PNG IHDR', () => {
32
+ const png = new Uint8Array(24);
33
+ png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
34
+ png[16] = 0;
35
+ png[17] = 0;
36
+ png[18] = 0x01;
37
+ png[19] = 0x90; // 400
38
+ png[20] = 0;
39
+ png[21] = 0;
40
+ png[22] = 0x03;
41
+ png[23] = 0xe8; // 1000
42
+ expect(readImageSize(png)).toEqual({ width: 400, height: 1000 });
43
+ });
44
+
45
+ test('explainRodinGatewayError: live Volcengine 400', () => {
46
+ const raw =
47
+ 'gateway http 400: {"error":{"message":"litellm.BadRequestError: VolcengineException - {\\"error\\":{\\"code\\":\\"InvalidParameter\\",\\"message\\":\\"Error while downloading image, error: expected the aspect ratio to be between 0.40 and 2.50, but received image with」';
48
+ expect(explainRodinGatewayError(raw)).toBe(RODIN_ASPECT_USER_MESSAGE);
49
+ expect(rodinAspectRejectMessage(100, 1000)).toMatch(/100×1000/);
50
+ expect(rodinAspectRejectMessage(100, 1000)).toMatch(/太瘦/);
51
+ });