sindicate 0.13.0 → 0.16.0

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.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // TRUTH → ASSEMBLIES — rebuild the KiwiRoads assemblies from Unity's OWN export
3
+ // (road-materials.json: per renderer, the prefab, mesh, Unity-resolved world transform
4
+ // and material stack). No YAML archaeology: Unity resolved its hierarchy itself.
5
+ //
6
+ // node tools/truthToAssemblies.mjs <SindicateExport> <kiwiAssets> <packDir>
7
+ //
8
+ // · entries come in CONTIGUOUS RUNS per prefab (the exporter walked prefab-by-prefab);
9
+ // same-named Motorway/DualCarriageway variants are split by run and labeled by where
10
+ // their mesh guids live on disk.
11
+ // · parts reference the deduped lib (meshGuid → asset path → aliases.json) or shared FBX
12
+ // props (copied into the pack on demand).
13
+ // · handedness: pos x → −x, quat (x,y,z,w) → (x,−y,−z,w) — same as unityMeshToGlb.
14
+ // · materials: mainTex + color tint + tiling carried per part; textures copied in.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const [exportDir, kiwiAssets, packDir] = process.argv.slice(2);
19
+ if (!exportDir || !kiwiAssets || !packDir) { console.error('usage: truthToAssemblies.mjs <SindicateExport> <kiwiAssets> <packDir>'); process.exit(1); }
20
+
21
+ // guid → asset path
22
+ const guidMap = new Map();
23
+ (function scan(dir) {
24
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
25
+ const p = path.join(dir, e.name);
26
+ if (e.isDirectory()) { scan(p); continue; }
27
+ if (!e.name.endsWith('.meta')) continue;
28
+ const m = fs.readFileSync(p, 'utf8').match(/guid: ([0-9a-f]{32})/);
29
+ if (m) guidMap.set(m[1], p.slice(0, -5));
30
+ }
31
+ })(kiwiAssets);
32
+
33
+ const aliases = JSON.parse(fs.readFileSync(path.join(packDir, 'aliases.json'), 'utf8'));
34
+ const ROADMESHES = path.join(kiwiAssets, 'AIGenerateMap/Generated/RoadMeshes');
35
+ const libFor = (assetPath) => {
36
+ const rel = assetPath.startsWith(ROADMESHES)
37
+ ? path.relative(ROADMESHES, assetPath).replace(/\.asset$/, '.glb')
38
+ : path.basename(assetPath).replace(/\.asset$/, '.glb');
39
+ return aliases[rel] ?? null;
40
+ };
41
+
42
+ const truth = JSON.parse(fs.readFileSync(path.join(exportDir, 'road-materials.json'), 'utf8')).entries;
43
+
44
+ // split into contiguous prefab runs
45
+ const runs = [];
46
+ for (const e of truth) {
47
+ const last = runs[runs.length - 1];
48
+ if (last && last.prefab === e.prefab) last.entries.push(e);
49
+ else runs.push({ prefab: e.prefab, entries: [e] });
50
+ }
51
+ // name each run; disambiguate dup-named variants by their meshes' disk location
52
+ const nameCounts = {};
53
+ for (const r of runs) nameCounts[r.prefab] = (nameCounts[r.prefab] ?? 0) + 1;
54
+ function variantLabel(run) {
55
+ if (nameCounts[run.prefab] === 1) return run.prefab;
56
+ for (const e of run.entries) {
57
+ const p = e.meshGuid && guidMap.get(e.meshGuid);
58
+ if (!p) continue;
59
+ if (p.includes('/Motorway/')) return `Motorway_${run.prefab}`;
60
+ if (p.includes('/DualCarriageway/')) return `DualCarriageway_${run.prefab}`;
61
+ }
62
+ return `${run.prefab}_v${(variantLabel._n = (variantLabel._n ?? 0) + 1)}`;
63
+ }
64
+
65
+ const flipPos = (p) => [-p[0], p[1], p[2]].map((v) => +v.toFixed(5));
66
+ const flipQuat = (q) => [q[0], -q[1], -q[2], q[3]].map((v) => +v.toFixed(6));
67
+
68
+ // FBX props keep Unity's per-file import scale: effective = globalScale × cm fileScale.
69
+ // PolygonCity files are metre-authored with globalScale 100 (net ×1); PolygonTown are
70
+ // true centimetres with globalScale 1 (net ×0.01). Hard-coding 0.01 shrank every
71
+ // PolygonCity road deck to a 5cm speck — the invisible-countryside-roads bug.
72
+ function metaScale(assetPath) {
73
+ try {
74
+ const meta = fs.readFileSync(assetPath + '.meta', 'utf8');
75
+ const g = +(meta.match(/globalScale: ([\d.eE+-]+)/)?.[1] ?? 1);
76
+ const useFile = (meta.match(/useFileScale: (\d)/)?.[1] ?? '1') === '1';
77
+ return g * (useFile ? 0.01 : 1);
78
+ } catch { return 0.01; }
79
+ }
80
+
81
+ const modelsDir = path.join(packDir, 'models');
82
+ const outDir = path.join(packDir, 'assemblies');
83
+ fs.rmSync(outDir, { recursive: true, force: true });
84
+ fs.mkdirSync(outDir, { recursive: true });
85
+ const fbxCopied = new Set();
86
+ const fbxSource = {}; // final filename → assetPath (same-named props from different packs collide)
87
+ const fbxScale = {}; // final filename → Unity effective import scale, shipped as fbxScale.json
88
+ let missingMeshes = 0;
89
+ const made = [];
90
+ for (const run of runs) {
91
+ const name = variantLabel(run).replace(/[^\w.-]+/g, '_');
92
+ const parts = [];
93
+ for (const e of run.entries) {
94
+ if (e.active === false) continue; // disabled variant geometry — Unity hides it, so do we
95
+ let file = null;
96
+ const assetPath = e.meshGuid && guidMap.get(e.meshGuid);
97
+ if (e.mesh === 'unity default resources' && e.meshName) file = `builtin:${e.meshName}`;
98
+ else if (assetPath?.endsWith('.asset')) file = libFor(assetPath);
99
+ else if (assetPath && /\.fbx$/i.test(assetPath)) {
100
+ file = path.basename(assetPath);
101
+ if (fbxSource[file] && fbxSource[file] !== assetPath) {
102
+ // same basename from a different pack — prefix with the pack dir (Polygon*)
103
+ const pack = assetPath.split(path.sep).findLast((s) => /^Polygon/i.test(s)) ?? path.basename(path.dirname(assetPath));
104
+ file = `${pack}_${file}`;
105
+ }
106
+ if (!fbxSource[file]) {
107
+ fs.copyFileSync(assetPath, path.join(modelsDir, file)); // always overwrite — purge stale first-run copies
108
+ fbxSource[file] = assetPath;
109
+ fbxScale[file] = +metaScale(assetPath).toFixed(6);
110
+ fbxCopied.add(file);
111
+ }
112
+ }
113
+ if (!file) { missingMeshes++; continue; }
114
+ const m0 = (e.materials ?? []).find((m) => m);
115
+ parts.push({
116
+ file,
117
+ pos: flipPos(e.pos), quat: flipQuat(e.quat), scale: e.scale.map((v) => +v.toFixed(5)),
118
+ ...(m0?.mainTex ? { tex: m0.mainTex } : {}),
119
+ ...(m0 && m0.color?.some((c, i) => Math.abs(c - 1) > 0.01 && i < 3) ? { color: m0.color.slice(0, 3).map((c) => +c.toFixed(3)) } : {}),
120
+ ...(m0 && (m0.tiling?.[0] !== 1 || m0.tiling?.[1] !== 1) ? { tiling: m0.tiling } : {}),
121
+ });
122
+ }
123
+ if (!parts.length) continue;
124
+ fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify({ name, parts }));
125
+ made.push(name);
126
+ }
127
+ // reconstructed parts where Kiwi's own assets are gone upstream (e.g. SimpleRoundabout
128
+ // road meshes whose generated .assets were deleted): patches/<Assembly>.json parts are
129
+ // merged in on every rebuild so the reconstruction survives truth re-exports.
130
+ const patchDir = path.join(packDir, 'patches');
131
+ let patched = 0;
132
+ if (fs.existsSync(patchDir)) {
133
+ for (const f of fs.readdirSync(patchDir)) {
134
+ if (!f.endsWith('.json')) continue;
135
+ const name = f.slice(0, -5);
136
+ const patch = JSON.parse(fs.readFileSync(path.join(patchDir, f), 'utf8'));
137
+ const outPath = path.join(outDir, `${name}.json`);
138
+ const man = fs.existsSync(outPath) ? JSON.parse(fs.readFileSync(outPath, 'utf8')) : { name, parts: [] };
139
+ man.parts.push(...patch.parts);
140
+ fs.writeFileSync(outPath, JSON.stringify(man));
141
+ if (!made.includes(name)) made.push(name);
142
+ patched++;
143
+ }
144
+ }
145
+ fs.writeFileSync(path.join(outDir, 'index.json'), JSON.stringify([...new Set(made)].sort()));
146
+ fs.writeFileSync(path.join(packDir, 'fbxScale.json'), JSON.stringify(fbxScale, null, 1));
147
+ if (patched) console.log(`${patched} assembly patches merged`);
148
+
149
+ // textures
150
+ const texSrc = path.join(exportDir, 'textures');
151
+ const texDir = path.join(packDir, 'textures');
152
+ fs.mkdirSync(texDir, { recursive: true });
153
+ let texN = 0;
154
+ for (const t of fs.readdirSync(texSrc)) {
155
+ if (t.endsWith('.meta')) continue; // Unity bookkeeping, not an asset
156
+ fs.copyFileSync(path.join(texSrc, t), path.join(texDir, t));
157
+ texN++;
158
+ }
159
+
160
+ console.log(`${made.length} assemblies from Unity truth · ${fbxCopied.size} new FBX parts copied · ${texN} textures · ${missingMeshes} entries without a resolvable mesh`);
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // UNITY .ASSET MESH → GLB — salvage text-serialized Unity meshes (YAML, serializedVersion
3
+ // 12) without opening Unity. Built to recover Nick's hand-tuned Kiwi road pieces
4
+ // (junctions, road segments, interchanges) into a Sindicate pack.
5
+ //
6
+ // node tools/unityMeshToGlb.mjs <src.asset|srcDir> <outDir>
7
+ //
8
+ // Reads m_VertexData (interleaved stream-0 channels: 0=position 1=normal 2=tangent
9
+ // 3=color 4=uv0, format 0 = float32), m_IndexBuffer (u16/u32 by m_IndexFormat) and
10
+ // m_SubMeshes; converts LEFT-handed Unity → RIGHT-handed glTF (negate X on positions
11
+ // and normals, reverse triangle winding); emits a minimal GLB with one primitive per
12
+ // submesh. Skips compressed meshes (m_CompressedMesh with non-zero payload) — none of
13
+ // the Kiwi road meshes are compressed.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { writeGlb } from './glbWrite.mjs';
17
+
18
+ const CH_NAMES = ['POSITION', 'NORMAL', 'TANGENT', 'COLOR_0', 'TEXCOORD_0', 'TEXCOORD_1'];
19
+
20
+ function parseMeshYaml(text, name) {
21
+ const num = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
22
+ const hex = (re) => { const m = text.match(re); return m ? m[1] : ''; };
23
+ const vertexCount = num(/m_VertexCount: (\d+)/);
24
+ const indexFormat = num(/m_IndexFormat: (\d+)/) ?? 0;
25
+ const indexHex = hex(/m_IndexBuffer: ([0-9a-f]+)/);
26
+ const dataHex = hex(/_typelessdata: ([0-9a-f]+)/);
27
+ if (!vertexCount || !indexHex || !dataHex) throw new Error(`${name}: not a parseable mesh (missing buffers)`);
28
+
29
+ // channels: 14 fixed slots, stream/offset/format/dimension quads in order
30
+ const chBlock = text.match(/m_Channels:\n((?:\s+- stream: .*\n(?:\s+\w+: .*\n)*)+)/);
31
+ if (!chBlock) throw new Error(`${name}: no channel table`);
32
+ const quads = [...chBlock[1].matchAll(/- stream: (\d+)\n\s+offset: (\d+)\n\s+format: (\d+)\n\s+dimension: (\d+)/g)]
33
+ .map((m) => ({ stream: +m[1], offset: +m[2], format: +m[3], dimension: +m[4] & 0x7f }));
34
+ const active = quads.map((c, i) => ({ ...c, slot: i })).filter((c) => c.dimension > 0);
35
+ for (const c of active) {
36
+ if (c.stream !== 0) throw new Error(`${name}: multi-stream vertex data not supported (slot ${c.slot})`);
37
+ if (c.format !== 0) throw new Error(`${name}: non-float32 channel (slot ${c.slot} format ${c.format})`);
38
+ }
39
+ const stride = Math.max(...active.map((c) => c.offset + c.dimension * 4));
40
+
41
+ const data = Buffer.from(dataHex, 'hex');
42
+ if (data.length < vertexCount * stride) throw new Error(`${name}: vertex buffer short (${data.length} < ${vertexCount}×${stride})`);
43
+ const idxBuf = Buffer.from(indexHex, 'hex');
44
+ const idxSize = indexFormat === 0 ? 2 : 4;
45
+ const indices = [];
46
+ for (let o = 0; o + idxSize <= idxBuf.length; o += idxSize) {
47
+ indices.push(indexFormat === 0 ? idxBuf.readUInt16LE(o) : idxBuf.readUInt32LE(o));
48
+ }
49
+
50
+ // submeshes (topology 0 = triangles only)
51
+ const subs = [...text.matchAll(/firstByte: (\d+)\n\s+indexCount: (\d+)\n\s+topology: (\d+)\n\s+baseVertex: (\d+)/g)]
52
+ .map((m) => ({ firstIndex: +m[1] / idxSize, indexCount: +m[2], topology: +m[3], baseVertex: +m[4] }));
53
+ for (const s of subs) if (s.topology !== 0) throw new Error(`${name}: non-triangle submesh`);
54
+
55
+ // de-interleave the channels we keep, converting handedness
56
+ const attrs = {};
57
+ for (const c of active) {
58
+ if (c.slot > 5) continue; // uv1 is the last we care about
59
+ const semantic = CH_NAMES[c.slot];
60
+ if (!semantic) continue;
61
+ const out = new Float32Array(vertexCount * c.dimension);
62
+ for (let v = 0; v < vertexCount; v++) {
63
+ const base = v * stride + c.offset;
64
+ for (let d = 0; d < c.dimension; d++) out[v * c.dimension + d] = data.readFloatLE(base + d * 4);
65
+ if ((c.slot === 0 || c.slot === 1) && c.dimension >= 1) out[v * c.dimension] *= -1; // negate X
66
+ }
67
+ attrs[semantic] = { array: out, dim: c.dimension };
68
+ }
69
+ if (!attrs.POSITION) throw new Error(`${name}: no positions`);
70
+ // reverse winding (handedness flip turns faces inside out otherwise)
71
+ for (let i = 0; i + 2 < indices.length; i += 3) { const t = indices[i]; indices[i] = indices[i + 2]; indices[i + 2] = t; }
72
+
73
+ return { name, vertexCount, indices, attrs, subs };
74
+ }
75
+
76
+
77
+ const [src, outDir] = process.argv.slice(2);
78
+ if (!src || !outDir) { console.error('usage: unityMeshToGlb.mjs <src.asset|srcDir> <outDir>'); process.exit(1); }
79
+ fs.mkdirSync(outDir, { recursive: true });
80
+ const files = fs.statSync(src).isDirectory()
81
+ ? fs.readdirSync(src).filter((f) => f.endsWith('.asset')).map((f) => path.join(src, f))
82
+ : [src];
83
+ let ok = 0, skip = 0;
84
+ for (const f of files) {
85
+ const name = path.basename(f, '.asset');
86
+ try {
87
+ const text = fs.readFileSync(f, 'utf8');
88
+ if (!/^%YAML/.test(text) || !/!u!43 /.test(text)) { skip++; continue; } // not a text mesh asset
89
+ const mesh = parseMeshYaml(text, name);
90
+ const r = writeGlb(mesh, path.join(outDir, `${name}.glb`));
91
+ console.log(`${name}: ${r.verts} verts, ${r.tris} tris, ${r.prims} prim(s), ${(r.bytes / 1024).toFixed(0)}KB`);
92
+ ok++;
93
+ } catch (e) { console.warn(`SKIP ${name}: ${e.message}`); skip++; }
94
+ }
95
+ console.log(`\n${ok} converted, ${skip} skipped`);
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // UNITYPACKAGE EXTRACT — pull NAMED assets out of a .unitypackage.
3
+ //
4
+ // The dev-server importer takes a whole pack; this is for when you want a handful of things
5
+ // out of a 90 MB archive (the cars from POLYGON City, say) without staging the other 2,000
6
+ // assets. A .unitypackage is a gzip tar of GUID directories, each holding `pathname`,
7
+ // `asset` and `asset.meta`; this rebuilds the real filenames for whatever matches.
8
+ //
9
+ // node tools/unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]
10
+ //
11
+ // The .meta travels with each asset because Unity keeps the FBX import scale there, and
12
+ // Synty packs mix metre- and centimetre-authored models (see tools/fbxToGlb.mjs).
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { execFileSync } from 'node:child_process';
17
+
18
+ const [pkg, outDir, pattern, ...rest] = process.argv.slice(2);
19
+ if (!pkg || !outDir || !pattern) {
20
+ console.error('usage: unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]');
21
+ process.exit(1);
22
+ }
23
+ const listOnly = rest.includes('--list');
24
+ const re = new RegExp(pattern, 'i');
25
+
26
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'unitypkg-'));
27
+ try {
28
+ execFileSync('tar', ['xzf', pkg, '-C', work], { stdio: ['ignore', 'ignore', 'pipe'] });
29
+ const hits = [];
30
+ for (const dir of fs.readdirSync(work)) {
31
+ const base = path.join(work, dir);
32
+ const pnFile = path.join(base, 'pathname');
33
+ if (!fs.existsSync(pnFile)) continue;
34
+ const assetPath = fs.readFileSync(pnFile, 'utf8').split('\n')[0].trim();
35
+ if (!re.test(assetPath)) continue;
36
+ if (!fs.existsSync(path.join(base, 'asset'))) continue; // a folder entry, not a file
37
+ hits.push({ dir: base, assetPath });
38
+ }
39
+ hits.sort((a, b) => a.assetPath.localeCompare(b.assetPath));
40
+ if (listOnly) {
41
+ for (const h of hits) console.log(h.assetPath);
42
+ console.log(`\n${hits.length} matching assets`);
43
+ } else {
44
+ fs.mkdirSync(outDir, { recursive: true });
45
+ for (const h of hits) {
46
+ const name = path.basename(h.assetPath);
47
+ fs.copyFileSync(path.join(h.dir, 'asset'), path.join(outDir, name));
48
+ const meta = path.join(h.dir, 'asset.meta');
49
+ if (fs.existsSync(meta)) fs.copyFileSync(meta, path.join(outDir, `${name}.meta`));
50
+ console.log(`${name} ← ${h.assetPath}`);
51
+ }
52
+ console.log(`\n${hits.length} assets extracted to ${outDir}`);
53
+ }
54
+ } finally {
55
+ fs.rmSync(work, { recursive: true, force: true });
56
+ }
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // UNITY .PREFAB → ASSEMBLY MANIFEST — rebuild Nick's CONSTRUCTED Kiwi road junctions
3
+ // (roundabouts, overpasses, dumbbells) as manifests of SHARED parts + transforms:
4
+ // { name, parts: [{ file, pos:[x,y,z], quat:[x,y,z,w], scale:[x,y,z] }], missing: [] }
5
+ // Parts reference the deduped KiwiRoads library (aliases.json), so a hundred-piece
6
+ // interchange costs a few kilobytes and every guardrail mesh is loaded ONCE — reuse,
7
+ // never copies.
8
+ //
9
+ // node tools/unityPrefabToAssembly.mjs <kiwiAssetsDir> <packDir> <prefabDir...>
10
+ //
11
+ // Parses the YAML class docs: GameObject(1), Transform(4), MeshFilter(33) and nested
12
+ // PrefabInstance(1001) (m_SourcePrefab guid + m_Modifications overrides on the source's
13
+ // root transform), recursing into Kiwi-local source prefabs. Meshes referenced from FBX
14
+ // model files (Synty signs/barriers) are recorded in `missing` with their file names —
15
+ // they wire to the POLYGON_City pack import later. Handedness matches unityMeshToGlb:
16
+ // position x negated, quaternion (x,y,z,w) → (x,-y,-z,w).
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ const [kiwiAssets, packDir, ...prefabDirs] = process.argv.slice(2);
21
+ if (!kiwiAssets || !packDir || !prefabDirs.length) {
22
+ console.error('usage: unityPrefabToAssembly.mjs <kiwiAssetsDir> <packDir> <prefabDir...>');
23
+ process.exit(1);
24
+ }
25
+
26
+ // ── guid → asset path map (scan .meta files once) ──
27
+ const guidMap = new Map();
28
+ (function scan(dir) {
29
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
30
+ const p = path.join(dir, e.name);
31
+ if (e.isDirectory()) { scan(p); continue; }
32
+ if (!e.name.endsWith('.meta')) continue;
33
+ const m = fs.readFileSync(p, 'utf8').match(/guid: ([0-9a-f]{32})/);
34
+ if (m) guidMap.set(m[1], p.slice(0, -5));
35
+ }
36
+ })(kiwiAssets);
37
+ console.log(`guid map: ${guidMap.size} assets`);
38
+
39
+ // material guid → its main texture's asset path (URP _BaseMap or legacy _MainTex)
40
+ const texNeeded = new Map();
41
+ const matTexCache = new Map();
42
+ function textureOfMaterial(matGuid) {
43
+ if (!matGuid) return null;
44
+ if (matTexCache.has(matGuid)) return matTexCache.get(matGuid);
45
+ let texPath = null;
46
+ const matPath = guidMap.get(matGuid);
47
+ if (matPath && matPath.endsWith('.mat')) {
48
+ const y = fs.readFileSync(matPath, 'utf8');
49
+ const m = y.match(/_(?:BaseMap|MainTex):\n\s+m_Texture: \{fileID: \d+, guid: ([0-9a-f]{32})/);
50
+ const tp = m ? guidMap.get(m[1]) : null;
51
+ if (tp && /\.(png|jpg|jpeg|tga)$/i.test(tp)) { texPath = tp; texNeeded.set(path.basename(tp), tp); }
52
+ }
53
+ matTexCache.set(matGuid, texPath);
54
+ return texPath;
55
+ }
56
+
57
+ // ── converted-name resolution: asset path → canonical lib GLB (via aliases.json) ──
58
+ const aliases = JSON.parse(fs.readFileSync(path.join(packDir, 'aliases.json'), 'utf8'));
59
+ const ROADMESHES = path.join(kiwiAssets, 'AIGenerateMap/Generated/RoadMeshes');
60
+ function libNameFor(assetPath) {
61
+ let rel;
62
+ if (assetPath.startsWith(ROADMESHES)) rel = path.relative(ROADMESHES, assetPath).replace(/\.asset$/, '.glb');
63
+ else rel = path.basename(assetPath).replace(/\.asset$/, '.glb');
64
+ return aliases[rel] ?? null;
65
+ }
66
+
67
+ // ── minimal Unity YAML doc parser ──
68
+ function parseDocs(text) {
69
+ const docs = [];
70
+ const re = /^--- !u!(\d+) &(-?\d+)( stripped)?$/gm;
71
+ let m, prev = null;
72
+ while ((m = re.exec(text))) {
73
+ if (prev) prev.body = text.slice(prev.end, m.index);
74
+ prev = { cls: +m[1], id: m[2], stripped: !!m[3], end: m.index + m[0].length };
75
+ docs.push(prev);
76
+ }
77
+ if (prev) prev.body = text.slice(prev.end);
78
+ return docs;
79
+ }
80
+ const ref = (body, key) => body.match(new RegExp(`${key}: \\{fileID: (-?\\d+)(?:, guid: ([0-9a-f]{32}))?`))?.slice(1) ?? [null, null];
81
+ const vec = (body, key, dims) => {
82
+ const m = body.match(new RegExp(`${key}: \\{x: ([-\\d.e]+), y: ([-\\d.e]+), z: ([-\\d.e]+)(?:, w: ([-\\d.e]+))?\\}`));
83
+ if (!m) return null;
84
+ return m.slice(1, 1 + dims).map(Number);
85
+ };
86
+
87
+ // ── quaternion/matrix helpers (column-major 4x4, y-up) ──
88
+ const qMul = (a, b) => [
89
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
90
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
91
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
92
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
93
+ ];
94
+ const qRot = (q, v) => {
95
+ const [x, y, z, w] = q, [vx, vy, vz] = v;
96
+ const tx = 2 * (y * vz - z * vy), ty = 2 * (z * vx - x * vz), tz = 2 * (x * vy - y * vx);
97
+ return [vx + w * tx + y * tz - z * ty, vy + w * ty + z * tx - x * tz, vz + w * tz + x * ty - y * tx];
98
+ };
99
+ const trsCompose = (p, q, s, cp, cq, cs) => ({
100
+ // parent (p,q,s) ∘ child (cp,cq,cs)
101
+ p: (() => { const sc = [cp[0] * s[0], cp[1] * s[1], cp[2] * s[2]]; const r = qRot(q, sc); return [p[0] + r[0], p[1] + r[1], p[2] + r[2]]; })(),
102
+ q: qMul(q, cq),
103
+ s: [s[0] * cs[0], s[1] * cs[1], s[2] * cs[2]],
104
+ });
105
+ const ID = { p: [0, 0, 0], q: [0, 0, 0, 1], s: [1, 1, 1] };
106
+
107
+ // ── prefab → parts (recursive over nested PrefabInstances) ──
108
+ const prefabCache = new Map();
109
+ const fbxNeeded = new Map(); // basename → source path (copied into the pack after assembly)
110
+ function loadPrefab(file) {
111
+ if (prefabCache.has(file)) return prefabCache.get(file);
112
+ const docs = parseDocs(fs.readFileSync(file, 'utf8'));
113
+ const gos = new Map(), transforms = new Map(), filters = [], instances = [], renderers = new Map(), stripped = new Map();
114
+ for (const d of docs) {
115
+ if (d.cls === 1) gos.set(d.id, { name: d.body.match(/m_Name: (.*)/)?.[1] ?? '?' });
116
+ else if (d.cls === 4) {
117
+ if (d.stripped) {
118
+ // a stripped Transform stands in for a transform INSIDE a PrefabInstance —
119
+ // children parented to it belong under that instance's world transform
120
+ stripped.set(d.id, d.body.match(/m_PrefabInstance: \{fileID: (-?\d+)/)?.[1] ?? null);
121
+ continue;
122
+ }
123
+ transforms.set(d.id, {
124
+ go: ref(d.body, 'm_GameObject')[0],
125
+ father: ref(d.body, 'm_Father')[0],
126
+ p: vec(d.body, 'm_LocalPosition', 3) ?? [0, 0, 0],
127
+ q: vec(d.body, 'm_LocalRotation', 4) ?? [0, 0, 0, 1],
128
+ s: vec(d.body, 'm_LocalScale', 3) ?? [1, 1, 1],
129
+ });
130
+ } else if (d.cls === 33) {
131
+ const [fid, guid] = ref(d.body, 'm_Mesh');
132
+ filters.push({ go: ref(d.body, 'm_GameObject')[0], meshGuid: guid, meshFid: fid });
133
+ } else if (d.cls === 23) {
134
+ const matGuid = d.body.match(/m_Materials:\n\s+- \{fileID: \d+, guid: ([0-9a-f]{32})/)?.[1] ?? null;
135
+ renderers.set(ref(d.body, 'm_GameObject')[0], matGuid);
136
+ } else if (d.cls === 1001) {
137
+ const srcGuid = d.body.match(/m_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})/)?.[1];
138
+ const parent = d.body.match(/m_TransformParent: \{fileID: (-?\d+)/)?.[1] ?? '0';
139
+ // OVERRIDES ARE PER-TARGET: group by the fileID they modify inside the source
140
+ const modsByTarget = {};
141
+ for (const mm of d.body.matchAll(/- target: \{fileID: (-?\d+), guid: [0-9a-f]{32}, type: \d+\}\n\s+propertyPath: (m_Local(?:Position|Rotation|Scale)\.[xyzw])\n\s+value: ([-\d.e]+)/g)) {
142
+ (modsByTarget[mm[1]] ??= {})[mm[2]] = Number(mm[3]);
143
+ }
144
+ instances.push({ id: d.id, srcGuid, parent, modsByTarget });
145
+ }
146
+ }
147
+ const out = { gos, transforms, filters, instances, renderers, stripped };
148
+ prefabCache.set(file, out);
149
+ return out;
150
+ }
151
+
152
+ function worldOf(transforms, tid) {
153
+ const chain = [];
154
+ let cur = tid;
155
+ let guard = 0;
156
+ while (cur && cur !== '0' && transforms.has(cur) && guard++ < 64) { chain.unshift(transforms.get(cur)); cur = transforms.get(cur).father; }
157
+ let acc = ID;
158
+ for (const t of chain) acc = trsCompose(acc.p, acc.q, acc.s, t.p, t.q, t.s);
159
+ return acc;
160
+ }
161
+
162
+ function assemble(file, base = ID, depth = 0) {
163
+ if (depth > 4) return { parts: [], missing: [] };
164
+ const { gos, transforms, filters, instances } = loadPrefab(file);
165
+ const tByGo = new Map([...transforms.entries()].map(([id, t]) => [t.go, id]));
166
+ const parts = [], missing = [];
167
+ for (const f of filters) {
168
+ const tid = tByGo.get(f.go);
169
+ const w = tid ? worldOf(transforms, tid) : ID;
170
+ const acc = trsCompose(base.p, base.q, base.s, w.p, w.q, w.s);
171
+ const assetPath = f.meshGuid ? guidMap.get(f.meshGuid) : null;
172
+ const texPath = textureOfMaterial(loadPrefab(file).renderers.get(f.go));
173
+ const tex = texPath ? path.basename(texPath) : undefined;
174
+ if (assetPath && assetPath.endsWith('.asset')) {
175
+ const lib = libNameFor(assetPath);
176
+ if (lib) { parts.push({ file: lib, ...(tex ? { tex } : {}), ...acc }); continue; }
177
+ }
178
+ if (assetPath && /\.fbx$/i.test(assetPath)) {
179
+ fbxNeeded.set(path.basename(assetPath), assetPath);
180
+ parts.push({ file: path.basename(assetPath), ...(tex ? { tex } : {}), ...acc });
181
+ continue;
182
+ }
183
+ missing.push({ mesh: assetPath ? path.basename(assetPath) : `guid:${f.meshGuid}#${f.meshFid}`, from: gos.get(f.go)?.name });
184
+ }
185
+ const instWorlds = new Map(); // instance id → resolved world (for stripped-parented children)
186
+ for (const inst of instances) {
187
+ const src = inst.srcGuid ? guidMap.get(inst.srcGuid) : null;
188
+ // the SOURCE's own root transform provides the DEFAULTS; mods targeting that root override
189
+ let srcRoot = { p: [0, 0, 0], q: [0, 0, 0, 1], s: [1, 1, 1] }, srcRootId = null;
190
+ if (src && src.endsWith('.prefab')) {
191
+ const sp = loadPrefab(src);
192
+ for (const [tid, t] of sp.transforms) if (!t.father || t.father === '0') { srcRoot = t; srcRootId = tid; break; }
193
+ }
194
+ const mods = (srcRootId && inst.modsByTarget[srcRootId]) ?? Object.values(inst.modsByTarget)[0] ?? {};
195
+ const local = {
196
+ p: [mods['m_LocalPosition.x'] ?? srcRoot.p[0], mods['m_LocalPosition.y'] ?? srcRoot.p[1], mods['m_LocalPosition.z'] ?? srcRoot.p[2]],
197
+ q: [mods['m_LocalRotation.x'] ?? srcRoot.q[0], mods['m_LocalRotation.y'] ?? srcRoot.q[1], mods['m_LocalRotation.z'] ?? srcRoot.q[2], mods['m_LocalRotation.w'] ?? srcRoot.q[3]],
198
+ s: [mods['m_LocalScale.x'] ?? srcRoot.s[0], mods['m_LocalScale.y'] ?? srcRoot.s[1], mods['m_LocalScale.z'] ?? srcRoot.s[2]],
199
+ };
200
+ // the instance parents into THIS prefab's hierarchy — possibly via a STRIPPED transform
201
+ // standing in for a transform inside an earlier PrefabInstance
202
+ const here = loadPrefab(file);
203
+ let parentW = ID;
204
+ if (inst.parent && inst.parent !== '0') {
205
+ if (here.transforms.has(inst.parent)) parentW = worldOf(here.transforms, inst.parent);
206
+ else if (here.stripped.has(inst.parent)) parentW = instWorlds.get(here.stripped.get(inst.parent)) ?? ID;
207
+ }
208
+ const baseW = trsCompose(base.p, base.q, base.s, parentW.p, parentW.q, parentW.s);
209
+ const instW = trsCompose(baseW.p, baseW.q, baseW.s, local.p, local.q, local.s);
210
+ instWorlds.set(inst.id, instW);
211
+ if (src && src.endsWith('.prefab')) {
212
+ const sub = assemble(src, instW, depth + 1);
213
+ parts.push(...sub.parts);
214
+ missing.push(...sub.missing);
215
+ } else if (src && /\.fbx$/i.test(src)) {
216
+ fbxNeeded.set(path.basename(src), src);
217
+ parts.push({ file: path.basename(src), ...instW });
218
+ } else if (src) {
219
+ missing.push({ mesh: path.basename(src), from: 'unresolved instance' });
220
+ }
221
+ }
222
+ return { parts, missing };
223
+ }
224
+
225
+ // unity → three handedness on the FINAL transforms
226
+ const flip = (t) => ({
227
+ pos: [-t.p[0], t.p[1], t.p[2]].map((v) => +v.toFixed(5)),
228
+ quat: [t.q[0], -t.q[1], -t.q[2], t.q[3]].map((v) => +v.toFixed(6)),
229
+ scale: t.s.map((v) => +v.toFixed(5)),
230
+ });
231
+
232
+ const outDir = path.join(packDir, 'assemblies');
233
+ fs.mkdirSync(outDir, { recursive: true });
234
+ const made = [];
235
+ for (const dir of prefabDirs) {
236
+ const files = [];
237
+ (function walk(d) {
238
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
239
+ const p = path.join(d, e.name);
240
+ if (e.isDirectory()) walk(p);
241
+ else if (e.name.endsWith('.prefab')) files.push(p);
242
+ }
243
+ })(dir);
244
+ for (const f of files) {
245
+ // variant dirs (Motorway/DualCarriageway/L2-R1…) share basenames — the name carries the path
246
+ const relName = path.relative(dir, f).replace(/\.prefab$/, '');
247
+ const name = relName.split(path.sep).filter((seg, i, a) => !a[i + 1]?.startsWith(seg)).join('_').replace(/[^\w.-]+/g, '_');
248
+ try {
249
+ const { parts, missing } = assemble(f);
250
+ const manifest = {
251
+ name,
252
+ parts: parts.map((p) => ({ file: p.file, ...(p.tex ? { tex: p.tex } : {}), ...flip(p) })),
253
+ missing: missing.map((m) => (m.p ? { mesh: m.mesh, from: m.from, ...flip(m) } : m)),
254
+ };
255
+ fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify(manifest));
256
+ made.push(name);
257
+ console.log(`${name}: ${parts.length} parts, ${missing.length} missing`);
258
+ } catch (e) { console.warn(`SKIP ${name}: ${e.message}`); }
259
+ }
260
+ }
261
+ fs.writeFileSync(path.join(outDir, 'index.json'), JSON.stringify([...new Set(made)].sort()));
262
+ // copy every referenced Synty FBX into the pack's model library (shared, deduped by name)
263
+ const modelsDir = path.join(packDir, 'models');
264
+ let copied = 0;
265
+ for (const [name, srcPath] of fbxNeeded) {
266
+ const dst = path.join(modelsDir, name);
267
+ if (!fs.existsSync(dst)) { fs.copyFileSync(srcPath, dst); copied++; }
268
+ }
269
+ const texDir = path.join(packDir, 'textures');
270
+ fs.mkdirSync(texDir, { recursive: true });
271
+ let texCopied = 0;
272
+ for (const [name, srcPath] of texNeeded) {
273
+ const dst = path.join(texDir, name);
274
+ if (!fs.existsSync(dst)) { fs.copyFileSync(srcPath, dst); texCopied++; }
275
+ }
276
+ console.log(`\n${made.length} assemblies · ${fbxNeeded.size} shared FBX parts (${copied} copied) · ${texNeeded.size} textures (${texCopied} copied)`);