@helix3/helix-cli 0.1.14-helix3.199 → 0.1.14-helix3.200

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helix3/helix-cli",
3
- "version": "0.1.14-helix3.199",
3
+ "version": "0.1.14-helix3.200",
4
4
  "description": "helix — the HELIX creator CLI: scaffold, validate, and publish Instant Worlds",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",
@@ -63,7 +63,7 @@
63
63
  "@gltf-transform/extensions": "^4.4.0",
64
64
  "@gltf-transform/functions": "^4.4.0",
65
65
  "@hypersoniclabs/helix-manifest": "npm:@helix3/helix-manifest@0.3.25-helix3.68",
66
- "@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.5-helix3.97",
66
+ "@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.5-helix3.99",
67
67
  "commander": "^13.0.0",
68
68
  "ktx2-encoder": "^0.5.3",
69
69
  "meshoptimizer": "^0.22.0",
@@ -5,7 +5,7 @@ This plugin makes `helix bridge import <unpacked-mod>` the single preflight and
5
5
  The importer performs two gates:
6
6
 
7
7
  1. Candidate assessment selects exactly one `vehicles/<id>` root and requires JBeam, render geometry, source material bindings, an authored steering-wheel mesh, real cabin signals, rims **and authored tyre geometry**, four wheel signals, and recorded or source-referenced engine audio. JBeam power-steering text cannot satisfy the visual steering requirement, and a rim radius cannot satisfy the tyre requirement. Candidate-local `vehicles/common` dependencies win over any external common root.
8
- 2. Conversion resolves the strongest renderable JBeam slot/flexbody closure, ranking actual selected-DAE coverage and a complete authored cabin ahead of optional-part volume. It retains only selected common wheel dependencies, expands legacy single/axle-pair road-wheel geometry to four measured shell corners before validation, runs Blender headlessly, preserves authored maps and UVs, semantically projects every vehicle surface onto a required `helix-palette/3` input, and embeds `helix-material-binding/1`. One size-compatible authored tyre mesh is instanced at the four measured rim centres; all source and unselected tyre nodes are removed. Acceptance then tests dimensions, triangle budget, `+Z` forward semantics, exact-four tyre custody, 90% render-ready material coverage, engine placement, authored seat H-points and steering geometry, cockpit ergonomics, provenance, and audio evidence. Repairs remain recorded but cannot manufacture source eligibility. A rear-at-`+Z` export is converted again with a geometry-only front/back reflection; any other failed check blocks the import.
8
+ 2. Conversion resolves the strongest renderable JBeam slot/flexbody closure, ranking actual selected-DAE coverage and a complete authored cabin ahead of optional-part volume. It retains only selected common wheel dependencies, expands legacy single/axle-pair road-wheel geometry to four measured shell corners before validation, runs Blender headlessly, preserves authored maps and UVs, semantically projects every vehicle surface onto a required `helix-palette/3` input, and embeds `helix-material-binding/1`. Before downstream material deduplication, reused source materials are split by node/primitive surface and stamped with `helix.vehicle-material-semantic/1`; family identity therefore survives optimization while authored atlases, UVs, and texture references remain shared. This metadata lets runtime consumers bypass name inference for new imports while retaining that fallback for older assets. One size-compatible authored tyre mesh is instanced at the four measured rim centres; all source and unselected tyre nodes are removed. Acceptance then tests dimensions, triangle budget, `+Z` forward semantics, exact-four tyre custody, 90% render-ready material coverage, engine placement, authored seat H-points and steering geometry, cockpit ergonomics, provenance, and audio evidence. Repairs remain recorded but cannot manufacture source eligibility. A rear-at-`+Z` export is converted again with a geometry-only front/back reflection; any other failed check blocks the import.
9
9
 
10
10
  BeamNG vehicle archives commonly reference tyres and materials shipped by the base game instead of duplicating them in every mod. Index that exact dependency once, review it as the named `beamngCommon` Bridge input, and pass it beside the Palette:
11
11
 
@@ -108,7 +108,19 @@ function vehiclePalette(path) {
108
108
  throw new Error(`BeamNG Palette input has no valid reusable vehicle family '${id}'`);
109
109
  }
110
110
  }
