@helix3/helix-cli 0.1.14-helix3.180 → 0.1.14-helix3.182
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/docs/bridge.md +1 -0
- package/package.json +3 -2
- package/plugins/beamng-vehicle/README.md +21 -0
- package/plugins/beamng-vehicle/bin/beamng-vehicle.mjs +279 -0
- package/plugins/beamng-vehicle/bin/convert-beamng.py +1102 -0
- package/plugins/beamng-vehicle/bridge-plugin.json +37 -0
- package/plugins/beamng-vehicle/lib/vehicle-qa.mjs +221 -0
- package/plugins/beamng-vehicle/profiles/source-compatibility.json +14 -0
package/docs/bridge.md
CHANGED
|
@@ -43,6 +43,7 @@ Blocked `bridge plan` and `bridge import --dry-run` commands print the complete
|
|
|
43
43
|
| Unity project | `Assets/` + project settings | plan seam; install a batch-mode adapter | canonical GLB → Scene v2 |
|
|
44
44
|
| Godot project | `project.godot` | plan seam; install a headless adapter | canonical GLB → Scene v2 |
|
|
45
45
|
| MetaHuman face GLB | `FaceMesh` + baked-eye material names | head bake, canonical eye bake, material repair | web face GLB |
|
|
46
|
+
| Unpacked BeamNG vehicle mod | `vehicles/` marker through `hypersonic.beamng-vehicle` | default JBeam configuration, source DAE/PBR/audio evidence, canonical vehicle receipt, fail-closed acceptance | canonical textured GLB |
|
|
46
47
|
|
|
47
48
|
Modular character parts use `--type addon --input host=<base-avatar.glb> --input addon=<profile.json>`. The profile must carry `hostSha256` for that exact host GLB and the runtime `character.<slot>@1` fitment contract used by the customizer. Bridge validates the host as a base avatar, hashes it, rejects a digest mismatch, and runs the generated `fits` and `ops` through the same exact runtime contract validator used by Universal Item publishing. Upload-only `Cosmetic.Slot.*` values, undeclared nodes/paths/pointers, and unsupported operations are rejected before an add-on package is published.
|
|
48
49
|
|
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.182",
|
|
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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"bridge-recipes",
|
|
46
46
|
"docs/bridge.md",
|
|
47
47
|
"dist",
|
|
48
|
+
"plugins/beamng-vehicle",
|
|
48
49
|
"vendor"
|
|
49
50
|
],
|
|
50
51
|
"scripts": {
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"@gltf-transform/extensions": "^4.4.0",
|
|
62
63
|
"@gltf-transform/functions": "^4.4.0",
|
|
63
64
|
"@hypersoniclabs/helix-manifest": "npm:@helix3/helix-manifest@0.3.22-helix3.62",
|
|
64
|
-
"@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.5-helix3.
|
|
65
|
+
"@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.5-helix3.95",
|
|
65
66
|
"commander": "^13.0.0",
|
|
66
67
|
"ktx2-encoder": "^0.5.3",
|
|
67
68
|
"meshoptimizer": "^0.22.0",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# BeamNG Vehicle Bridge Plugin
|
|
2
|
+
|
|
3
|
+
This plugin makes `helix bridge import <unpacked-mod>` the single preflight and conversion path for BeamNG road vehicles. It deliberately refuses a source rather than publishing a car that only looks complete in a file listing.
|
|
4
|
+
|
|
5
|
+
The importer performs two gates:
|
|
6
|
+
|
|
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.
|
|
8
|
+
2. Conversion runs Blender headlessly, binds source materials and textures, derives the canonical HELIX receipt and internal source provenance, and then acceptance-tests dimensions, triangle budget, `+Z` forward semantics, wheel and tyre geometry, measured engine placement, authored seat H-points and steering geometry, cockpit ergonomics, texture coverage, 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
|
+
|
|
10
|
+
BeamNG vehicle archives commonly reference tyres shipped by the base game instead of duplicating them in every mod. Point `HELIX_BEAMNG_COMMON_ROOT` at an unpacked, locally available BeamNG common-asset root before planning/importing such a vehicle. The selected dependency is recorded in the import report; if neither the archive nor that root contains authored tyre geometry, candidate assessment refuses the vehicle instead of publishing bare rims.
|
|
11
|
+
|
|
12
|
+
Local development:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm run build
|
|
16
|
+
node dist/index.js bridge plugins inspect plugins/beamng-vehicle --json
|
|
17
|
+
node dist/index.js bridge plugins test plugins/beamng-vehicle --json
|
|
18
|
+
node dist/index.js bridge plan /path/to/unpacked-mod --plugin-dir plugins/beamng-vehicle --plugin hypersonic.beamng-vehicle --json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
External-plugin execution still requires the Bridge plan key and all permissions named by the reviewed plan. The plugin does not weaken Bridge's immutable source snapshot or provenance rules.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { auditVehicleGlb, injectVehicleReceipt } from '../lib/vehicle-qa.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_FILES = 50_000;
|
|
10
|
+
const MAX_DAE = 12;
|
|
11
|
+
const MAX_CONVERTER_LOG_BYTES = 1024 * 1024;
|
|
12
|
+
const AUDIO_EXTENSIONS = new Set(['.wav', '.ogg', '.flac', '.mp3']);
|
|
13
|
+
|
|
14
|
+
function walk(root) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
const visit = (dir) => {
|
|
17
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
18
|
+
const path = join(dir, entry.name);
|
|
19
|
+
if (entry.isSymbolicLink()) throw new Error(`source contains a symlink: ${relative(root, path)}`);
|
|
20
|
+
if (entry.isDirectory()) visit(path);
|
|
21
|
+
else if (entry.isFile()) rows.push(path);
|
|
22
|
+
if (rows.length > MAX_FILES) throw new Error(`source contains more than ${MAX_FILES} files`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
visit(root);
|
|
26
|
+
return rows;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function semanticCount(files, pattern) {
|
|
30
|
+
return files.reduce((count, path) => count + (pattern.test(basename(path).toLowerCase()) ? 1 : 0), 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sourceReferencesAudio(jbeams) {
|
|
34
|
+
return jbeams.some((path) => /soundconfig|sampleName|engineSound|exhaustSound/i.test(readFileSync(path, 'utf8')));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function daeHasAuthoredSteering(path) {
|
|
38
|
+
return /(?:id|name)="[^"]*steer(?:ing)?(?:wheel|rim)?[^"]*"/i.test(readFileSync(path, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function daeHasAuthoredTyre(path) {
|
|
42
|
+
return /(?:id|name)="[^"]*(?:tyre|tire)[^"]*"/i.test(readFileSync(path, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commonTyreDependency() {
|
|
46
|
+
const configured = process.env.HELIX_BEAMNG_COMMON_ROOT;
|
|
47
|
+
if (!configured || !existsSync(configured)) return null;
|
|
48
|
+
const candidates = walk(resolve(configured)).filter((path) => extname(path).toLowerCase() === '.dae'
|
|
49
|
+
&& /(?:tyre|tire)/i.test(basename(path)) && daeHasAuthoredTyre(path));
|
|
50
|
+
return candidates.sort((a, b) => lstatSync(b).size - lstatSync(a).size || a.localeCompare(b))[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dependencyReceipt(path) {
|
|
54
|
+
if (!path) return null;
|
|
55
|
+
const configured = process.env.HELIX_BEAMNG_COMMON_ROOT;
|
|
56
|
+
if (!configured) return basename(path);
|
|
57
|
+
return relative(resolve(configured), path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assess(source) {
|
|
61
|
+
const vehicles = join(source, 'vehicles');
|
|
62
|
+
if (!existsSync(vehicles)) throw new Error("not a BeamNG mod: missing top-level 'vehicles' directory");
|
|
63
|
+
const tyreDependency = commonTyreDependency();
|
|
64
|
+
const candidates = readdirSync(vehicles, { withFileTypes: true })
|
|
65
|
+
.filter((entry) => entry.isDirectory() && entry.name !== 'common')
|
|
66
|
+
.map((entry) => {
|
|
67
|
+
const root = join(vehicles, entry.name);
|
|
68
|
+
const files = walk(root);
|
|
69
|
+
const jbeams = files.filter((path) => extname(path).toLowerCase() === '.jbeam');
|
|
70
|
+
const daes = files.filter((path) => extname(path).toLowerCase() === '.dae');
|
|
71
|
+
const glbs = files.filter((path) => extname(path).toLowerCase() === '.glb');
|
|
72
|
+
const materials = files.filter((path) => /(?:\.materials\.json|materials\.cs)$/i.test(path));
|
|
73
|
+
const audio = files.filter((path) => AUDIO_EXTENSIONS.has(extname(path).toLowerCase()));
|
|
74
|
+
const cabin = semanticCount(files, /(interior|cabin|dashboard|dash|seat|steer)/)
|
|
75
|
+
+ Math.min(20, jbeams.reduce((count, path) => count + (readFileSync(path, 'utf8').match(/seat|steer|dashboard|interior|cabin/gi)?.length ?? 0), 0));
|
|
76
|
+
const wheels = semanticCount(files, /(wheel|rim|tyre|tire)/)
|
|
77
|
+
+ Math.min(12, jbeams.reduce((count, path) => count + (readFileSync(path, 'utf8').match(/wheel|rim|tyre|tire/gi)?.length ?? 0), 0));
|
|
78
|
+
const audioReferences = sourceReferencesAudio(jbeams);
|
|
79
|
+
const hasAudio = audio.length > 0 || audioReferences;
|
|
80
|
+
// JBeam power-steering controllers are not visual cabin evidence. Require
|
|
81
|
+
// a steering object in DAE geometry before spending a Blender conversion.
|
|
82
|
+
// Source GLBs are checked for the same authorship receipt after conversion.
|
|
83
|
+
const authoredSteering = glbs.length > 0 || daes.some(daeHasAuthoredSteering);
|
|
84
|
+
const authoredTyres = glbs.length > 0 || daes.some(daeHasAuthoredTyre) || tyreDependency !== null;
|
|
85
|
+
const blockers = [];
|
|
86
|
+
if (jbeams.length === 0) blockers.push('no JBeam vehicle definition');
|
|
87
|
+
if (jbeams.length > 0 && jbeams.length < 5) blockers.push('fewer than five configurable JBeam part definitions');
|
|
88
|
+
if (daes.length === 0 && glbs.length === 0) blockers.push('no DAE or GLB render geometry');
|
|
89
|
+
if (materials.length === 0) blockers.push('no BeamNG material bindings');
|
|
90
|
+
if (cabin < 2) blockers.push('no convincing cabin/steering source');
|
|
91
|
+
if (!authoredSteering) blockers.push('no authored steering-wheel mesh in source geometry');
|
|
92
|
+
if (!authoredTyres) blockers.push('no authored tyre geometry in the mod or HELIX_BEAMNG_COMMON_ROOT');
|
|
93
|
+
if (wheels < 4) blockers.push('fewer than four wheel/rim/tyre source signals');
|
|
94
|
+
if (!hasAudio) blockers.push('no recorded audio and no source engine/exhaust sound reference');
|
|
95
|
+
const score = jbeams.length * 2 + Math.min(daes.length + glbs.length, 12) * 3
|
|
96
|
+
+ Math.min(materials.length, 8) * 2 + Math.min(cabin, 20) + Math.min(wheels, 12) + (hasAudio ? 10 : 0);
|
|
97
|
+
return { id: entry.name, root, files, jbeams, daes, glbs, materials, audio, audioReferences,
|
|
98
|
+
cabin, wheels, hasAudio, authoredSteering, authoredTyres, tyreDependency, blockers, score };
|
|
99
|
+
});
|
|
100
|
+
const viable = candidates.filter((row) => row.blockers.length === 0).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
101
|
+
if (viable.length === 0) {
|
|
102
|
+
throw new Error(`no importable BeamNG vehicle:\n${candidates.map((row) => `- ${row.id}: ${row.blockers.join('; ')}`).join('\n')}`);
|
|
103
|
+
}
|
|
104
|
+
if (viable.length > 1 && viable[0].score === viable[1].score) {
|
|
105
|
+
throw new Error(`ambiguous BeamNG mod: equally viable vehicles ${viable.filter((row) => row.score === viable[0].score).map((row) => row.id).join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
return { selected: viable[0], candidates };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function blenderExecutable() {
|
|
111
|
+
const candidates = [process.env.HELIX_BLENDER, '/Applications/Blender.app/Contents/MacOS/Blender', 'blender'].filter(Boolean);
|
|
112
|
+
return candidates.find((candidate) => candidate.includes('/') ? existsSync(candidate) : true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function run(executable, args) {
|
|
116
|
+
return new Promise((ok, fail) => {
|
|
117
|
+
const child = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
118
|
+
let stdout = '', stderr = '';
|
|
119
|
+
const tail = (current, chunk) => `${current}${chunk.toString('utf8')}`.slice(-MAX_CONVERTER_LOG_BYTES);
|
|
120
|
+
child.stdout.on('data', (chunk) => { stdout = tail(stdout, chunk); });
|
|
121
|
+
child.stderr.on('data', (chunk) => { stderr = tail(stderr, chunk); });
|
|
122
|
+
child.on('error', fail);
|
|
123
|
+
child.on('close', (code) => code === 0 ? ok(`${stdout}\n${stderr}`)
|
|
124
|
+
: fail(new Error(`${basename(executable)} exited ${code}: ${stderr.slice(-4000)}`)));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function requestedConfiguration(source, candidate) {
|
|
129
|
+
const hint = basename(source).toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
130
|
+
const matches = candidate.files.filter((path) => /^info(?:_[^.]+)?\.json$/i.test(basename(path))).flatMap((path) => {
|
|
131
|
+
try {
|
|
132
|
+
const info = JSON.parse(readFileSync(path, 'utf8'));
|
|
133
|
+
const id = basename(path).match(/^info_([^.]+)\.json$/i)?.[1] ?? '';
|
|
134
|
+
const tokens = `${String(info.Configuration ?? info.configuration ?? '')} ${id}`.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
135
|
+
const token = tokens.filter((value) => value.length >= 3 && hint.includes(value))
|
|
136
|
+
.sort((a, b) => b.length - a.length || a.localeCompare(b))[0];
|
|
137
|
+
return token ? [{ configuration: token, year: Number(info.Years?.max ?? info.years?.max ?? 0), specificity: token.length }] : [];
|
|
138
|
+
} catch { return []; }
|
|
139
|
+
}).sort((a, b) => b.specificity - a.specificity || b.year - a.year || a.configuration.localeCompare(b.configuration));
|
|
140
|
+
return matches[0]?.configuration || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function compatibilityProfile(candidate, configurationHint) {
|
|
144
|
+
const path = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'profiles', 'source-compatibility.json');
|
|
145
|
+
const profiles = JSON.parse(readFileSync(path, 'utf8')).profiles ?? [];
|
|
146
|
+
return profiles.find((profile) => profile.candidateId === candidate.id
|
|
147
|
+
&& (profile.configuration ?? null) === configurationHint) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function daeSelection(candidate, configurationHint = null) {
|
|
151
|
+
const own = candidate.daes.sort((a, b) => {
|
|
152
|
+
const identity = Number(basename(a, '.dae').toLowerCase() === candidate.id.toLowerCase())
|
|
153
|
+
- Number(basename(b, '.dae').toLowerCase() === candidate.id.toLowerCase());
|
|
154
|
+
return identity || lstatSync(b).size - lstatSync(a).size;
|
|
155
|
+
});
|
|
156
|
+
// BeamNG mods commonly carry complete trim/configuration variants as separate large DAEs.
|
|
157
|
+
// Combining them creates several cars in one GLB, so import exactly one highest-confidence
|
|
158
|
+
// primary body plus only small, explicitly named wheel geometry.
|
|
159
|
+
const rootLevel = own.filter((path) => dirname(path) === candidate.root);
|
|
160
|
+
let primary = rootLevel[0] ?? own[0];
|
|
161
|
+
const requestedBadge = configurationHint;
|
|
162
|
+
if (requestedBadge) {
|
|
163
|
+
const variants = candidate.files.filter((path) => /^info(?:_[^.]+)?\.json$/i.test(basename(path))).flatMap((path) => {
|
|
164
|
+
try {
|
|
165
|
+
const info = JSON.parse(readFileSync(path, 'utf8'));
|
|
166
|
+
const configuration = String(info.Configuration ?? info.configuration ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
167
|
+
const id = basename(path).match(/^info_([^.]+)\.json$/i)?.[1];
|
|
168
|
+
const year = Number(info.Years?.max ?? info.years?.max ?? 0);
|
|
169
|
+
return id && configuration.includes(requestedBadge) ? [{ id, year }] : [];
|
|
170
|
+
} catch { return []; }
|
|
171
|
+
}).sort((a, b) => b.year - a.year || a.id.localeCompare(b.id));
|
|
172
|
+
const selected = variants[0] && own.find((path) => path.split('/').includes(variants[0].id));
|
|
173
|
+
if (selected) primary = selected;
|
|
174
|
+
}
|
|
175
|
+
const normalized = (path) => path.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
176
|
+
const desired = requestedBadge
|
|
177
|
+
? own.filter((path) => normalized(relative(candidate.root, path)).includes(requestedBadge)) : [];
|
|
178
|
+
const desiredBody = desired.filter((path) => !/(wheel|rim|tyre|tire|steer)/i.test(path))
|
|
179
|
+
.sort((a, b) => lstatSync(b).size - lstatSync(a).size)[0];
|
|
180
|
+
const desiredSteering = desired.filter((path) => /steer/i.test(path))
|
|
181
|
+
.sort((a, b) => lstatSync(b).size - lstatSync(a).size)[0];
|
|
182
|
+
const desiredWheels = desired.filter((path) => /(wheel|rim|tyre|tire)/i.test(path))
|
|
183
|
+
.sort((a, b) => lstatSync(b).size - lstatSync(a).size).slice(0, 2);
|
|
184
|
+
const profile = compatibilityProfile(candidate, configurationHint);
|
|
185
|
+
const supplemental = (profile?.supplementalGeometry ?? []).flatMap((rule) => own.filter((path) => {
|
|
186
|
+
const relativePath = normalized(relative(candidate.root, path));
|
|
187
|
+
return (rule.include ?? []).every((token) => relativePath.includes(token))
|
|
188
|
+
&& !(rule.exclude ?? []).some((token) => relativePath.includes(token));
|
|
189
|
+
}).sort((a, b) => lstatSync(b).size - lstatSync(a).size).slice(0, 1));
|
|
190
|
+
const suspension = rootLevel.find((path) => /suspension/i.test(path));
|
|
191
|
+
const pedals = own.find((path) => /pedal/i.test(path));
|
|
192
|
+
const shared = own.filter((path) => !desired.includes(path) && path !== primary
|
|
193
|
+
&& /(wheel|rim|tyre|tire)/i.test(path) && lstatSync(path).size < 25 * 1024 * 1024);
|
|
194
|
+
return [...new Set([primary, suspension, desiredBody, ...supplemental, desiredSteering, ...desiredWheels, pedals,
|
|
195
|
+
candidate.tyreDependency,
|
|
196
|
+
...shared.slice(0, desiredWheels.length > 0 ? 0 : 3)].filter(Boolean))].slice(0, MAX_DAE);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stampBridgeProvenance(output, source, selected) {
|
|
200
|
+
injectVehicleReceipt(output, {
|
|
201
|
+
HELIX_provenance: {
|
|
202
|
+
source: `BeamNG vehicle mod ${basename(source)} / vehicles/${selected.id}`,
|
|
203
|
+
derivation: 'mod-derived',
|
|
204
|
+
distribution: 'INTERNAL-ONLY',
|
|
205
|
+
convertedBy: 'HELIX Bridge / hypersonic.beamng-vehicle',
|
|
206
|
+
convertedOn: new Date().toISOString().slice(0, 10),
|
|
207
|
+
pipeline: 'BeamNG candidate assessment + authored geometry/material conversion + canonical vehicle QA',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function convert(source, candidate, output, mirrorZ = false) {
|
|
213
|
+
if (candidate.glbs.length > 0) {
|
|
214
|
+
const preferred = candidate.glbs.sort((a, b) => lstatSync(b).size - lstatSync(a).size)[0];
|
|
215
|
+
copyFileSync(preferred, output);
|
|
216
|
+
return { converter: 'source-glb', sourceGeometry: [relative(candidate.root, preferred)] };
|
|
217
|
+
}
|
|
218
|
+
const blender = blenderExecutable();
|
|
219
|
+
if (!blender) throw new Error('Blender is required to convert BeamNG DAE geometry');
|
|
220
|
+
const configurationHint = requestedConfiguration(source, candidate);
|
|
221
|
+
const daes = daeSelection(candidate, configurationHint);
|
|
222
|
+
if (daes.length === 0) throw new Error('candidate has no DAE geometry after selection');
|
|
223
|
+
const receipt = `${output}.receipt.json`;
|
|
224
|
+
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
225
|
+
const args = ['-b', '--python', resolve(pluginDir, 'convert-beamng.py'), '--',
|
|
226
|
+
'--source-root', source, '--vehicle-root', candidate.root, '--output', output, '--receipt', receipt,
|
|
227
|
+
...daes.flatMap((path) => ['--dae', path]),
|
|
228
|
+
...(candidate.tyreDependency ? ['--material-root', resolve(process.env.HELIX_BEAMNG_COMMON_ROOT)] : []),
|
|
229
|
+
...(configurationHint ? ['--configuration-hint', configurationHint] : []),
|
|
230
|
+
...(mirrorZ ? ['--mirror-z'] : [])];
|
|
231
|
+
const converterLog = await run(blender, args);
|
|
232
|
+
if (!existsSync(receipt)) {
|
|
233
|
+
throw new Error(`Blender did not produce a vehicle receipt:\n${converterLog.slice(-6000)}`);
|
|
234
|
+
}
|
|
235
|
+
injectVehicleReceipt(output, JSON.parse(readFileSync(receipt, 'utf8')));
|
|
236
|
+
return { converter: 'blender-dae', sourceGeometry: daes.map((path) => path === candidate.tyreDependency
|
|
237
|
+
? `common-dependency:${dependencyReceipt(path)}` : relative(candidate.root, path)),
|
|
238
|
+
configurationHint, mirrorZ };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function main() {
|
|
242
|
+
let raw = '';
|
|
243
|
+
process.stdin.setEncoding('utf8');
|
|
244
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
245
|
+
const request = JSON.parse(raw);
|
|
246
|
+
if (request?.schemaVersion !== 'helix.bridge-plugin-request/1') throw new Error('unsupported Bridge plugin request');
|
|
247
|
+
const source = resolve(request.source.source);
|
|
248
|
+
const outputDir = resolve(request.outputDir);
|
|
249
|
+
mkdirSync(outputDir, { recursive: true });
|
|
250
|
+
const assessment = assess(source);
|
|
251
|
+
const selected = assessment.selected;
|
|
252
|
+
const output = join(outputDir, `${selected.id}.glb`);
|
|
253
|
+
let conversion = await convert(source, selected, output, false);
|
|
254
|
+
stampBridgeProvenance(output, source, selected);
|
|
255
|
+
let qa = auditVehicleGlb(output, { sourceRoot: selected.root, hasAudio: selected.hasAudio });
|
|
256
|
+
if (qa.findings.some((row) => row.code === 'frame.forward-semantics' && row.status === 'error') && selected.glbs.length === 0) {
|
|
257
|
+
conversion = await convert(source, selected, output, true);
|
|
258
|
+
stampBridgeProvenance(output, source, selected);
|
|
259
|
+
qa = auditVehicleGlb(output, { sourceRoot: selected.root, hasAudio: selected.hasAudio });
|
|
260
|
+
}
|
|
261
|
+
const report = {
|
|
262
|
+
schemaVersion: 'helix.beamng-vehicle-import/1',
|
|
263
|
+
selected: selected.id,
|
|
264
|
+
assessment: assessment.candidates.map((row) => ({ id: row.id, eligible: row.blockers.length === 0,
|
|
265
|
+
score: row.score, blockers: row.blockers, jbeam: row.jbeams.length, geometry: row.daes.length + row.glbs.length,
|
|
266
|
+
materials: row.materials.length, audio: row.audio.length,
|
|
267
|
+
audioReferences: row.audioReferences, cabinSignals: row.cabin, authoredSteering: row.authoredSteering,
|
|
268
|
+
authoredTyres: row.authoredTyres, tyreDependency: dependencyReceipt(row.tyreDependency), wheelSignals: row.wheels })),
|
|
269
|
+
conversion,
|
|
270
|
+
qa,
|
|
271
|
+
};
|
|
272
|
+
writeFileSync(join(outputDir, 'beamng-import-report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
273
|
+
if (!qa.eligible) {
|
|
274
|
+
throw new Error(`converted vehicle failed acceptance:\n${qa.findings.filter((row) => row.status === 'error').map((row) => `- ${row.code}: ${JSON.stringify(row.measured)} (${row.requirement})`).join('\n')}`);
|
|
275
|
+
}
|
|
276
|
+
process.stdout.write(JSON.stringify({ artifacts: [{ kind: 'format.glb', path: basename(output), metadata: report }] }));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });
|