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.
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ // ROAD KIT ANALYZE — turn a pack of road assemblies into a KIT: what each piece IS and
3
+ // where its arms connect. Without this, junctions are just meshes; with it the solver can
4
+ // place a junction and run a road out of every arm to the next one.
5
+ //
6
+ // node tools/roadKitAnalyze.mjs <packDir>
7
+ //
8
+ // Writes <packDir>/roadkit.json:
9
+ // { pieces: { <assembly>: { family, class, arms: n, bbox, surfaceY,
10
+ // sockets: [{ id, pos:[x,y,z], dir:[x,z], width }] } } }
11
+ //
12
+ // Sockets come from geometry, not names: an arm's asphalt mesh is measured, its outward
13
+ // face located, and the road's width taken across that face. Names only supply labels.
14
+ // (When Kiwi's exporter is re-run it also emits connector markers — those will override
15
+ // these measurements where present.)
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ const [packDir] = process.argv.slice(2);
20
+ if (!packDir) { console.error('usage: roadKitAnalyze.mjs <packDir>'); process.exit(1); }
21
+ const modelsDir = path.join(packDir, 'models');
22
+
23
+ // ── minimal GLB reader: positions of every primitive, merged ──
24
+ const posCache = new Map();
25
+ function positionsOf(file) {
26
+ if (posCache.has(file)) return posCache.get(file);
27
+ let out = null;
28
+ try {
29
+ const buf = fs.readFileSync(path.join(modelsDir, file));
30
+ const jsonLen = buf.readUInt32LE(12);
31
+ const gltf = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
32
+ const binStart = 20 + jsonLen + 8;
33
+ const chunks = [];
34
+ for (const mesh of gltf.meshes ?? []) {
35
+ for (const prim of mesh.primitives ?? []) {
36
+ const acc = gltf.accessors[prim.attributes.POSITION];
37
+ const view = gltf.bufferViews[acc.bufferView];
38
+ const start = binStart + (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
39
+ chunks.push(new Float32Array(buf.buffer, buf.byteOffset + start, acc.count * 3));
40
+ }
41
+ }
42
+ if (chunks.length) {
43
+ const total = chunks.reduce((n, c) => n + c.length, 0);
44
+ out = new Float32Array(total);
45
+ let o = 0;
46
+ for (const c of chunks) { out.set(c, o); o += c.length; }
47
+ }
48
+ } catch { /* builtin: or unreadable — handled by caller */ }
49
+ posCache.set(file, out);
50
+ return out;
51
+ }
52
+ // Unity built-ins the assemblies reference by name
53
+ const BUILTIN = {
54
+ Plane: [[-5, 0, -5], [5, 0, 5]], Quad: [[-0.5, -0.5, 0], [0.5, 0.5, 0]],
55
+ Cube: [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], Sphere: [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
56
+ Cylinder: [[-0.5, -1, -0.5], [0.5, 1, 0.5]], Capsule: [[-0.5, -1, -0.5], [0.5, 1, 0.5]],
57
+ };
58
+ function cornersOfBuiltin(name) {
59
+ const b = BUILTIN[name] ?? BUILTIN.Cube;
60
+ const pts = [];
61
+ for (const x of [b[0][0], b[1][0]]) for (const y of [b[0][1], b[1][1]]) for (const z of [b[0][2], b[1][2]]) pts.push([x, y, z]);
62
+ return pts;
63
+ }
64
+
65
+ const applyTRS = (v, part) => {
66
+ const [x, y, z] = [v[0] * part.scale[0], v[1] * part.scale[1], v[2] * part.scale[2]];
67
+ const [qx, qy, qz, qw] = part.quat;
68
+ // q * v * q⁻¹
69
+ const ix = qw * x + qy * z - qz * y, iy = qw * y + qz * x - qx * z;
70
+ const iz = qw * z + qx * y - qy * x, iw = -qx * x - qy * y - qz * z;
71
+ return [
72
+ ix * qw + iw * -qx + iy * -qz - iz * -qy + part.pos[0],
73
+ iy * qw + iw * -qy + iz * -qx - ix * -qz + part.pos[1],
74
+ iz * qw + iw * -qz + ix * -qy - iy * -qx + part.pos[2],
75
+ ];
76
+ };
77
+
78
+ function worldPoints(part) {
79
+ if (part.file.startsWith('builtin:')) return cornersOfBuiltin(part.file.slice(8)).map((v) => applyTRS(v, part));
80
+ const p = positionsOf(part.file);
81
+ if (!p) return [];
82
+ const out = [];
83
+ for (let i = 0; i < p.length; i += 3) out.push(applyTRS([p[i], p[i + 1], p[i + 2]], part));
84
+ return out;
85
+ }
86
+
87
+ const bboxOf = (pts) => {
88
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
89
+ for (const p of pts) for (let d = 0; d < 3; d++) { if (p[d] < min[d]) min[d] = p[d]; if (p[d] > max[d]) max[d] = p[d]; }
90
+ return { min, max };
91
+ };
92
+
93
+ // An arm is a long thin strip: its own principal axis IS the direction it points, which is
94
+ // exact even for diagonal (Y-junction) arms where "offset from the assembly centre" is not —
95
+ // an asymmetric junction's centre is nowhere near its arms' axis.
96
+ function principalAxis(pts) {
97
+ let mx = 0, mz = 0;
98
+ for (const p of pts) { mx += p[0]; mz += p[2]; }
99
+ mx /= pts.length; mz /= pts.length;
100
+ let sxx = 0, sxz = 0, szz = 0;
101
+ for (const p of pts) { const x = p[0] - mx, z = p[2] - mz; sxx += x * x; sxz += x * z; szz += z * z; }
102
+ const theta = 0.5 * Math.atan2(2 * sxz, sxx - szz);
103
+ return { axis: [Math.cos(theta), Math.sin(theta)], mean: [mx, mz] };
104
+ }
105
+
106
+ // an arm's socket: push out along the arm's own direction, measure the outer slab
107
+ function socketFrom(pts, dir, id) {
108
+ let maxProj = -Infinity;
109
+ for (const p of pts) { const d = p[0] * dir[0] + p[2] * dir[1]; if (d > maxProj) maxProj = d; }
110
+ const perp = [-dir[1], dir[0]];
111
+ // widen the slab until it holds a real face (a hair of axis error can otherwise clip it
112
+ // down to a single corner vertex, which reads as width 0)
113
+ let lo = 0, hi = 0, ySum = 0, n = 0;
114
+ for (const tol of [0.35, 1, 2.5]) {
115
+ lo = Infinity; hi = -Infinity; ySum = 0; n = 0;
116
+ for (const p of pts) {
117
+ if (p[0] * dir[0] + p[2] * dir[1] < maxProj - tol) continue;
118
+ const l = p[0] * perp[0] + p[2] * perp[1];
119
+ if (l < lo) lo = l;
120
+ if (l > hi) hi = l;
121
+ ySum += p[1]; n++;
122
+ }
123
+ if (n && hi - lo > 0.5) break;
124
+ }
125
+ if (!n) return null;
126
+ const mid = (lo + hi) / 2;
127
+ return {
128
+ id,
129
+ pos: [dir[0] * maxProj + perp[0] * mid, +(ySum / n).toFixed(3), dir[1] * maxProj + perp[1] * mid].map((v) => +v.toFixed(3)),
130
+ dir: dir.map((v) => +v.toFixed(4)),
131
+ width: +(hi - lo).toFixed(2),
132
+ };
133
+ }
134
+
135
+ // THE CROSS-SECTION AT A SOCKET. A country road is one deck with a dashed middle; a motorway
136
+ // is two carriageways either side of a median, each with its own lane dashes. Rather than
137
+ // hard-code either, measure the paint at the opening: sorted lateral positions fall into
138
+ // bands, and a band is a line. A generated road can then be built to match exactly.
139
+ function bandsOf(pts, socket, gap = 0.5) {
140
+ const [dx, dz] = socket.dir;
141
+ const right = [-dz, dx];
142
+ const lat = pts.map((p) => (p[0] - socket.pos[0]) * right[0] + (p[2] - socket.pos[2]) * right[1]).sort((a, b) => a - b);
143
+ if (!lat.length) return [];
144
+ const bands = [];
145
+ let lo = lat[0], prev = lat[0];
146
+ for (let i = 1; i < lat.length; i++) {
147
+ if (lat[i] - prev > gap) { bands.push({ at: +((lo + prev) / 2).toFixed(3), width: +(prev - lo).toFixed(3) }); lo = lat[i]; }
148
+ prev = lat[i];
149
+ }
150
+ bands.push({ at: +((lo + prev) / 2).toFixed(3), width: +(prev - lo).toFixed(3) });
151
+ return bands;
152
+ }
153
+
154
+ // Every arm is a TWO-WAY road: traffic leaves on one side of the centre line and arrives on
155
+ // the other. WHICH side is a runtime choice, not a property of the geometry — a game (or the
156
+ // designer, mid-edit) must be able to flip between drive-on-left and drive-on-right without
157
+ // re-analysing anything. So lanes are stored by their SIDE of the centre line ('L'/'R',
158
+ // looking out along the socket) and `laneFlow(side, driveOnLeft)` in world/roadKit.js turns
159
+ // that into in/out at read time.
160
+ const DRIVE_ON_LEFT_DEFAULT = !process.argv.includes('--drive-on-right');
161
+ const KIT_LANE_W = 5; // Kiwi's generator: laneWidth 5, verge 1.5 → 13 m arms
162
+
163
+ // A motorway's lanes come straight out of its measured paint: the two innermost solid lines
164
+ // fence the median, the outermost fence the shoulders, and the dashes between them divide
165
+ // each carriageway. Traffic on the near side of the median runs one way, the far side the other.
166
+ function motorwayLanes(socket) {
167
+ const { lines = [], dashes = [] } = socket.section ?? {};
168
+ const [dx, dz] = socket.dir;
169
+ const right = [-dz, dx];
170
+ const at = (off) => [
171
+ +(socket.pos[0] + right[0] * off).toFixed(3), socket.pos[1],
172
+ +(socket.pos[2] + right[1] * off).toFixed(3),
173
+ ];
174
+ const out = [];
175
+ for (const side of [-1, 1]) {
176
+ // the carriageway on this side runs from the median line to the outer edge line
177
+ const own = lines.filter((l) => Math.sign(l.at) === side).sort((a, b) => Math.abs(a.at) - Math.abs(b.at));
178
+ if (own.length < 2) continue;
179
+ const inner = own[0].at, outer = own[own.length - 1].at;
180
+ const divs = dashes.filter((d) => Math.sign(d.at) === side).map((d) => d.at).sort((a, b) => Math.abs(a) - Math.abs(b));
181
+ const edges = [inner, ...divs, outer];
182
+ // drive-on-left: the carriageway LEFT of the centre (negative `right`) carries traffic out
183
+ for (let i = 0; i < edges.length - 1; i++) {
184
+ out.push({
185
+ side: side < 0 ? 'L' : 'R',
186
+ centre: at((edges[i] + edges[i + 1]) / 2),
187
+ width: +Math.abs(edges[i + 1] - edges[i]).toFixed(2),
188
+ });
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ function lanesOf(socket, centreMarks, edgeMarks) {
195
+ const [dx, dz] = socket.dir;
196
+ const right = [-dz, dx]; // right-hand side of a driver heading OUT along dir
197
+ const lat = (p) => (p[0] - socket.pos[0]) * right[0] + (p[2] - socket.pos[2]) * right[1];
198
+ const mean = (pts) => pts.reduce((s, p) => s + lat(p), 0) / pts.length;
199
+ // centre line from the arm's own dash mesh; else the middle of the opening
200
+ const c = centreMarks?.length ? mean(centreMarks) : 0;
201
+ // carriageway half-width: to the far edge line if we have one, else half the opening
202
+ let half = socket.width / 2;
203
+ let lineW = 0.15;
204
+ if (edgeMarks?.length) {
205
+ let lo = Infinity, hi = -Infinity;
206
+ // the two edge lines sit either side of the centre: measure each band separately so we
207
+ // learn both where the paint is and how wide a line is
208
+ let rLo = Infinity, rHi = -Infinity, lLo = Infinity, lHi = -Infinity;
209
+ for (const p of edgeMarks) {
210
+ const l = lat(p);
211
+ if (l < lo) lo = l;
212
+ if (l > hi) hi = l;
213
+ if (l > c) { if (l < rLo) rLo = l; if (l > rHi) rHi = l; }
214
+ else { if (l < lLo) lLo = l; if (l > lHi) lHi = l; }
215
+ }
216
+ half = Math.max(hi - c, c - lo);
217
+ const bands = [rHi - rLo, lHi - lLo].filter((w) => Number.isFinite(w) && w > 0.01 && w < 1);
218
+ if (bands.length) lineW = Math.min(...bands);
219
+ }
220
+ const laneW = Math.min(KIT_LANE_W, Math.max(half, 1));
221
+ const at = (off) => [
222
+ +(socket.pos[0] + right[0] * off).toFixed(3), socket.pos[1],
223
+ +(socket.pos[2] + right[1] * off).toFixed(3),
224
+ ];
225
+ // 'L' is left of the centre line looking OUT along the socket; which way it flows is the
226
+ // game's runtime call (see laneFlow)
227
+ return {
228
+ lanes: [
229
+ { side: 'L', centre: at(c - laneW / 2), width: +laneW.toFixed(2) },
230
+ { side: 'R', centre: at(c + laneW / 2), width: +laneW.toFixed(2) },
231
+ ],
232
+ // where the paint actually sits, so a generated road's lines continue the arm's
233
+ // instead of kinking at the seam (the verge outside the edge line is often 1.5 m)
234
+ paint: { centre: +c.toFixed(3), half: +half.toFixed(3), lineWidth: +lineW.toFixed(3), measured: !!edgeMarks?.length },
235
+ };
236
+ }
237
+
238
+ const asmDir = path.join(packDir, 'assemblies');
239
+ const index = JSON.parse(fs.readFileSync(path.join(asmDir, 'index.json'), 'utf8'));
240
+ const familyOf = (n) => n
241
+ .replace(/_(?:[NSEW]{1,2}-(?:CR|SR)(?:-(?:GW|P))?)(?:_[NSEW]{1,2}-(?:CR|SR)(?:-(?:GW|P))?)*$/, '')
242
+ .replace(/_L\d+-R\d+$/, '').replace(/_R\d+$/, '').replace(/_\d+deg$/, '');
243
+ const classify = (fam) => /Roundabout/i.test(fam) ? 'roundabout'
244
+ : /Interchange|Overpass|Dumbbell/i.test(fam) ? 'interchange'
245
+ : /RoadSegment/i.test(fam) ? 'segment'
246
+ : /Junction|Cross|_T_|_Y_|Corner|4Way|TJunction|YJunction/i.test(fam) ? 'junction'
247
+ : /Transition/i.test(fam) ? 'transition' : 'other';
248
+
249
+ const pieces = {};
250
+ let withSockets = 0;
251
+ for (const name of index) {
252
+ const man = JSON.parse(fs.readFileSync(path.join(asmDir, `${name}.json`), 'utf8'));
253
+ const all = [];
254
+ const roadPts = []; // asphalt/markings only — grass and sidewalk would widen arms
255
+ const armPts = new Map(); // arm label → points of that arm's asphalt
256
+ const markPts = new Map(); // "<arm>:centre" / "<arm>:edge" → points of that marking
257
+ const throughPts = [], throughDash = []; // motorway carriageway lines + its lane dashes
258
+ for (const part of man.parts) {
259
+ const pts = worldPoints(part);
260
+ if (!pts.length) continue;
261
+ all.push(...pts);
262
+ if (/road|asphalt|lines|markings/i.test(part.file) || /road/i.test(part.tex ?? '')) roadPts.push(...pts);
263
+ // generated junction arms carry their label in the mesh name; the reconstructed
264
+ // roundabout arms are numbered (SimpleRoundabout_Arm3.glb)
265
+ const m = /Arm_(North|South|East|West|NorthEast|NorthWest|SouthEast|SouthWest)_(?:Asphalt|Road)/i.exec(part.file)
266
+ ?? /^SimpleRoundabout_Arm(\d)\.glb$/i.exec(part.file);
267
+ if (m) {
268
+ const k = m[1];
269
+ if (!armPts.has(k)) armPts.set(k, []);
270
+ armPts.get(k).push(...pts);
271
+ }
272
+ // interchange pieces: a motorway runs THROUGH them (DualCarriageway, with its lane
273
+ // dashes) and local roads spur OFF them (CountryRoad, same arms the roundabouts use)
274
+ if (/^DualCarriageway(_\d+)?\.glb$/i.test(part.file)) throughPts.push(...pts);
275
+ else if (/^CarriagewayLaneDashes(_\d+)?\.glb$/i.test(part.file)) throughDash.push(...pts);
276
+ else if (/^CountryRoad(_\d+)?\.glb$/i.test(part.file)) {
277
+ const k = `spur${armPts.size}:${part.file}`;
278
+ if (!armPts.has(k)) armPts.set(k, []);
279
+ armPts.get(k).push(...pts);
280
+ }
281
+ // markings tell us where the centre line and the carriageway edges are, which is how
282
+ // an arm splits into its two directions of travel
283
+ const mk = /Arm_(North|South|East|West|NorthEast|NorthWest|SouthEast|SouthWest)_(CenterDash|EdgeLines)/i.exec(part.file);
284
+ if (mk) {
285
+ const key = `${mk[1]}:${/center/i.test(mk[2]) ? 'centre' : 'edge'}`;
286
+ if (!markPts.has(key)) markPts.set(key, []);
287
+ markPts.get(key).push(...pts);
288
+ }
289
+ }
290
+ if (!all.length) continue;
291
+ const bb = bboxOf(all);
292
+ const centre = [(bb.min[0] + bb.max[0]) / 2, (bb.min[2] + bb.max[2]) / 2];
293
+ const sockets = [];
294
+ for (const [label, pts] of armPts) {
295
+ // direction from the arm's own axis, pointed AWAY from the junction centre
296
+ // (the pack is X-mirrored vs Unity, so the label is never trusted for direction)
297
+ const { axis, mean } = principalAxis(pts);
298
+ const away = [mean[0] - centre[0], mean[1] - centre[1]];
299
+ const dir = (axis[0] * away[0] + axis[1] * away[1]) < 0 ? [-axis[0], -axis[1]] : axis;
300
+ const s = socketFrom(pts, dir, label);
301
+ if (!s) continue;
302
+ Object.assign(s, lanesOf(s, markPts.get(`${label}:centre`), markPts.get(`${label}:edge`)));
303
+ sockets.push(s);
304
+ }
305
+ // MOTORWAY: the carriageway runs clean through the interchange, so it offers a socket at
306
+ // each end. Its cross-section is measured, not assumed — two carriageways, a median, and
307
+ // however many lanes this variant was generated with.
308
+ if (throughPts.length) {
309
+ const { axis } = principalAxis(throughPts);
310
+ for (const sgn of [1, -1]) {
311
+ const dir = [axis[0] * sgn, axis[1] * sgn];
312
+ const s = socketFrom(throughPts, dir, sgn > 0 ? 'M-A' : 'M-B');
313
+ if (!s) continue;
314
+ const deck = socketFrom(all, dir, s.id);
315
+ s.kind = 'motorway';
316
+ s.width = deck && deck.width > s.width ? deck.width : s.width;
317
+ s.section = { lines: bandsOf(throughPts, s), dashes: bandsOf(throughDash, s) };
318
+ s.lanes = motorwayLanes(s);
319
+ sockets.push(s);
320
+ }
321
+ }
322
+
323
+ const fam = familyOf(name);
324
+ // a straight segment's sockets are simply its two ends along its long axis
325
+ if (!sockets.length && classify(fam) === 'segment') {
326
+ const { axis } = principalAxis(all);
327
+ for (const sgn of [1, -1]) {
328
+ const s = socketFrom(all, [axis[0] * sgn, axis[1] * sgn], sgn > 0 ? 'A' : 'B');
329
+ if (s) { Object.assign(s, lanesOf(s)); sockets.push(s); }
330
+ }
331
+ }
332
+ // hand-built tile junctions (the City/Countryside FBX ones) have no named arm meshes —
333
+ // probe the four compass faces of the ROAD surface instead
334
+ if (!sockets.length && classify(fam) === 'junction' && roadPts.length) {
335
+ const probes = [['N', [0, 1]], ['E', [1, 0]], ['S', [0, -1]], ['W', [-1, 0]]];
336
+ for (const [id, dir] of probes) {
337
+ const s = socketFrom(roadPts, dir, id);
338
+ if (!s || s.width < 3) continue;
339
+ // an arm exists only where the ASPHALT runs out to the tile's own edge — a T's
340
+ // missing arm stops at the far kerb of the through road
341
+ const tileEdge = dir[0] ? (dir[0] > 0 ? bb.max[0] : -bb.min[0]) : (dir[1] > 0 ? bb.max[2] : -bb.min[2]);
342
+ const face = s.pos[0] * dir[0] + s.pos[2] * dir[1];
343
+ if (tileEdge - face < 0.5) { Object.assign(s, lanesOf(s)); sockets.push(s); }
344
+ }
345
+ }
346
+ pieces[name] = {
347
+ family: fam,
348
+ class: classify(fam),
349
+ size: [+(bb.max[0] - bb.min[0]).toFixed(2), +(bb.max[1] - bb.min[1]).toFixed(2), +(bb.max[2] - bb.min[2]).toFixed(2)],
350
+ bbox: { min: bb.min.map((v) => +v.toFixed(3)), max: bb.max.map((v) => +v.toFixed(3)) },
351
+ surfaceY: +bb.max[1].toFixed(3),
352
+ sockets,
353
+ };
354
+ if (sockets.length) withSockets++;
355
+ }
356
+ fs.writeFileSync(path.join(packDir, 'roadkit.json'), JSON.stringify({
357
+ generated: 'roadKitAnalyze',
358
+ driveOnLeft: DRIVE_ON_LEFT_DEFAULT, // the kit's DEFAULT side of the road; switchable at runtime
359
+ laneWidth: KIT_LANE_W,
360
+ pieces,
361
+ }, null, 0));
362
+
363
+ const byClass = {};
364
+ for (const p of Object.values(pieces)) byClass[p.class] = (byClass[p.class] ?? 0) + 1;
365
+ console.log(`${Object.keys(pieces).length} pieces analysed · ${withSockets} with measured sockets`);
366
+ console.log('by class:', byClass);
@@ -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`);