@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 +2 -2
- package/plugins/beamng-vehicle/README.md +1 -1
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/beamng-vehicle.mjs +17 -4
- package/plugins/beamng-vehicle/bin/convert-beamng.py +1 -0
- package/plugins/beamng-vehicle/lib/material-semantics.mjs +300 -0
- package/plugins/beamng-vehicle/lib/vehicle-qa.mjs +30 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helix3/helix-cli",
|
|
3
|
-
"version": "0.1.14-helix3.
|
|
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.
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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, {
|
|
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)
|
|
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:
|
|
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));
|