sindicate 0.12.0 → 0.15.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.
- package/CHANGELOG.md +69 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +11 -3
- package/src/core/assets.js +15 -0
- package/src/systems/missions.js +2 -2
- package/src/systems/shatter.js +2 -1
- package/src/ui/dialogue.js +2 -1
- package/src/ui/pauseMenu.js +18 -11
- package/src/ui/saveLoadScreen.js +13 -9
- package/src/ui/theme.js +34 -0
- package/src/world/bridgeSplice.js +176 -0
- package/src/world/design.js +72 -0
- package/src/world/geo.js +53 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/railFormation.js +185 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +270 -0
- package/src/world/roadNetwork.js +118 -0
- package/src/world/roadTerrain.js +263 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +324 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/registry.json +45 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/sindicate-cli.mjs +71 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE .sindicatepackage CLI (ENGINE_DESIGN §4c) — one distributable wrapper for everything
|
|
3
|
+
// installable. Under the hood: an npm-compatible tarball (package/ prefix) with a
|
|
4
|
+
// sindicate.json manifest at the package root, so code add-ons install with plain npm and
|
|
5
|
+
// the engine tooling can read what a thing IS before installing it.
|
|
6
|
+
//
|
|
7
|
+
// sindicate pack <dir> → <name>-<version>.sindicatepackage (from
|
|
8
|
+
// package.json + sindicate.json in <dir>)
|
|
9
|
+
// sindicate inspect <file> → print the manifest
|
|
10
|
+
// sindicate install <file> [--game .] → type 'addon' = npm install the tarball into
|
|
11
|
+
// the game (dep + node_modules, bridge on boot)
|
|
12
|
+
// type 'assets'|'clips' = extract package/assets
|
|
13
|
+
// into <game>/public/assets/packs/<name>/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const [, , cmd, target, ...rest] = process.argv;
|
|
19
|
+
const opt = (name, dflt) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : dflt; };
|
|
20
|
+
const die = (m) => { console.error(`sindicate: ${m}`); process.exit(1); };
|
|
21
|
+
|
|
22
|
+
function readManifestFromTarball(file) {
|
|
23
|
+
try {
|
|
24
|
+
const out = execFileSync('tar', ['-xzOf', file, 'package/sindicate.json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
return JSON.parse(out.toString());
|
|
26
|
+
} catch (e) { die(`${path.basename(file)}: no readable package/sindicate.json — not a .sindicatepackage (${String(e).slice(0, 120)})`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (cmd === 'pack') {
|
|
30
|
+
const dir = path.resolve(target ?? '.');
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
32
|
+
const sinPath = path.join(dir, 'sindicate.json');
|
|
33
|
+
if (!fs.existsSync(sinPath)) die(`${dir}/sindicate.json missing — add { name, version, type, description } first`);
|
|
34
|
+
const sin = JSON.parse(fs.readFileSync(sinPath, 'utf8'));
|
|
35
|
+
// npm pack builds the canonical npm tarball (honours "files", writes package/ prefix);
|
|
36
|
+
// sindicate.json rides along because it is listed in "files" (enforce that here).
|
|
37
|
+
const files = pkg.files ?? [];
|
|
38
|
+
if (files.length && !files.includes('sindicate.json')) die(`package.json "files" must include "sindicate.json" so the manifest ships`);
|
|
39
|
+
const out = execFileSync('npm', ['pack', '--silent'], { cwd: dir }).toString().trim().split('\n').pop();
|
|
40
|
+
const dest = path.join(dir, `${(sin.name ?? pkg.name).replace('/', '-')}-${pkg.version}.sindicatepackage`);
|
|
41
|
+
fs.renameSync(path.join(dir, out), dest);
|
|
42
|
+
console.log(`packed ${dest} (type: ${sin.type})`);
|
|
43
|
+
} else if (cmd === 'inspect') {
|
|
44
|
+
console.log(JSON.stringify(readManifestFromTarball(path.resolve(target)), null, 2));
|
|
45
|
+
} else if (cmd === 'install') {
|
|
46
|
+
const file = path.resolve(target);
|
|
47
|
+
const game = path.resolve(opt('--game', '.'));
|
|
48
|
+
if (!fs.existsSync(path.join(game, 'package.json'))) die(`${game} is not a game (no package.json) — pass --game <dir>`);
|
|
49
|
+
const sin = readManifestFromTarball(file);
|
|
50
|
+
if (sin.engineRange) console.log(`engine range: ${sin.engineRange} (checked at boot by the bridge)`);
|
|
51
|
+
if (sin.type === 'addon') {
|
|
52
|
+
// npm only recognises tarballs by extension (.sindicatepackage reads as a folder path
|
|
53
|
+
// → ENOTDIR), so a .tgz TWIN sits beside the original — and STAYS: npm records the
|
|
54
|
+
// dep as file:<path-to-tgz>, so deleting it would break the game's next npm install
|
|
55
|
+
const tgz = file.replace(/\.sindicatepackage$/, '.tgz');
|
|
56
|
+
fs.copyFileSync(file, tgz);
|
|
57
|
+
execFileSync('npm', ['install', tgz], { cwd: game, stdio: 'inherit' });
|
|
58
|
+
console.log(`installed addon '${sin.name}' into ${path.basename(game)} — import it from game code${sin.bridge ? `; bridge: ${sin.bridge}` : ''}`);
|
|
59
|
+
} else if (sin.type === 'assets' || sin.type === 'clips') {
|
|
60
|
+
const dest = path.join(game, 'public/assets/packs', sin.name);
|
|
61
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
62
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
63
|
+
// extract only the package/assets tree, stripping the two leading path parts
|
|
64
|
+
execFileSync('tar', ['-xzf', file, '-C', dest, '--strip-components', '2', 'package/assets'], { stdio: 'pipe' });
|
|
65
|
+
fs.writeFileSync(path.join(dest, 'pack.json'), JSON.stringify({ ...sin, installedAt: new Date().toISOString(), source: 'sindicatepackage' }, null, 2));
|
|
66
|
+
console.log(`installed ${sin.type} pack '${sin.name}' → ${dest}`);
|
|
67
|
+
} else die(`unknown type '${sin.type}' (addon | assets | clips | pack-profile)`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log('usage: sindicate pack <dir> | inspect <file> | install <file> [--game <dir>]');
|
|
70
|
+
process.exit(cmd ? 1 : 0);
|
|
71
|
+
}
|
|
@@ -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,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)`);
|