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,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);
|