sindicate 0.13.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 +3 -2
- package/src/world/design.js +72 -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/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 +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -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)`);
|