111
- return resolve(path);
111
+ return {
112
+ path: resolve(path),
113
+ familyVersions: Object.fromEntries(required.map((id) => [id, families.get(id).definitionVersion])),
114
+ familyDefaults: Object.fromEntries(required.map((id) => [id, {
115
+ roughness: families.get(id).shader.defaults.roughness,
116
+ metallic: families.get(id).shader.defaults.metallic,
117
+ }])),
118
+ };
119
+ }
120
+
121
+ export function stampConversionMaterials(output, conversion, palette) {
122
+ injectVehicleMaterialBindings(output, conversion.importQuality?.materials?.bindings,
123
+ palette.familyVersions, palette.familyDefaults);
112
124
  }
113
125
 
114
126
  function assess(source, commonRoot = null) {
@@ -375,7 +387,6 @@ async function convert(source, candidate, output, palette, commonDependency = nu
375
387
  }
376
388
  const conversionReceipt = JSON.parse(readFileSync(receipt, 'utf8'));
377
389
  injectVehicleReceipt(output, conversionReceipt);
378
- injectVehicleMaterialBindings(output, conversionReceipt.HELIX_import_quality?.materials?.bindings);
379
390
  const localDependencies = new Set(candidate.localWheelDependencies);
380
391
  return { converter: 'blender-dae', sourceGeometry: daes.map((path) => path === candidate.tyreDependency || localDependencies.has(path)
381
392
  ? dependencyReceipt(path, source, commonDependency) : relative(candidate.root, path)),
@@ -664,13 +675,15 @@ async function main() {
664
675
  }
665
676
  const indexIdentity = { bridgePlanKey: request.deterministicKey, sourceSha256: request.source.sha256,
666
677
  commonClosureSha256: commonDependency?.closureSha256 ?? null };
667
- conversion = await convert(source, selected, output, palette, commonDependency, false,
678
+ conversion = await convert(source, selected, output, palette.path, commonDependency, false,
668
679
  selectedPartIndexPath, indexIdentity);
680
+ stampConversionMaterials(output, conversion, palette);
669
681
  stampBridgeProvenance(output, source, selected);
670
682
  qa = auditVehicleGlb(output, { sourceRoot: selected.root, hasAudio: selected.hasAudio });
671
683
  if (qa.findings.some((row) => row.code === 'frame.forward-semantics' && row.status === 'error') && selected.glbs.length === 0) {
672
- conversion = await convert(source, selected, output, palette, commonDependency, true,
684
+ conversion = await convert(source, selected, output, palette.path, commonDependency, true,
673
685
  selectedPartIndexPath, indexIdentity);
686
+ stampConversionMaterials(output, conversion, palette);
674
687
  stampBridgeProvenance(output, source, selected);
675
688
  qa = auditVehicleGlb(output, { sourceRoot: selected.root, hasAudio: selected.hasAudio });
676
689
  }
@@ -841,6 +841,7 @@ def restore_materials(roots: list[Path], palette_path: Path, vehicle_root: Path,
841
841
  for row in definitions.values() if row[0].get("provenance")})
842
842
  return {"definitions": len({id(row[0]) for row in definitions.values()}), "resolved": resolved, "mapped": mapped, "materials": total,
843
843
  "paletteContract": "helix-palette/3", "bound": total, "factorDefaults": factor_defaults,
844
+ "familyVersions": {family_id: family["definitionVersion"] for family_id, family in palette.items()},
844
845
  "renderReady": total, "renderReadyCoverage": 1.0 if total else 0.0,
845
846
  "roles": role_counts, "bindings": bindings,
846
847
  "declarationProvenance": [json.loads(value) for value in declaration_provenance],
@@ -0,0 +1,300 @@
1
+ const semantic = (role, familyId, sourceMaterialId) => ({
2
+ contract: 'helix.vehicle-material-semantic/1',
3
+ role,
4
+ familyId,
5
+ sourceMaterialId,
6
+ });
7
+
8
+ const matches = (value, pattern) => pattern.test(value);
9
+
10
+ /**
11
+ * Resolve a physical surface from the source material plus the primitive's
12
+ * authored object context. BeamNG regularly reuses one atlas across unrelated
13
+ * surfaces, so neither name is authoritative alone.
14
+ */
15
+ export function classifyVehicleMaterialSemantic(nodeName, materialName) {
16
+ const node = String(nodeName ?? '').toLowerCase();
17
+ const material = String(materialName ?? '').toLowerCase();
18
+ const sourceMaterialId = String(materialName ?? '');
19
+
20
+ // Geometry custody is strongest: these parts frequently share a body-blue
21
+ // source material even though their physical response is unrelated to paint.
22
+ if (matches(node, /under.?body|under.?tray|wheel.?tub|wheelwell|subframe|skid.?plate|bumper.?bar/)) {
23
+ return semantic('underbody', 'underbody-coating', sourceMaterialId);
24
+ }
25
+ if (matches(node, /exhaust|muffler|tail.?pipe|manifold/)) {
26
+ return semantic('exhaust', 'stainless-steel', sourceMaterialId);
27
+ }
28
+ if (matches(node, /(?:^|[_-])hub(?:[_-]|$)|brake.?disc|brake.?rotor|brake.?drum/)) {
29
+ return semantic('brake-rotor', 'brake-rotor', sourceMaterialId);
30
+ }
31
+ if (matches(node, /(?:^|[_-])brake[_-](?:lf|rf|lr|rr)(?:[_-]|$)/)) {
32
+ return semantic('brake-rotor', 'brake-rotor', sourceMaterialId);
33
+ }
34
+ if (matches(node, /tyre|tire|sidewall|tread/)) return semantic('tyre', 'tire-rubber', sourceMaterialId);
35
+ if (matches(node, /(?:^|[_-])(?:rim|road.?wheel)(?:[_-]|$)/)) {
36
+ return semantic('wheel', 'aluminum-brushed', sourceMaterialId);
37
+ }
38
+
39
+ // Instrument geometry wins over lamp-like atlas names such as M_signal_R.
40
+ if (matches(node, /gauge|cluster|needle|speedo|tacho|instrument/)) {
41
+ return semantic('dashboard', 'dashboard-soft-touch', sourceMaterialId);
42
+ }
43
+ const upholstery = matches(material, /leather|cloth|fabric|cuciture|stitch|upholstery/);
44
+ if (matches(material, /engine|motor/)) return semantic('metal', 'stainless-steel', sourceMaterialId);
45
+ if (matches(material, /caliper/)) return semantic('brake-caliper', 'brake-caliper-painted', sourceMaterialId);
46
+ if (matches(material, /carbon|cfiber|(?:^|[_-])cf(?:[_-]|$)/)) return semantic('carbon', 'carbon-fiber', sourceMaterialId);
47
+ if (matches(material, /(?:^|[_-])mirror(?:[_-]|$)|chrome/)) return semantic('chrome', 'chrome', sourceMaterialId);
48
+ if (upholstery) return semantic('leather', 'vehicle-leather', sourceMaterialId);
49
+ if (matches(material, /carpet|floor.?mat/)) return semantic('carpet', 'vehicle-carpet', sourceMaterialId);
50
+ if (matches(material, /parking.?brake|hand.?brake/)) {
51
+ return semantic('interior', 'vehicle-interior-plastic', sourceMaterialId);
52
+ }
53
+ if (matches(material, /paint|body|bodyshell/)) return semantic('paint', 'vehicle-paint-metallic', sourceMaterialId);
54
+ if (matches(material, /plastic/)) return semantic('interior', 'vehicle-interior-plastic', sourceMaterialId);
55
+ if (matches(node, /dash|door.?card|console|seat|steering|shift.?knob|parking.?brake|interior|cabin|ceiling|headliner/)) {
56
+ if (matches(node, /dash/)) return semantic('dashboard', 'dashboard-soft-touch', sourceMaterialId);
57
+ return semantic('interior', 'vehicle-interior-plastic', sourceMaterialId);
58
+ }
59
+
60
+ // Material names are strongest for authored optical/mechanical submeshes
61
+ // nested under a broad headlight or bodyshell object.
62
+ if (matches(material, /(?:loop[lr]?|lowhi1|head.?l(?:amp|ight))/)) {
63
+ return semantic('headlamp', 'headlamp-lens', sourceMaterialId);
64
+ }
65
+ if (matches(material, /lowhi3|tail.?l(?:amp|ight)|revik|(?:^|[_-])(?:brak|sig[lr]?\d*)(?:[_-]|$)/)) {
66
+ return semantic('taillamp', 'taillamp-lens', sourceMaterialId);
67
+ }
68
+
69
+ // A shared glazing material becomes the correct optical family from node
70
+ // context while retaining the same identity texture and UVs.
71
+ if (matches(node, /head.?l(?:amp|ight)/)) return semantic('headlamp', 'headlamp-lens', sourceMaterialId);
72
+ if (matches(node, /tail.?l(?:amp|ight)|reverse.?light|brake.?light|chmsl/)) {
73
+ return semantic('taillamp', 'taillamp-lens', sourceMaterialId);
74
+ }
75
+ if (matches(material, /(?:^|[_-])lights?(?:_lod\d+)?(?:[_-]|$)/)) {
76
+ return semantic('taillamp', 'taillamp-lens', sourceMaterialId);
77
+ }
78
+ if (matches(node, /glass|window|windscreen|windshield|backlight|sunroof/)
79
+ || matches(material, /glass|window|windscreen|windshield/)) {
80
+ return semantic('glass', 'vehicle-glass', sourceMaterialId);
81
+ }
82
+
83
+ if (matches(material, /tyre|tire|rubber|tread/)) return semantic('tyre', 'tire-rubber', sourceMaterialId);
84
+ if (matches(material, /(?:^|[_-])(?:wheel|rim|alloy|aluminium|aluminum)(?:[_-]|$)/)) {
85
+ return semantic('wheel', 'aluminum-brushed', sourceMaterialId);
86
+ }
87
+ if (matches(material, /(?:^|[_-])(?:brake|rotor|disc)(?:[_-]|$)/)) {
88
+ return semantic('brake-rotor', 'brake-rotor', sourceMaterialId);
89
+ }
90
+ if (matches(material, /dashboard|dash|gauges?|instrument|cluster|soft.?touch/)) {
91
+ return semantic('dashboard', 'dashboard-soft-touch', sourceMaterialId);
92
+ }
93
+ if (matches(material, /interior|plastic|console|door.?card|trim|speaker|symbol|elements?/)) {
94
+ return semantic('interior', 'vehicle-interior-plastic', sourceMaterialId);
95
+ }
96
+ if (matches(material, /under.?body|under.?tray|chassis|subframe|suspension|grille/)) {
97
+ return semantic('underbody', 'underbody-coating', sourceMaterialId);
98
+ }
99
+ if (matches(material, /exhaust|muffler|tail.?pipe|metal|steel|iron/)) {
100
+ return semantic('metal', 'stainless-steel', sourceMaterialId);
101
+ }
102
+ if (matches(node, /bonnet|hood|boot|trunk|bumper|door|fender|quarter|roof|side.?skirt|spoiler|bodyshell/)) {
103
+ if (matches(material, /black|block|grille/)) return semantic('underbody', 'underbody-coating', sourceMaterialId);
104
+ return semantic('paint', 'vehicle-paint-metallic', sourceMaterialId);
105
+ }
106
+ return semantic('interior', 'vehicle-interior-plastic', sourceMaterialId);
107
+ }
108
+
109
+ const runtimeProfile = (familyId) => ({
110
+ 'vehicle-paint-solid': 'car-paint',
111
+ 'vehicle-paint-metallic': 'car-paint',
112
+ 'vehicle-paint-pearl': 'car-paint',
113
+ 'vehicle-paint-matte': 'car-paint',
114
+ 'vehicle-glass': 'glass',
115
+ 'headlamp-lens': 'lamp',
116
+ 'taillamp-lens': 'lamp',
117
+ chrome: 'alloy',
118
+ 'stainless-steel': 'engine-metal',
119
+ 'aluminum-brushed': 'alloy',
120
+ 'brake-rotor': 'brake-metal',
121
+ 'brake-caliper-painted': 'brake-metal',
122
+ 'tire-rubber': 'tyre-rubber',
123
+ 'vehicle-interior-plastic': 'interior-plastic',
124
+ 'vehicle-leather': 'leather',
125
+ 'vehicle-carpet': 'interior-plastic',
126
+ 'dashboard-soft-touch': 'interior-plastic',
127
+ 'underbody-coating': 'engine-metal',
128
+ 'carbon-fiber': 'carbon',
129
+ }[familyId] ?? 'generic');
130
+
131
+ function bindingFor(source, familyVersions, semanticValue) {
132
+ const binding = structuredClone(source?.extras?.helixMaterialBinding ?? null);
133
+ if (!binding) return null;
134
+ if (binding.familyId !== semanticValue.familyId) {
135
+ const version = familyVersions?.[semanticValue.familyId];
136
+ if (typeof version !== 'string') {
137
+ throw new Error(`vehicle material semantic has no Palette version for ${semanticValue.familyId}`);
138
+ }
139
+ binding.familyId = semanticValue.familyId;
140
+ binding.familyVersion = version;
141
+ binding.presetId = null;
142
+ delete binding.packageRef;
143
+ delete binding.projectionProfileRef;
144
+ delete binding.scalarFallback;
145
+ const overrides = binding.overrides && typeof binding.overrides === 'object' && !Array.isArray(binding.overrides)
146
+ ? binding.overrides : {};
147
+ const { tint: _paintTint, ...portableOverrides } = overrides;
148
+ binding.overrides = portableOverrides;
149
+ }
150
+ binding.preserved = { ...(binding.preserved ?? {}), sourceMaterialId: semanticValue.sourceMaterialId };
151
+ return binding;
152
+ }
153
+
154
+ const MAX_SEMANTIC_NODES = 65_536;
155
+ const MAX_SEMANTIC_MESHES = 16_384;
156
+ const MAX_SEMANTIC_PRIMITIVES = 262_144;
157
+ const MAX_SEMANTIC_TRAVERSAL = 1_048_576;
158
+ const MAX_SEMANTIC_CLONE_CHARS = 128 * 1024 * 1024;
159
+
160
+ /**
161
+ * Split reused materials (and shared meshes when needed) into semantic variants
162
+ * before downstream material deduplication. Texture indices are deliberately
163
+ * untouched, so authored atlases remain one shared binary resource.
164
+ */
165
+ export function stampVehicleMaterialSemantics(json, familyVersions, familyDefaults) {
166
+ // The prior two-argument injector API did not carry a Palette closure. Keep
167
+ // it byte-semantically compatible rather than emitting a contradictory
168
+ // family ID with a version belonging to a different family.
169
+ if (familyVersions === undefined) return;
170
+ const nodes = json.nodes ?? [];
171
+ const materials = json.materials ?? [];
172
+ const meshes = json.meshes ?? [];
173
+ if (!Array.isArray(nodes) || !Array.isArray(materials) || !Array.isArray(meshes)) {
174
+ throw new Error('vehicle semantic stamping requires glTF node, material, and mesh arrays');
175
+ }
176
+ const sourceMaterials = [...materials];
177
+ const sourceMeshes = [...meshes];
178
+ if (nodes.length > MAX_SEMANTIC_NODES || sourceMeshes.length > MAX_SEMANTIC_MESHES) {
179
+ throw new Error('vehicle semantic stamping exceeds node or mesh limits');
180
+ }
181
+ let primitiveCount = 0;
182
+ for (const mesh of sourceMeshes) {
183
+ if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh) || !Array.isArray(mesh.primitives)) {
184
+ throw new Error('vehicle semantic stamping requires object meshes with primitive arrays');
185
+ }
186
+ primitiveCount += mesh.primitives.length;
187
+ if (primitiveCount > MAX_SEMANTIC_PRIMITIVES) {
188
+ throw new Error(`vehicle semantic stamping exceeds ${MAX_SEMANTIC_PRIMITIVES} source primitives`);
189
+ }
190
+ for (const primitive of mesh.primitives) {
191
+ if (!primitive || typeof primitive !== 'object' || Array.isArray(primitive)
192
+ || (primitive.material !== undefined && (!Number.isInteger(primitive.material)
193
+ || primitive.material < 0 || primitive.material >= sourceMaterials.length))) {
194
+ throw new Error('vehicle semantic stamping found an invalid primitive material reference');
195
+ }
196
+ }
197
+ }
198
+ let traversal = 0;
199
+ for (const node of nodes) {
200
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
201
+ throw new Error('vehicle semantic stamping requires object nodes');
202
+ }
203
+ if (node.mesh === undefined) continue;
204
+ if (!Number.isInteger(node.mesh) || node.mesh < 0 || node.mesh >= sourceMeshes.length) {
205
+ throw new Error('vehicle semantic stamping found an invalid node mesh reference');
206
+ }
207
+ traversal += sourceMeshes[node.mesh].primitives.length;
208
+ if (traversal > MAX_SEMANTIC_TRAVERSAL) {
209
+ throw new Error(`vehicle semantic stamping exceeds ${MAX_SEMANTIC_TRAVERSAL} node-primitive visits`);
210
+ }
211
+ }
212
+ const materialVariants = new Map();
213
+ const meshVariants = new Map();
214
+ const firstMaterialVariant = new Set();
215
+ const firstMeshVariant = new Set();
216
+ let cloneChars = 0;
217
+ const clone = (value) => {
218
+ cloneChars += JSON.stringify(value).length;
219
+ if (cloneChars > MAX_SEMANTIC_CLONE_CHARS) {
220
+ throw new Error(`vehicle semantic stamping exceeds ${MAX_SEMANTIC_CLONE_CHARS} projected clone characters`);
221
+ }
222
+ return structuredClone(value);
223
+ };
224
+
225
+ const materialVariant = (sourceIndex, semanticValue) => {
226
+ const key = `${sourceIndex}:${semanticValue.familyId}:${semanticValue.role}`;
227
+ if (materialVariants.has(key)) return materialVariants.get(key);
228
+ const source = sourceMaterials[sourceIndex];
229
+ if (!source) return sourceIndex;
230
+ const material = clone(source);
231
+ const resolved = semanticValue;
232
+ material.extras = { ...(material.extras ?? {}),
233
+ helixVehicleRole: resolved.role,
234
+ helixVehicleMaterial: runtimeProfile(resolved.familyId),
235
+ helixVehicleMaterialSemantic: resolved };
236
+ const binding = bindingFor(source, familyVersions, resolved);
237
+ if (binding) material.extras.helixMaterialBinding = binding;
238
+ if (source.extras?.helixMaterialBinding?.familyId !== resolved.familyId) {
239
+ const defaults = familyDefaults?.[resolved.familyId];
240
+ if (defaults && Number.isFinite(defaults.roughness) && Number.isFinite(defaults.metallic)) {
241
+ material.pbrMetallicRoughness ??= {};
242
+ const alpha = material.pbrMetallicRoughness.baseColorFactor?.[3] ?? 1;
243
+ material.pbrMetallicRoughness.baseColorFactor = [1, 1, 1, alpha];
244
+ material.pbrMetallicRoughness.roughnessFactor = defaults.roughness;
245
+ material.pbrMetallicRoughness.metallicFactor = defaults.metallic;
246
+ }
247
+ }
248
+ let index;
249
+ if (!firstMaterialVariant.has(sourceIndex)) {
250
+ firstMaterialVariant.add(sourceIndex);
251
+ json.materials[sourceIndex] = material;
252
+ index = sourceIndex;
253
+ } else {
254
+ if (json.materials.length >= 16_384) throw new Error('vehicle semantic material split exceeds 16384 materials');
255
+ index = json.materials.push(material) - 1;
256
+ }
257
+ materialVariants.set(key, index);
258
+ return index;
259
+ };
260
+
261
+ for (const node of nodes) {
262
+ if (!Number.isInteger(node?.mesh)) continue;
263
+ const sourceMeshIndex = node.mesh;
264
+ const sourceMesh = sourceMeshes[sourceMeshIndex];
265
+ if (!sourceMesh) continue;
266
+ const semantics = (sourceMesh.primitives ?? []).map((primitive) => {
267
+ const material = Number.isInteger(primitive.material) ? sourceMaterials[primitive.material] : null;
268
+ if (!material) return null;
269
+ return {
270
+ ...classifyVehicleMaterialSemantic(node.name, material.name),
271
+ identityBearingDetails: material.extras?.helixMaterialBinding?.preserved?.identityBearingDetails === true,
272
+ };
273
+ });
274
+ const signature = semantics.map((value) => value ? `${value.familyId}:${value.role}` : '-').join('|');
275
+ const key = `${sourceMeshIndex}:${signature}`;
276
+ if (meshVariants.has(key)) {
277
+ node.mesh = meshVariants.get(key);
278
+ continue;
279
+ }
280
+ const mesh = clone(sourceMesh);
281
+ mesh.primitives = (mesh.primitives ?? []).map((primitive, index) => {
282
+ const semanticValue = semantics[index];
283
+ if (!semanticValue || !Number.isInteger(primitive.material)) return primitive;
284
+ return { ...primitive,
285
+ material: materialVariant(primitive.material, semanticValue),
286
+ extras: { ...(primitive.extras ?? {}), helixVehicleMaterialSemantic: semanticValue } };
287
+ });
288
+ let meshIndex;
289
+ if (!firstMeshVariant.has(sourceMeshIndex)) {
290
+ firstMeshVariant.add(sourceMeshIndex);
291
+ json.meshes[sourceMeshIndex] = mesh;
292
+ meshIndex = sourceMeshIndex;
293
+ } else {
294
+ if (json.meshes.length >= 16_384) throw new Error('vehicle semantic mesh split exceeds 16384 meshes');
295
+ meshIndex = json.meshes.push(mesh) - 1;
296
+ }
297
+ meshVariants.set(key, meshIndex);
298
+ node.mesh = meshIndex;
299
+ }
300
+ }
@@ -11,6 +11,9 @@ import {
11
11
  } from 'node:fs';
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { basename, dirname, join } from 'node:path';
14
+ import { classifyVehicleMaterialSemantic, stampVehicleMaterialSemantics } from './material-semantics.mjs';
15
+
16
+ export { classifyVehicleMaterialSemantic };
14
17
 
15
18
  const MAX_GLB_JSON_BYTES = 64 * 1024 * 1024;
16
19
  const MAX_GLB_BYTES = 2 * 1024 * 1024 * 1024;
@@ -112,7 +115,7 @@ function applyReceipt(json, receipt) {
112
115
  json.asset.extras = { ...(extras ?? {}), ...receipt };
113
116
  }
114
117
 
115
- function applyMaterialBindings(json, rows) {
118
+ function applyMaterialBindings(json, rows, familyVersions, familyDefaults) {
116
119
  if (!Array.isArray(rows) || rows.length > MAX_GLB_MATERIALS) {
117
120
  throw new Error(`vehicle material bindings must be an array of at most ${MAX_GLB_MATERIALS} rows`);
118
121
  }
@@ -126,6 +129,10 @@ function applyMaterialBindings(json, rows) {
126
129
  if (!row || typeof row !== 'object' || Array.isArray(row) || typeof row.name !== 'string') {
127
130
  throw new Error('vehicle material binding rows must be named objects');
128
131
  }
132
+ const identityBearingDetails = row.binding?.preserved?.identityBearingDetails;
133
+ if (identityBearingDetails !== undefined && typeof identityBearingDetails !== 'boolean') {
134
+ throw new Error('vehicle material binding identityBearingDetails must be boolean');
135
+ }
129
136
  }
130
137
  const byName = new Map((rows ?? []).map((row) => [row.name, row]));
131
138
  for (const material of json.materials ?? []) {
@@ -154,6 +161,7 @@ function applyMaterialBindings(json, rows) {
154
161
  ];
155
162
  }
156
163
  }
164
+ stampVehicleMaterialSemantics(json, familyVersions, familyDefaults);
157
165
  }
158
166
 
159
167
  function serializeGlbJson(json) {
@@ -211,7 +219,9 @@ function fsyncDirectory(path) {
211
219
  * chunk through a fixed-size buffer. The source path is replaced only after
212
220
  * the complete output is durable and the source identity is re-verified.
213
221
  */
214
- export function injectVehicleMetadata(path, { receipt, materialBindings } = {}) {
222
+ export function injectVehicleMetadata(path, {
223
+ receipt, materialBindings, materialFamilyVersions, materialFamilyDefaults,
224
+ } = {}) {
215
225
  if (receipt === undefined && materialBindings === undefined) {
216
226
  throw new Error('vehicle GLB metadata rewrite requires a receipt or material bindings');
217
227
  }
@@ -222,7 +232,9 @@ export function injectVehicleMetadata(path, { receipt, materialBindings } = {})
222
232
  try {
223
233
  const glb = readOpenedGlb(sourceHandle);
224
234
  if (receipt !== undefined) applyReceipt(glb.json, receipt);
225
- if (materialBindings !== undefined) applyMaterialBindings(glb.json, materialBindings);
235
+ if (materialBindings !== undefined) {
236
+ applyMaterialBindings(glb.json, materialBindings, materialFamilyVersions, materialFamilyDefaults);
237
+ }
226
238
  const json = serializeGlbJson(glb.json);
227
239
  const trailingOffset = 20 + glb.jsonLength;
228
240
  const trailingBytes = glb.fileBytes - trailingOffset;
@@ -270,8 +282,8 @@ export function injectVehicleReceipt(path, receipt) {
270
282
  injectVehicleMetadata(path, { receipt });
271
283
  }
272
284
 
273
- export function injectVehicleMaterialBindings(path, rows) {
274
- injectVehicleMetadata(path, { materialBindings: rows ?? [] });
285
+ export function injectVehicleMaterialBindings(path, rows, materialFamilyVersions, materialFamilyDefaults) {
286
+ injectVehicleMetadata(path, { materialBindings: rows ?? [], materialFamilyVersions, materialFamilyDefaults });
275
287
  }
276
288
 
277
289
  function multiply(a, b) {
@@ -491,14 +503,25 @@ export function auditVehicleGlb(path, options = {}) {
491
503
  const coverage = mapped / Math.max(1, materials.length);
492
504
  const validPaletteBinding = (material) => {
493
505
  const binding = material.extras?.helixMaterialBinding;
506
+ const semanticValue = material.extras?.helixVehicleMaterialSemantic;
507
+ const validSemantic = semanticValue === undefined || (semanticValue?.contract === 'helix.vehicle-material-semantic/1'
508
+ && semanticValue.familyId === binding?.familyId
509
+ && semanticValue.sourceMaterialId === material.name
510
+ && typeof semanticValue.identityBearingDetails === 'boolean');
494
511
  return binding?.contract === 'helix-material-binding/1'
495
512
  && typeof binding.familyId === 'string' && binding.familyId.length > 0
496
513
  && typeof binding.familyVersion === 'string' && /^\d+\.\d+\.\d+$/.test(binding.familyVersion)
497
514
  && binding.uv?.uvSet === 0 && Array.isArray(binding.uv?.scale)
498
515
  && binding.preserved?.sourceMaterialId === material.name
499
- && ['opaque', 'mask', 'blend'].includes(binding.renderState?.alphaMode);
516
+ && ['opaque', 'mask', 'blend'].includes(binding.renderState?.alphaMode)
517
+ && validSemantic;
500
518
  };
501
519
  const paletteBound = materials.filter(validPaletteBinding).length;
520
+ const finalRoles = materials.reduce((counts, material) => {
521
+ const role = material.extras?.helixVehicleMaterialSemantic?.role ?? material.extras?.helixVehicleRole;
522
+ if (typeof role === 'string' && role.length > 0) counts[role] = (counts[role] ?? 0) + 1;
523
+ return counts;
524
+ }, {});
502
525
  const renderReady = materials.filter((material) => material.pbrMetallicRoughness?.baseColorTexture
503
526
  || material.normalTexture || validPaletteBinding(material)).length;
504
527
  const renderReadyCoverage = renderReady / Math.max(1, materials.length);
@@ -513,7 +536,7 @@ export function auditVehicleGlb(path, options = {}) {
513
536
  check('materials.palette-bindings', importQuality.materials?.paletteContract === 'helix-palette/3'
514
537
  && paletteBound / Math.max(1, materials.length) >= 0.9 && renderReadyCoverage >= 0.9,
515
538
  { materials: materials.length, paletteBound, renderReady, renderReadyCoverage: Number(renderReadyCoverage.toFixed(3)),
516
- roles: importQuality.materials?.roles ?? {} },
539
+ roles: finalRoles },
517
540
  'helix-palette/3 projection with valid helix-material-binding/1 metadata and at least 90% render-ready material coverage');
518
541
  const rear = semanticZ(doc, /(mainexhaust|exhaust|taillight|tailgate|trunk|boot|bumper[_-]?r)/i)
519
542
  .filter((z) => Math.abs(z) >= Math.max(0.75, dimensions[2] * 0.25));