sindicate 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +3 -2
- package/src/world/design.js +72 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +270 -0
- package/src/world/roadNetwork.js +118 -0
- package/src/world/roadTerrain.js +263 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FBX → GLB (pack bake) — Synty props arrive as raw .fbx with their unit scale living in
|
|
3
|
+
// Unity's per-file import settings (PolygonCity is metre-authored at globalScale 100,
|
|
4
|
+
// PolygonTown is centimetres at 1). Shipping raw FBX makes every consumer carry both an
|
|
5
|
+
// FBXLoader and the scale table; baking to GLB with the scale applied kills both.
|
|
6
|
+
//
|
|
7
|
+
// node tools/fbxToGlb.mjs <packDir>
|
|
8
|
+
//
|
|
9
|
+
// Reads <packDir>/fbxScale.json (written by truthToAssemblies from the Unity .meta files),
|
|
10
|
+
// converts every listed .fbx, rewrites the assemblies + patches + index to point at the GLBs,
|
|
11
|
+
// and leaves the .fbx sources in place (nothing is deleted — re-runnable).
|
|
12
|
+
//
|
|
13
|
+
// Geometry convention: whatever three's FBXLoader produces, world-baked. That is exactly what
|
|
14
|
+
// the viewers rendered before, so the flipped instance transforms from truthToAssemblies keep
|
|
15
|
+
// matching. No handedness change happens here.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { writeGlb } from './glbWrite.mjs';
|
|
19
|
+
|
|
20
|
+
// three's FBXLoader touches a few DOM APIs (texture loading paths we never hit)
|
|
21
|
+
globalThis.self = globalThis;
|
|
22
|
+
globalThis.window = globalThis;
|
|
23
|
+
const stubEl = () => ({ style: {}, addEventListener() {}, removeEventListener() {}, setAttribute() {}, getContext: () => null });
|
|
24
|
+
globalThis.document = { createElementNS: stubEl, createElement: stubEl };
|
|
25
|
+
|
|
26
|
+
const THREE = await import('three');
|
|
27
|
+
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
28
|
+
|
|
29
|
+
const [packDir] = process.argv.slice(2);
|
|
30
|
+
if (!packDir) { console.error('usage: fbxToGlb.mjs <packDir>'); process.exit(1); }
|
|
31
|
+
|
|
32
|
+
const modelsDir = path.join(packDir, 'models');
|
|
33
|
+
const scalePath = path.join(packDir, 'fbxScale.json');
|
|
34
|
+
const scales = fs.existsSync(scalePath) ? JSON.parse(fs.readFileSync(scalePath, 'utf8')) : {};
|
|
35
|
+
const loader = new FBXLoader();
|
|
36
|
+
|
|
37
|
+
function convert(fbxName, scale) {
|
|
38
|
+
const buf = fs.readFileSync(path.join(modelsDir, fbxName));
|
|
39
|
+
const root = loader.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), '');
|
|
40
|
+
root.scale.setScalar(scale);
|
|
41
|
+
root.updateMatrixWorld(true);
|
|
42
|
+
|
|
43
|
+
// world-bake every mesh into one buffer (parts get a single material from the truth data,
|
|
44
|
+
// so submesh splits buy nothing and cost draw calls)
|
|
45
|
+
const pos = [], nrm = [], uv = [], idx = [];
|
|
46
|
+
const v = new THREE.Vector3();
|
|
47
|
+
const nm = new THREE.Matrix3();
|
|
48
|
+
root.traverse((o) => {
|
|
49
|
+
if (!o.isMesh) return;
|
|
50
|
+
const g = o.geometry;
|
|
51
|
+
const p = g.attributes.position;
|
|
52
|
+
if (!p) return;
|
|
53
|
+
const base = pos.length / 3;
|
|
54
|
+
nm.getNormalMatrix(o.matrixWorld);
|
|
55
|
+
for (let i = 0; i < p.count; i++) {
|
|
56
|
+
v.fromBufferAttribute(p, i).applyMatrix4(o.matrixWorld);
|
|
57
|
+
pos.push(v.x, v.y, v.z);
|
|
58
|
+
if (g.attributes.normal) {
|
|
59
|
+
v.fromBufferAttribute(g.attributes.normal, i).applyMatrix3(nm).normalize();
|
|
60
|
+
nrm.push(v.x, v.y, v.z);
|
|
61
|
+
} else nrm.push(0, 1, 0);
|
|
62
|
+
if (g.attributes.uv) uv.push(g.attributes.uv.getX(i), g.attributes.uv.getY(i));
|
|
63
|
+
else uv.push(0, 0);
|
|
64
|
+
}
|
|
65
|
+
if (g.index) for (let i = 0; i < g.index.count; i++) idx.push(g.index.getX(i) + base);
|
|
66
|
+
else for (let i = 0; i < p.count; i++) idx.push(base + i);
|
|
67
|
+
});
|
|
68
|
+
if (!pos.length) throw new Error('no geometry');
|
|
69
|
+
const glbName = fbxName.replace(/\.fbx$/i, '.glb');
|
|
70
|
+
const r = writeGlb({
|
|
71
|
+
name: path.basename(glbName, '.glb'),
|
|
72
|
+
vertexCount: pos.length / 3,
|
|
73
|
+
indices: idx,
|
|
74
|
+
attrs: {
|
|
75
|
+
POSITION: { array: new Float32Array(pos), dim: 3 },
|
|
76
|
+
NORMAL: { array: new Float32Array(nrm), dim: 3 },
|
|
77
|
+
TEXCOORD_0: { array: new Float32Array(uv), dim: 2 },
|
|
78
|
+
},
|
|
79
|
+
subs: [{ firstIndex: 0, indexCount: idx.length, baseVertex: 0 }],
|
|
80
|
+
}, path.join(modelsDir, glbName));
|
|
81
|
+
return { glbName, ...r };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const renamed = {};
|
|
85
|
+
for (const [fbxName, scale] of Object.entries(scales)) {
|
|
86
|
+
if (!fs.existsSync(path.join(modelsDir, fbxName))) { console.warn(`missing ${fbxName}`); continue; }
|
|
87
|
+
try {
|
|
88
|
+
const r = convert(fbxName, scale);
|
|
89
|
+
renamed[fbxName] = r.glbName;
|
|
90
|
+
console.log(`${fbxName} ×${scale} → ${r.glbName} ${r.verts} verts, ${r.tris} tris`);
|
|
91
|
+
} catch (e) { console.warn(`FAILED ${fbxName}: ${e.message}`); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// repoint assemblies, patches and the model index at the GLBs
|
|
95
|
+
let touched = 0;
|
|
96
|
+
for (const dir of ['assemblies', 'patches']) {
|
|
97
|
+
const d = path.join(packDir, dir);
|
|
98
|
+
if (!fs.existsSync(d)) continue;
|
|
99
|
+
for (const f of fs.readdirSync(d)) {
|
|
100
|
+
if (!f.endsWith('.json') || f === 'index.json') continue;
|
|
101
|
+
const p = path.join(d, f);
|
|
102
|
+
const man = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
103
|
+
let hit = false;
|
|
104
|
+
for (const part of man.parts) if (renamed[part.file]) { part.file = renamed[part.file]; hit = true; }
|
|
105
|
+
if (hit) { fs.writeFileSync(p, JSON.stringify(man)); touched++; }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const idxPath = path.join(packDir, 'index.json');
|
|
109
|
+
if (fs.existsSync(idxPath)) {
|
|
110
|
+
const index = JSON.parse(fs.readFileSync(idxPath, 'utf8'))
|
|
111
|
+
.map((f) => renamed[f] ?? f)
|
|
112
|
+
.filter((f) => !/\.fbx$/i.test(f));
|
|
113
|
+
fs.writeFileSync(idxPath, JSON.stringify([...new Set(index)].sort()));
|
|
114
|
+
}
|
|
115
|
+
console.log(`${Object.keys(renamed).length} converted · ${touched} manifests repointed`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// GLB WRITER — shared by unityMeshToGlb (Unity YAML mesh recovery) and
|
|
2
|
+
// rebuildSimpleRoundabouts (synthesized replacements for Kiwi's deleted meshes).
|
|
3
|
+
// mesh = { name, vertexCount, indices, attrs: { POSITION: {array, dim}, ... }, subs }
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export function writeGlb(mesh, outFile) {
|
|
7
|
+
const buffers = [];
|
|
8
|
+
let byteLen = 0;
|
|
9
|
+
const views = [];
|
|
10
|
+
const push = (buf, target) => {
|
|
11
|
+
while (byteLen % 4) { buffers.push(Buffer.alloc(1)); byteLen++; }
|
|
12
|
+
views.push({ buffer: 0, byteOffset: byteLen, byteLength: buf.length, ...(target ? { target } : {}) });
|
|
13
|
+
buffers.push(buf); byteLen += buf.length;
|
|
14
|
+
return views.length - 1;
|
|
15
|
+
};
|
|
16
|
+
const accessors = [];
|
|
17
|
+
const attrAcc = {};
|
|
18
|
+
for (const [sem, a] of Object.entries(mesh.attrs)) {
|
|
19
|
+
const buf = Buffer.from(a.array.buffer, a.array.byteOffset, a.array.byteLength);
|
|
20
|
+
const view = push(buf, 34962);
|
|
21
|
+
let min, max;
|
|
22
|
+
if (sem === 'POSITION') {
|
|
23
|
+
min = [Infinity, Infinity, Infinity]; max = [-Infinity, -Infinity, -Infinity];
|
|
24
|
+
for (let v = 0; v < mesh.vertexCount; v++) for (let d = 0; d < 3; d++) {
|
|
25
|
+
const x = a.array[v * 3 + d];
|
|
26
|
+
if (x < min[d]) min[d] = x;
|
|
27
|
+
if (x > max[d]) max[d] = x;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
accessors.push({ bufferView: view, componentType: 5126, count: mesh.vertexCount, type: ['SCALAR', 'VEC2', 'VEC3', 'VEC4'][a.dim - 1], ...(min ? { min, max } : {}) });
|
|
31
|
+
attrAcc[sem] = accessors.length - 1;
|
|
32
|
+
}
|
|
33
|
+
const primitives = mesh.subs.map((s) => {
|
|
34
|
+
const idx = new Uint32Array(mesh.indices.slice(s.firstIndex, s.firstIndex + s.indexCount).map((i) => i + s.baseVertex));
|
|
35
|
+
const view = push(Buffer.from(idx.buffer), 34963);
|
|
36
|
+
accessors.push({ bufferView: view, componentType: 5125, count: idx.length, type: 'SCALAR' });
|
|
37
|
+
return { attributes: attrAcc, indices: accessors.length - 1, mode: 4 };
|
|
38
|
+
});
|
|
39
|
+
const gltf = {
|
|
40
|
+
asset: { version: '2.0', generator: 'sindicate unityMeshToGlb' },
|
|
41
|
+
scene: 0,
|
|
42
|
+
scenes: [{ nodes: [0] }],
|
|
43
|
+
nodes: [{ mesh: 0, name: mesh.name }],
|
|
44
|
+
meshes: [{ name: mesh.name, primitives }],
|
|
45
|
+
accessors,
|
|
46
|
+
bufferViews: views,
|
|
47
|
+
buffers: [{ byteLength: byteLen }],
|
|
48
|
+
};
|
|
49
|
+
const bin = Buffer.concat(buffers, byteLen);
|
|
50
|
+
let json = Buffer.from(JSON.stringify(gltf));
|
|
51
|
+
while (json.length % 4) json = Buffer.concat([json, Buffer.from(' ')]);
|
|
52
|
+
const binPad = (4 - (bin.length % 4)) % 4;
|
|
53
|
+
const paddedBin = binPad ? Buffer.concat([bin, Buffer.alloc(binPad)]) : bin;
|
|
54
|
+
const total = 12 + 8 + json.length + 8 + paddedBin.length;
|
|
55
|
+
const out = Buffer.alloc(total);
|
|
56
|
+
out.writeUInt32LE(0x46546c67, 0); out.writeUInt32LE(2, 4); out.writeUInt32LE(total, 8);
|
|
57
|
+
out.writeUInt32LE(json.length, 12); out.writeUInt32LE(0x4e4f534a, 16); json.copy(out, 20);
|
|
58
|
+
let o = 20 + json.length;
|
|
59
|
+
out.writeUInt32LE(paddedBin.length, o); out.writeUInt32LE(0x004e4942, o + 4); paddedBin.copy(out, o + 8);
|
|
60
|
+
fs.writeFileSync(outFile, out);
|
|
61
|
+
return { verts: mesh.vertexCount, tris: mesh.indices.length / 3, prims: primitives.length, bytes: total };
|
|
62
|
+
}
|
|
@@ -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');
|