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.
Files changed (42) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/plans/kiwi-road-recon.md +97 -0
  3. package/docs/plans/road-system.md +142 -0
  4. package/docs/plans/three-cities.md +43 -0
  5. package/package.json +11 -3
  6. package/src/core/assets.js +15 -0
  7. package/src/systems/missions.js +2 -2
  8. package/src/systems/shatter.js +2 -1
  9. package/src/ui/dialogue.js +2 -1
  10. package/src/ui/pauseMenu.js +18 -11
  11. package/src/ui/saveLoadScreen.js +13 -9
  12. package/src/ui/theme.js +34 -0
  13. package/src/world/bridgeSplice.js +176 -0
  14. package/src/world/design.js +72 -0
  15. package/src/world/geo.js +53 -0
  16. package/src/world/heightfield.js +146 -0
  17. package/src/world/kit.js +924 -0
  18. package/src/world/permanentWay.js +161 -0
  19. package/src/world/placement.js +309 -0
  20. package/src/world/railFormation.js +185 -0
  21. package/src/world/roadKit.js +110 -0
  22. package/src/world/roadLink.js +270 -0
  23. package/src/world/roadNetwork.js +118 -0
  24. package/src/world/roadTerrain.js +263 -0
  25. package/src/world/waterLevels.js +95 -0
  26. package/src/world/waterMeshes.js +168 -0
  27. package/src/world/worldBase.js +493 -0
  28. package/tools/ExportKiwiRoadMaterials.cs +145 -0
  29. package/tools/ExportKiwiRoadRefs.cs +94 -0
  30. package/tools/buildKiwiRoadsPack.mjs +36 -0
  31. package/tools/designer-mcp.mjs +103 -0
  32. package/tools/designer.js +316 -0
  33. package/tools/devPlugin.js +324 -0
  34. package/tools/fbxToGlb.mjs +115 -0
  35. package/tools/glbWrite.mjs +62 -0
  36. package/tools/rebuildSimpleRoundabouts.mjs +211 -0
  37. package/tools/registry.json +45 -0
  38. package/tools/roadKitAnalyze.mjs +366 -0
  39. package/tools/sindicate-cli.mjs +71 -0
  40. package/tools/truthToAssemblies.mjs +160 -0
  41. package/tools/unityMeshToGlb.mjs +95 -0
  42. package/tools/unityPrefabToAssembly.mjs +276 -0
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // REBUILD SIMPLE ROUNDABOUTS — Kiwi's SimpleRoundabout_01_R1..R4 prefabs reference five
3
+ // generated meshes (Roundabout + CountryRoad_1..4) whose .assets were deleted from the
4
+ // project (guids dangle in every copy; Unity renders the prefabs lights-only). This is a
5
+ // 1:1 port of the generator that built them — InterCityRoadWindow_Roundabout.cs
6
+ // GenerateRoundabout() / GenerateRoundaboutCountryRoad() with the batch defaults from
7
+ // SaveAllSimpleRoundaboutPrefabs() — emitting pack GLBs plus assembly patches that
8
+ // truthToAssemblies merges on every rebuild.
9
+ //
10
+ // node tools/rebuildSimpleRoundabouts.mjs <packDir>
11
+ //
12
+ // Geometry is generated in Unity space exactly as the C# did, then flipped to the pack's
13
+ // right-handed convention the same way unityMeshToGlb does (negate X, reverse winding).
14
+ // UVs are constant palette texels into T_SimpligonUniverse_02_A_01.png (no tiling) —
15
+ // same convention as every surviving generated road mesh.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { writeGlb } from './glbWrite.mjs';
19
+
20
+ const [packDir] = process.argv.slice(2);
21
+ if (!packDir) { console.error('usage: rebuildSimpleRoundabouts.mjs <packDir>'); process.exit(1); }
22
+
23
+ // batch defaults (SaveAllSimpleRoundaboutPrefabs used the window's serialized defaults)
24
+ const SEGMENTS = 64;
25
+ const INNER_R = 30, LANES = 2, LANE_W = 5, SHOULDER = 1.5;
26
+ const OUTER_R = INNER_R + LANES * LANE_W; // 40
27
+ const TOTAL_INNER = INNER_R - SHOULDER; // 28.5
28
+ const TOTAL_OUTER = OUTER_R + SHOULDER; // 41.5
29
+ const MARK_Y = 0.02;
30
+ const RING_EDGE_W = 0.15;
31
+ const CURB_H = 0.15, CURB_W = 0.4;
32
+ const ISLAND_R = TOTAL_INNER - 0.5; // 28.0
33
+ const ARM_LEN = 50, ARM_SEGS = 20;
34
+ const VERGE_W = 1.5, ARM_HALF_W = LANE_W + VERGE_W; // 6.5 (2 lanes @5 + verges: 5*2/2+1.5)
35
+ const ARM_EDGE_W = 0.1;
36
+ const TRI_LEN = 4, TRI_HALF_W = 1.25;
37
+ const CAP_SEGS = 10, CAP_OVERLAP = 0.15;
38
+ // palette texels in the Simpligon atlas (flat-colour sampling, no tiling)
39
+ const UV_ASPHALT = [0.2871, 0.0361];
40
+ const UV_WHITE = [0.2871, 0.1105];
41
+ const UV_GRASS = [0.2141, 0.0398];
42
+ const TEX = 'T_SimpligonUniverse_02_A_01.png';
43
+
44
+ function builder(name) {
45
+ const pos = [], uv = [], idx = [];
46
+ const b = {
47
+ name,
48
+ get count() { return pos.length / 3; },
49
+ vert(p, t) { pos.push(p[0], p[1], p[2]); uv.push(t[0], t[1]); return pos.length / 3 - 1; },
50
+ tri(a, c, d) { idx.push(a, c, d); },
51
+ // strip quads over interleaved [left/inner, right/outer] pairs — the generator's pattern
52
+ stripTris(base, rows) {
53
+ for (let i = 0; i < rows - 1; i++) {
54
+ const k = base + i * 2;
55
+ b.tri(k, k + 2, k + 1);
56
+ b.tri(k + 1, k + 2, k + 3);
57
+ }
58
+ },
59
+ mesh() {
60
+ // Unity → pack handedness: negate X, reverse winding (same as unityMeshToGlb)
61
+ const P = new Float32Array(pos);
62
+ for (let i = 0; i < P.length; i += 3) P[i] *= -1;
63
+ for (let i = 0; i + 2 < idx.length; i += 3) { const t = idx[i]; idx[i] = idx[i + 2]; idx[i + 2] = t; }
64
+ const N = new Float32Array(P.length);
65
+ for (let i = 1; i < N.length; i += 3) N[i] = 1; // everything is horizontal: (0,1,0)
66
+ return {
67
+ name, vertexCount: P.length / 3, indices: idx,
68
+ attrs: { POSITION: { array: P, dim: 3 }, NORMAL: { array: N, dim: 3 }, TEXCOORD_0: { array: new Float32Array(uv), dim: 2 } },
69
+ subs: [{ firstIndex: 0, indexCount: idx.length, baseVertex: 0 }],
70
+ };
71
+ },
72
+ };
73
+ return b;
74
+ }
75
+
76
+ // AddRingSegment: 65 rows inclusive (duplicated seam), pairs [inner, outer], x=cos z=sin
77
+ function addRing(b, rI, rO, y, t) {
78
+ const base = b.count;
79
+ for (let i = 0; i <= SEGMENTS; i++) {
80
+ const a = i * (Math.PI * 2 / SEGMENTS);
81
+ b.vert([Math.cos(a) * rI, y, Math.sin(a) * rI], t);
82
+ b.vert([Math.cos(a) * rO, y, Math.sin(a) * rO], t);
83
+ }
84
+ b.stripTris(base, SEGMENTS + 1);
85
+ }
86
+
87
+ function buildRing() {
88
+ const b = builder('Roundabout');
89
+ addRing(b, TOTAL_INNER, TOTAL_OUTER, 0, UV_ASPHALT); // deck annulus
90
+ addRing(b, INNER_R - RING_EDGE_W, INNER_R, MARK_Y, UV_WHITE); // inner edge line
91
+ addRing(b, OUTER_R, OUTER_R + RING_EDGE_W, MARK_Y, UV_WHITE); // outer edge line
92
+ // lane-divider dash arcs at r=35 (lanes=2 → one divider)
93
+ const laneR = INNER_R + 1 * LANE_W, dashW = 0.12;
94
+ const numDashes = Math.floor((Math.PI * 2 * laneR) / 5); // 3m dash / 2m gap
95
+ const cycle = (Math.PI * 2) / numDashes, dashAngle = cycle * (3 / 5);
96
+ for (let d = 0; d < numDashes; d++) {
97
+ const base = b.count;
98
+ for (let s = 0; s <= 4; s++) {
99
+ const a = d * cycle + dashAngle * (s / 4);
100
+ b.vert([Math.cos(a) * (laneR - dashW / 2), MARK_Y, Math.sin(a) * (laneR - dashW / 2)], UV_WHITE);
101
+ b.vert([Math.cos(a) * (laneR + dashW / 2), MARK_Y, Math.sin(a) * (laneR + dashW / 2)], UV_WHITE);
102
+ }
103
+ b.stripTris(base, 5);
104
+ }
105
+ addRing(b, ISLAND_R - CURB_W, ISLAND_R, CURB_H, UV_GRASS); // island kerb ring
106
+ // island fill disk (fan)
107
+ const c = b.vert([0, CURB_H * 0.5, 0], UV_GRASS);
108
+ for (let i = 0; i <= SEGMENTS; i++) {
109
+ const a = i * (Math.PI * 2 / SEGMENTS);
110
+ b.vert([Math.cos(a) * (ISLAND_R - CURB_W), CURB_H * 0.5, Math.sin(a) * (ISLAND_R - CURB_W)], UV_GRASS);
111
+ }
112
+ for (let i = 0; i < SEGMENTS; i++) b.tri(c, c + 1 + i + 1, c + 1 + i);
113
+ return b.mesh();
114
+ }
115
+
116
+ function buildArm(k) {
117
+ const b = builder(`CountryRoad_${k}`);
118
+ const aDeg = (k - 1) * 90, aRad = (aDeg * Math.PI) / 180;
119
+ const out = [Math.sin(aRad), 0, Math.cos(aRad)]; // 0° = +Z, 90° = +X
120
+ const end = out.map((v) => v * TOTAL_OUTER); // junction point
121
+ const start = out.map((v) => v * (TOTAL_OUTER + ARM_LEN));
122
+ const tangent = [-out[0], 0, -out[2]]; // toward the ring
123
+ const right = [tangent[2], 0, -tangent[0]]; // Cross(up, tangent)
124
+ const P = (i) => start.map((v, d) => v + (end[d] - v) * (i / ARM_SEGS));
125
+ const flare = (i) => { const t = i / ARM_SEGS; return t > 1 - TRI_LEN / ARM_LEN ? TRI_HALF_W * ((t - (1 - TRI_LEN / ARM_LEN)) / (TRI_LEN / ARM_LEN)) : 0; };
126
+ const at = (i, off, y) => { const p = P(i); return [p[0] + right[0] * off, y, p[2] + right[2] * off]; };
127
+
128
+ let base = b.count; // asphalt strip
129
+ for (let i = 0; i <= ARM_SEGS; i++) {
130
+ const f = flare(i);
131
+ b.vert(at(i, -ARM_HALF_W - f, 0), UV_ASPHALT);
132
+ b.vert(at(i, ARM_HALF_W + f, 0), UV_ASPHALT);
133
+ }
134
+ b.stripTris(base, ARM_SEGS + 1);
135
+ base = b.count; // left solid edge line
136
+ for (let i = 0; i <= ARM_SEGS; i++) {
137
+ const f = flare(i);
138
+ b.vert(at(i, -ARM_HALF_W - f, MARK_Y), UV_WHITE);
139
+ b.vert(at(i, -ARM_HALF_W - f + ARM_EDGE_W, MARK_Y), UV_WHITE);
140
+ }
141
+ b.stripTris(base, ARM_SEGS + 1);
142
+ base = b.count; // right solid edge line
143
+ for (let i = 0; i <= ARM_SEGS; i++) {
144
+ const f = flare(i);
145
+ b.vert(at(i, ARM_HALF_W + f - ARM_EDGE_W, MARK_Y), UV_WHITE);
146
+ b.vert(at(i, ARM_HALF_W + f, MARK_Y), UV_WHITE);
147
+ }
148
+ b.stripTris(base, ARM_SEGS + 1);
149
+ base = b.count; // centre dashed line
150
+ const flareStartIndex = Math.floor((1 - TRI_LEN / ARM_LEN) * ARM_SEGS); // 18
151
+ for (let i = 0; i <= flareStartIndex; i++) {
152
+ b.vert(at(i, -ARM_EDGE_W / 2, MARK_Y), UV_WHITE);
153
+ b.vert(at(i, ARM_EDGE_W / 2, MARK_Y), UV_WHITE);
154
+ }
155
+ for (let i = 0; i < flareStartIndex; i += 2) { // even segments only → dashes
156
+ const k2 = base + i * 2;
157
+ b.tri(k2, k2 + 2, k2 + 1);
158
+ b.tri(k2 + 1, k2 + 2, k2 + 3);
159
+ }
160
+ base = b.count; // triangle ghost island
161
+ for (let i = flareStartIndex; i <= ARM_SEGS; i++) {
162
+ const halfW = TRI_HALF_W * ((i - flareStartIndex) / (ARM_SEGS - flareStartIndex));
163
+ b.vert(at(i, -halfW, MARK_Y), UV_WHITE);
164
+ b.vert(at(i, halfW, MARK_Y), UV_WHITE);
165
+ }
166
+ b.stripTris(base, ARM_SEGS - flareStartIndex + 1);
167
+ // curved end cap wrapping onto the ring
168
+ const totalEdge = ARM_HALF_W + TRI_HALF_W; // 7.75
169
+ const angOff = totalEdge / TOTAL_OUTER;
170
+ const fFinal = flare(ARM_SEGS);
171
+ const capL = at(ARM_SEGS, -ARM_HALF_W - fFinal, 0), capR = at(ARM_SEGS, ARM_HALF_W + fFinal, 0);
172
+ const row1 = b.count;
173
+ for (let j = 0; j <= CAP_SEGS; j++) b.vert(capL.map((v, d) => v + (capR[d] - v) * (j / CAP_SEGS)), UV_ASPHALT);
174
+ const row2 = b.count;
175
+ for (let j = 0; j <= CAP_SEGS; j++) {
176
+ const a = aRad + angOff + (aRad - angOff - (aRad + angOff)) * (j / CAP_SEGS);
177
+ b.vert([Math.sin(a) * (TOTAL_OUTER - CAP_OVERLAP), 0, Math.cos(a) * (TOTAL_OUTER - CAP_OVERLAP)], UV_ASPHALT);
178
+ }
179
+ for (let j = 0; j < CAP_SEGS; j++) {
180
+ b.tri(row1 + j, row2 + j, row1 + j + 1);
181
+ b.tri(row2 + j, row2 + j + 1, row1 + j + 1);
182
+ }
183
+ return b.mesh();
184
+ }
185
+
186
+ // ---- emit GLBs + patches ----
187
+ const modelsDir = path.join(packDir, 'models');
188
+ const patchDir = path.join(packDir, 'patches');
189
+ fs.mkdirSync(patchDir, { recursive: true });
190
+ const files = { ring: 'SimpleRoundabout_Ring.glb' };
191
+ const r = writeGlb(buildRing(), path.join(modelsDir, files.ring));
192
+ console.log(`${files.ring}: ${r.verts} verts, ${r.tris} tris`);
193
+ for (let k = 1; k <= 4; k++) {
194
+ files[k] = `SimpleRoundabout_Arm${k}.glb`;
195
+ const a = writeGlb(buildArm(k), path.join(modelsDir, files[k]));
196
+ console.log(`${files[k]}: ${a.verts} verts, ${a.tris} tris`);
197
+ }
198
+ const part = (file) => ({ file, pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1], tex: TEX });
199
+ for (let n = 1; n <= 4; n++) {
200
+ const parts = [part(files.ring)];
201
+ for (let k = 1; k <= n; k++) parts.push(part(files[k]));
202
+ fs.writeFileSync(path.join(patchDir, `SimpleRoundabout_01_R${n}.json`), JSON.stringify({ parts }, null, 1));
203
+ }
204
+ // list the new GLBs in the pack's model index so the Parts view can browse them
205
+ const idxPath = path.join(packDir, 'index.json');
206
+ if (fs.existsSync(idxPath)) {
207
+ const index = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
208
+ const add = Object.values(files).filter((f) => !index.includes(f));
209
+ if (add.length) fs.writeFileSync(idxPath, JSON.stringify([...index, ...add].sort()));
210
+ }
211
+ console.log('4 assembly patches written — run truthToAssemblies to merge');
@@ -0,0 +1,45 @@
1
+ {
2
+ "//": "The Sindicate package catalog — what exists in the ecosystem and where it lives on this machine (repo paths relative to the Sites root, i.e. the games' parent dir). The dev GUI reads this via /__pack/registry and installs straight from it.",
3
+ "packages": [
4
+ {
5
+ "name": "rayhit-three",
6
+ "type": "addon",
7
+ "description": "Runtime mesh fracturing & physics destruction — Voronoi shattering, demolition, bombs, structural collapse.",
8
+ "repo": "rayhit-three"
9
+ },
10
+ {
11
+ "name": "weather-threejs",
12
+ "type": "addon",
13
+ "description": "Stylized weather, atmosphere & time-of-day — sky, sun/moon/stars, clouds, fog, rain & snow, forecast system.",
14
+ "repo": "weather-threejs"
15
+ },
16
+ {
17
+ "name": "accentimations",
18
+ "type": "clips",
19
+ "description": "The animation pack: 259 cooked clips + manifest. Dev flow consumes it via file: + registerClipPack; a packaged release is pending.",
20
+ "repo": "Accentimations",
21
+ "note": "consumed via file: dependency in dev — not yet packaged"
22
+ },
23
+ {
24
+ "name": "kitforge",
25
+ "type": "addon",
26
+ "description": "Kit-piece cataloguer + render-to-PNG thumbnails (feeds the importer's future thumbnail grid).",
27
+ "repo": "kitforge",
28
+ "note": "not yet manifested"
29
+ },
30
+ {
31
+ "name": "fft-water",
32
+ "type": "addon",
33
+ "description": "FFT ocean/water surface (from transport-tycoon-remake) — extraction pending.",
34
+ "repo": "transport-tycoon-remake",
35
+ "note": "planned — extraction not started"
36
+ },
37
+ {
38
+ "name": "glodify",
39
+ "type": "service",
40
+ "description": "LOD baker (CLI/REST service) — games consume its output files; never installed as a package.",
41
+ "repo": "glodify",
42
+ "note": "service, not installable"
43
+ }
44
+ ]
45
+ }
@@ -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);