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,161 @@
|
|
|
1
|
+
// THE PERMANENT WAY's MACHINERY (world splits, step 8) — the generic half of laying
|
|
2
|
+
// track as geometry, moved verbatim from western's railway.js. The GAME keeps the
|
|
3
|
+
// railway itself: which castings, which gauge mates to which, where the line runs,
|
|
4
|
+
// portal art direction, station areas. The engine keeps the laws:
|
|
5
|
+
//
|
|
6
|
+
// · THE PERF LAW: a hundred track pieces as a hundred meshes is not acceptable.
|
|
7
|
+
// Panels batch per file into an InstancedMesh whose instance matrix carries the full
|
|
8
|
+
// transform — position, YAW, PITCH and a Z-SCALE — which is what makes the adaptive
|
|
9
|
+
// panel length free: a 9.6 m panel on a curve and a 10.0 m panel on the straight
|
|
10
|
+
// ride in the same batch.
|
|
11
|
+
// · YOU MUST BE ABLE TO WALK OVER IT: panel batches are named `scatterDeco:` (visual
|
|
12
|
+
// only) — track is 20 cm of rail, not a wall; the formation under it is terrain and
|
|
13
|
+
// the terrain is already in the BVH.
|
|
14
|
+
// · a deck is A FLOOR, NOT A PICTURE OF ONE: the trestle builder hands its deck batch
|
|
15
|
+
// back as an instance group for a side BVH.
|
|
16
|
+
import * as THREE from 'three/webgpu';
|
|
17
|
+
import { loadModel, loadTexture, resolveModelUrl } from '../core/assets.js';
|
|
18
|
+
import { flattenToGeometry } from './scatter.js';
|
|
19
|
+
|
|
20
|
+
// A raw FBX carries the default UV flip; a .glb twin carries glTF-convention UVs
|
|
21
|
+
// (flipY:false). Kits whose castings HAVE twins genuinely need two textures — mixing
|
|
22
|
+
// them is the white-roofs bug, and at a junction it paints the turnout in somebody
|
|
23
|
+
// else's colours.
|
|
24
|
+
export async function railMaterial(texUrl, modelUrl) {
|
|
25
|
+
const resolved = await resolveModelUrl(modelUrl, { texture: texUrl });
|
|
26
|
+
const tex = await loadTexture(texUrl, /\.glb$/i.test(resolved) ? { flipY: false } : {});
|
|
27
|
+
return new THREE.MeshStandardMaterial({ map: tex, roughness: 0.95, metalness: 0 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// THE FOUNDRY MIRRORS THE PATTERN. A casting that throws its diverging road to its own
|
|
31
|
+
// LEFT when the yard is on the RIGHT is reflected in x. A negative scale would do it in
|
|
32
|
+
// one line and get it wrong in three ways (winding reversed → the mesh renders
|
|
33
|
+
// inside-out; normals still pointing the old way → lit from the wrong side; and three's
|
|
34
|
+
// frustum/shadow maths quietly disagreeing about the determinant). So the reflection is
|
|
35
|
+
// baked into the geometry properly: negate x on the positions, negate x on the normals,
|
|
36
|
+
// and reverse the winding to put the faces back the right way out.
|
|
37
|
+
export function mirrorX(src) {
|
|
38
|
+
const g = src.clone();
|
|
39
|
+
const p = g.attributes.position, n = g.attributes.normal;
|
|
40
|
+
for (let i = 0; i < p.count; i++) p.setX(i, -p.getX(i));
|
|
41
|
+
if (n) for (let i = 0; i < n.count; i++) n.setX(i, -n.getX(i));
|
|
42
|
+
if (g.index) {
|
|
43
|
+
const a = g.index.array;
|
|
44
|
+
for (let i = 0; i < a.length; i += 3) { const t = a[i]; a[i] = a[i + 2]; a[i + 2] = t; }
|
|
45
|
+
g.index.needsUpdate = true;
|
|
46
|
+
} else {
|
|
47
|
+
// non-indexed: swap the 1st and 3rd vertex of every triangle, across every attribute
|
|
48
|
+
for (const key of Object.keys(g.attributes)) {
|
|
49
|
+
const at = g.attributes[key], w = at.itemSize, arr = at.array;
|
|
50
|
+
for (let t = 0; t < at.count; t += 3) {
|
|
51
|
+
for (let c = 0; c < w; c++) {
|
|
52
|
+
const i = (t + 0) * w + c, j = (t + 2) * w + c;
|
|
53
|
+
const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
at.needsUpdate = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
p.needsUpdate = true;
|
|
60
|
+
if (n) n.needsUpdate = true;
|
|
61
|
+
return g;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Euler YXZ: yaw first, then pitch about the piece's OWN lateral axis. Applied to the
|
|
65
|
+
// piece's local +Z it gives (cosθ·sin ry, sinθ, cosθ·cos ry) — a chord with heading `ry`
|
|
66
|
+
// climbing at θ — which is exactly the chord a panel is cut to. Any other order tilts
|
|
67
|
+
// the sleepers. Returns a fresh matrixFor with its own scratch objects.
|
|
68
|
+
export function makeChordMatrix() {
|
|
69
|
+
const _e = new THREE.Euler();
|
|
70
|
+
const _q = new THREE.Quaternion();
|
|
71
|
+
const _p = new THREE.Vector3();
|
|
72
|
+
const _s = new THREE.Vector3();
|
|
73
|
+
const matrixFor = (p) => {
|
|
74
|
+
_e.set(p.pitch, p.ry, 0, 'YXZ');
|
|
75
|
+
_q.setFromEuler(_e);
|
|
76
|
+
_p.set(p.x, p.y, p.z);
|
|
77
|
+
_s.set(1, 1, p.sz ?? 1); // the chord's own length over the casting's native length
|
|
78
|
+
return new THREE.Matrix4().compose(_p, _q, _s);
|
|
79
|
+
};
|
|
80
|
+
matrixFor.yawOnly = (p) => {
|
|
81
|
+
_e.set(0, p.ry, 0, 'YXZ');
|
|
82
|
+
_q.setFromEuler(_e);
|
|
83
|
+
return new THREE.Matrix4().compose(_p.set(p.x, p.y, p.z), _q, _s.set(1, 1, 1));
|
|
84
|
+
};
|
|
85
|
+
return matrixFor;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One InstancedMesh per (file) panel batch. `scatterDeco:` by default — see the header;
|
|
89
|
+
// pass a `scatter:` name yourself when the batch is genuinely SOLID (a ridge of rock).
|
|
90
|
+
// frustumCulled false: a kilometres-long line has a county-sized bounding sphere, so
|
|
91
|
+
// culling on it is a coin toss that always comes up "visible" — not worth the test.
|
|
92
|
+
export function panelBatch(scene, geo, material, mats, file) {
|
|
93
|
+
if (!geo || !mats.length) return null;
|
|
94
|
+
const mesh = new THREE.InstancedMesh(geo, material, mats.length);
|
|
95
|
+
mats.forEach((m, i) => mesh.setMatrixAt(i, m));
|
|
96
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
97
|
+
mesh.castShadow = false; // hundreds of low bars of rail cast nothing worth a shadow pass
|
|
98
|
+
mesh.receiveShadow = true;
|
|
99
|
+
mesh.name = `scatterDeco:${file}`;
|
|
100
|
+
mesh.matrixAutoUpdate = false;
|
|
101
|
+
mesh.updateMatrix();
|
|
102
|
+
mesh.frustumCulled = false;
|
|
103
|
+
scene.add(mesh);
|
|
104
|
+
return mesh;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One-off casting placer: turnouts, buffer stops, portals. Position + YXZ yaw/pitch +
|
|
108
|
+
// uniform scale, optional mirrorX. `scatterDeco:` — a buffer stop you cannot walk past
|
|
109
|
+
// is a buffer stop that traps you at the end of the spur; give the mesh real collision
|
|
110
|
+
// yourself (buildingObjects push) when the casting is genuinely solid.
|
|
111
|
+
export async function placeCasting(scene, { url, tex, place, name, mirror = false }) {
|
|
112
|
+
try {
|
|
113
|
+
const m = await loadModel(url, { texture: tex });
|
|
114
|
+
const f = flattenToGeometry(m);
|
|
115
|
+
if (!f) throw new Error('no geometry');
|
|
116
|
+
const geo = mirror ? mirrorX(f.geometry) : f.geometry;
|
|
117
|
+
const mesh = new THREE.Mesh(geo, await railMaterial(tex, url));
|
|
118
|
+
mesh.position.set(place.x, place.y, place.z);
|
|
119
|
+
mesh.rotation.order = 'YXZ';
|
|
120
|
+
mesh.rotation.set(place.pitch, place.ry, 0);
|
|
121
|
+
mesh.scale.setScalar(place.scale);
|
|
122
|
+
mesh.castShadow = true;
|
|
123
|
+
mesh.receiveShadow = true;
|
|
124
|
+
mesh.name = `scatterDeco:${name}`;
|
|
125
|
+
scene.add(mesh);
|
|
126
|
+
return mesh;
|
|
127
|
+
} catch (err) { console.error(`[railway] ${name} failed`, err); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Seeded LCG — an unseeded Math.random reshuffles a dressed ridge every boot: one boot
|
|
131
|
+
// frames the portal beautifully, the next drops a boulder in front of it. Same rocks
|
|
132
|
+
// every time.
|
|
133
|
+
export function lcgRng(seed = 0xC0FFEE) {
|
|
134
|
+
let _seed = seed >>> 0;
|
|
135
|
+
return () => { _seed = (_seed * 1664525 + 1013904223) >>> 0; return _seed / 4294967296; };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// THE TRESTLE, BUILT. Where the line flies it rides timber, not air: a flying panel
|
|
139
|
+
// KEEPS its rails and gains the kit's deck casting UNDER them (deck-only planking, same
|
|
140
|
+
// chord convention); the ballast is skipped by the caller (dirt does not float), and a
|
|
141
|
+
// bent goes under every deck joint, standing on (and into) whatever is below. `seat`
|
|
142
|
+
// lifts the deck so its top plane meets the panel origin — the plane the sleepers rest
|
|
143
|
+
// on — measured off the casting. Returns { deckMesh, bentCount, walkable } — the deck
|
|
144
|
+
// IS A FLOOR: bake `walkable` into a side BVH or someone rides onto the trestle and
|
|
145
|
+
// falls straight through the planks.
|
|
146
|
+
export async function buildTrestle(scene, { deckPanels, deckUrl, bentUrl, tex, material, seat = 0, matrixFor }) {
|
|
147
|
+
if (!deckPanels.length) return null;
|
|
148
|
+
const deckMaster = await loadModel(deckUrl, { texture: tex });
|
|
149
|
+
const bentMaster = await loadModel(bentUrl, { texture: tex });
|
|
150
|
+
const deckFlat = flattenToGeometry(deckMaster);
|
|
151
|
+
const bentFlat = flattenToGeometry(bentMaster);
|
|
152
|
+
const deckMats = [], bentMats = [];
|
|
153
|
+
for (const p of deckPanels) {
|
|
154
|
+
deckMats.push(matrixFor({ ...p, y: p.y + seat }));
|
|
155
|
+
bentMats.push(matrixFor.yawOnly({ x: p.x, y: p.y + seat, z: p.z, ry: p.ry })); // square to the track
|
|
156
|
+
}
|
|
157
|
+
const deckMesh = panelBatch(scene, deckFlat.geometry, material, deckMats, deckUrl.split('/').pop());
|
|
158
|
+
panelBatch(scene, bentFlat.geometry, material, bentMats, bentUrl.split('/').pop());
|
|
159
|
+
const walkable = deckMesh ? { geometry: deckMesh.geometry, count: deckMesh.count, matrices: deckMesh.instanceMatrix.array } : null;
|
|
160
|
+
return { deckMesh, bentCount: bentMats.length, walkable };
|
|
161
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// THE PLACEMENT MACHINERY (world splits, step 6) — the deterministic scatter samplers, the
|
|
2
|
+
// placement-manifest COOK, the resident-spec wrappers and the grass OCCUPANCY grid, moved
|
|
3
|
+
// verbatim from western's zones.js. The game keeps its manifest (species rows, counts,
|
|
4
|
+
// biome accepts, crop tables) and calls these; the engine keeps the laws:
|
|
5
|
+
//
|
|
6
|
+
// · the whole placement stream is deterministic — a seeded rng with space-hashed accepts
|
|
7
|
+
// — so it round-trips to ONE binary file per quality preset. On a cook hit the
|
|
8
|
+
// generators return recorded slices and never touch the rng OR heightAt.
|
|
9
|
+
// · slice arrays are the editor-override address space (slice:index), applied AFTER the
|
|
10
|
+
// pristine cook save and BEFORE anything consumes them.
|
|
11
|
+
// · collision-by-name: circle colliders register per tile WRITE (never at ensure()), so
|
|
12
|
+
// a failed asset fetch can't leave an invisible blocker; ground dressing declares a
|
|
13
|
+
// `scatterGround:` collider name and is skipped by the tile BVH bake; humps feed the
|
|
14
|
+
// analytic walk lift identically on cooked and computed paths.
|
|
15
|
+
import * as THREE from 'three/webgpu';
|
|
16
|
+
import { scatterResident } from './scatter.js';
|
|
17
|
+
import { registerHumps } from './walkHumps.js';
|
|
18
|
+
import { colliders } from './kit.js';
|
|
19
|
+
|
|
20
|
+
// ── the placement context — the game's terrain and sampling extent ──
|
|
21
|
+
let PC = { heightAt: () => 0, seaLevel: -Infinity, scatterSize: 2000, saveRoute: '/__save-cookbin', cookRoute: '/assets/cook' };
|
|
22
|
+
export function setPlacementContext(c = {}) { Object.assign(PC, c); }
|
|
23
|
+
|
|
24
|
+
// generic FNV table fingerprint — hash any nested table of numbers/booleans into the cook
|
|
25
|
+
// filename, so a tuned table with an unbumped version counter renames the bin and re-cooks
|
|
26
|
+
// (the only outcome that was ever correct)
|
|
27
|
+
export function fnvSig(table) {
|
|
28
|
+
let h = 2166136261 >>> 0;
|
|
29
|
+
const walk = (v) => {
|
|
30
|
+
if (typeof v === 'number') { h = Math.imul((h ^ (Math.round(v * 1000) >>> 0)) >>> 0, 16777619) >>> 0; }
|
|
31
|
+
else if (typeof v === 'boolean') walk(v ? 1 : 0);
|
|
32
|
+
else if (v && typeof v === 'object') for (const k of Object.keys(v).sort()) walk(v[k]);
|
|
33
|
+
};
|
|
34
|
+
walk(table);
|
|
35
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── the determinism probe + the editor-override address space ──
|
|
39
|
+
let _plHash = 0, _plCount = 0;
|
|
40
|
+
let _sliceRefs = []; // every slice array in stream order — the editor-override address space
|
|
41
|
+
const _hashPl = (out) => {
|
|
42
|
+
for (const p of out) {
|
|
43
|
+
for (const v of [p.x, p.y, p.z, p.ry, p.s]) {
|
|
44
|
+
_plHash = (_plHash ^ ((Math.round((v ?? 0) * 1000)) >>> 0)) >>> 0;
|
|
45
|
+
_plHash = Math.imul(_plHash, 16777619) >>> 0;
|
|
46
|
+
}
|
|
47
|
+
_plCount++;
|
|
48
|
+
}
|
|
49
|
+
_sliceRefs.push(out);
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
export const placementStats = () => ({ hash: _plHash >>> 0, count: _plCount });
|
|
53
|
+
export const sliceRefs = () => _sliceRefs;
|
|
54
|
+
|
|
55
|
+
// ── the cook state machine ──
|
|
56
|
+
let _cookSlices = null, _cookAt = 0, _cookRec = null;
|
|
57
|
+
const _rec = (out) => { if (_cookRec) _cookRec.push(out); return out; };
|
|
58
|
+
const _cookNext = () => {
|
|
59
|
+
const a = _cookSlices[_cookAt++];
|
|
60
|
+
if (!a) { throw new Error('[manifest] cook has fewer slices than the code has generators — bump the zones cook version or delete the cook dir'); }
|
|
61
|
+
const out = new Array(a.length / 6);
|
|
62
|
+
for (let i = 0; i < out.length; i++) {
|
|
63
|
+
const o = i * 6;
|
|
64
|
+
out[i] = { x: a[o], y: a[o + 1], z: a[o + 2], ry: a[o + 3], s: a[o + 4], cr: a[o + 5] };
|
|
65
|
+
}
|
|
66
|
+
return _rec(_hashPl(out)); // the determinism probe still tallies cooked placements
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Try the recorded placement stream. The bin is only trusted if it was cooked for THIS
|
|
70
|
+
// county at THIS scale — a scale mismatch is not a cosmetic problem: it is a whole world of
|
|
71
|
+
// props replayed at the wrong coordinates. Resets the probe + override address space for
|
|
72
|
+
// the build either way; on a miss, record mode arms.
|
|
73
|
+
export async function loadPlacementCook({ name, ver, scale }) {
|
|
74
|
+
_plHash = 2166136261 >>> 0; _plCount = 0;
|
|
75
|
+
_cookSlices = null; _cookAt = 0; _cookRec = null; _sliceRefs = [];
|
|
76
|
+
try {
|
|
77
|
+
const r = await fetch(`${PC.cookRoute}/${name}`);
|
|
78
|
+
if (r.ok) {
|
|
79
|
+
const buf = await r.arrayBuffer();
|
|
80
|
+
const hlen = new DataView(buf).getUint32(0, true);
|
|
81
|
+
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 4, hlen)));
|
|
82
|
+
if (head.ver !== ver || head.scale !== scale) {
|
|
83
|
+
console.warn(`[manifest] ${name} is v${head.ver}/x${head.scale}, this build wants v${ver}/x${scale} — IGNORING it and re-placing from scratch`);
|
|
84
|
+
} else {
|
|
85
|
+
const f32 = new Float32Array(buf, 4 + hlen);
|
|
86
|
+
const slices = []; let off = 0;
|
|
87
|
+
for (const len of head.lens) { slices.push(f32.subarray(off, off + len * 6)); off += len * 6; }
|
|
88
|
+
_cookSlices = slices;
|
|
89
|
+
console.log(`[manifest] COOKED: ${head.count} placements from ${name} — placement compute skipped`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (e) { console.warn('[zones] cook bin missing/unreadable — falling back to the SLOW compute path (first boot after a VER bump is expected; repeated boots are not)', e?.message ?? e); }
|
|
93
|
+
if (!_cookSlices) _cookRec = [];
|
|
94
|
+
return !!_cookSlices;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// record mode: persist the whole placement stream for the next boot (dev middleware writes
|
|
98
|
+
// the cook dir — the same self-writing scheme as the VAT bins). On a cooked boot, checks
|
|
99
|
+
// the slice count instead (a generator added/removed without a version bump is loud).
|
|
100
|
+
export function savePlacementCook({ name, ver, scale, quality }) {
|
|
101
|
+
if (_cookRec) {
|
|
102
|
+
try {
|
|
103
|
+
const lens = _cookRec.map((a) => a.length);
|
|
104
|
+
const total = lens.reduce((s, l) => s + l, 0);
|
|
105
|
+
const f32 = new Float32Array(total * 6);
|
|
106
|
+
let o = 0;
|
|
107
|
+
for (const arr of _cookRec) for (const p of arr) { f32[o++] = p.x; f32[o++] = p.y; f32[o++] = p.z; f32[o++] = p.ry; f32[o++] = p.s; f32[o++] = p.cr ?? 0; }
|
|
108
|
+
const srcs = _cookRec.map((a) => a._src ?? null); // slice source models — the editor renders real meshes from these
|
|
109
|
+
let head = JSON.stringify({ ver, scale, quality, hash: (_plHash >>> 0).toString(16), count: _plCount, lens, srcs });
|
|
110
|
+
while ((head.length + 4) % 4) head += ' '; // pad so the Float32Array view lands 4-byte aligned
|
|
111
|
+
const hb = new TextEncoder().encode(head);
|
|
112
|
+
const out = new Uint8Array(4 + hb.length + f32.byteLength);
|
|
113
|
+
new DataView(out.buffer).setUint32(0, hb.length, true);
|
|
114
|
+
out.set(hb, 4);
|
|
115
|
+
out.set(new Uint8Array(f32.buffer), 4 + hb.length);
|
|
116
|
+
fetch(`${PC.saveRoute}?name=${name}`, { method: 'POST', body: out });
|
|
117
|
+
console.log(`[manifest] cooked → ${name} (${(out.length / 1024).toFixed(0)}KB, ${lens.length} slices)`);
|
|
118
|
+
} catch (e) { console.warn('[manifest] cook save failed', e); }
|
|
119
|
+
_cookRec = null;
|
|
120
|
+
} else if (_cookSlices && _cookAt !== _cookSlices.length) {
|
|
121
|
+
console.warn(`[manifest] cook slice-count mismatch (${_cookAt} used of ${_cookSlices.length}) — a generator was added/removed; bump the zones cook version`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MAP-EDITOR OVERRIDES: hand edits addressed as slice:index against the PRISTINE stream,
|
|
126
|
+
// applied to the shared pl arrays AFTER the cook save (the recorded bin stays pristine) and
|
|
127
|
+
// BEFORE the grass occupancy/TileField consume them. Same behaviour cooked/computed.
|
|
128
|
+
export async function applyPlacementOverrides({ url = '/assets/cook/overrides.json', ver }) {
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetch(url);
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
const ov = await r.json();
|
|
133
|
+
if (ov.ver != null && ov.ver !== ver) {
|
|
134
|
+
console.warn(`[manifest] editor overrides were recorded against zones v${ov.ver} (now v${ver}) — indices would corrupt; SKIPPED.`);
|
|
135
|
+
throw 0; // into the catch — pristine world
|
|
136
|
+
}
|
|
137
|
+
let applied = 0;
|
|
138
|
+
for (const [k, m] of Object.entries(ov.move ?? {})) {
|
|
139
|
+
const [si, i] = k.split(':').map(Number);
|
|
140
|
+
const p = _sliceRefs[si]?.[i];
|
|
141
|
+
if (p) { p.x = m[0]; p.y = m[1]; p.z = m[2]; p.ry = m[3]; p.s = m[4]; applied++; }
|
|
142
|
+
}
|
|
143
|
+
const dels = (ov.del ?? []).map((k) => k.split(':').map(Number)).sort((a, b) => b[1] - a[1]); // splice high→low keeps indices coherent
|
|
144
|
+
for (const [si, i] of dels) { if (_sliceRefs[si]?.[i] !== undefined) { _sliceRefs[si].splice(i, 1); applied++; } }
|
|
145
|
+
for (const a of ov.add ?? []) {
|
|
146
|
+
const s = _sliceRefs[a.slice];
|
|
147
|
+
if (s) { s.push({ x: a.x, y: a.y ?? PC.heightAt(a.x, a.z) - 0.08, z: a.z, ry: a.ry ?? 0, s: a.s ?? 1, cr: a.cr ?? 0 }); applied++; }
|
|
148
|
+
}
|
|
149
|
+
if (applied) console.log(`[manifest] ${applied} editor overrides applied`);
|
|
150
|
+
}
|
|
151
|
+
} catch (e) { /* no overrides — pristine world */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── the samplers ──
|
|
155
|
+
export function placementField(rng, count, accept, { sMin = 0.85, sMax = 1.3, sPow = 1, collider = 0, bvhOwned = false, box = null, fitY = 0 } = {}) {
|
|
156
|
+
if (_cookSlices) return _cookNext(); // cooked boot: recorded placements, zero rng/heightAt
|
|
157
|
+
const out = [];
|
|
158
|
+
let guard = 0;
|
|
159
|
+
while (out.length < count && guard++ < count * 40) {
|
|
160
|
+
const x = box ? box[0] + rng() * (box[2] - box[0]) : (rng() - 0.5) * PC.scatterSize * 0.94;
|
|
161
|
+
const z = box ? box[1] + rng() * (box[3] - box[1]) : (rng() - 0.5) * PC.scatterSize * 0.94;
|
|
162
|
+
if (!accept(x, z)) continue;
|
|
163
|
+
const y = PC.heightAt(x, z);
|
|
164
|
+
// sPow > 1 biases sizes small (many modest plants, few giants)
|
|
165
|
+
let s = sMin + Math.pow(rng(), sPow) * (sMax - sMin);
|
|
166
|
+
let py = y - 0.08;
|
|
167
|
+
if (fitY) { // span from a sunk base up toward nearby high ground (cliff-fit)
|
|
168
|
+
let hi = y;
|
|
169
|
+
for (const [dx, dz] of [[16, 0], [-16, 0], [0, 16], [0, -16], [12, 12], [-12, -12], [12, -12], [-12, 12]]) { const hh = PC.heightAt(x + dx, z + dz); if (hh > hi) hi = hh; }
|
|
170
|
+
py = PC.seaLevel - 3;
|
|
171
|
+
s = Math.max(0.4, Math.min(2.6, (hi - py) / fitY));
|
|
172
|
+
}
|
|
173
|
+
const p = { x, y: py, z, ry: rng() * Math.PI * 2, s };
|
|
174
|
+
// circle colliders are registered into `colliders` ONLY once the mesh's tile actually
|
|
175
|
+
// writes (see residentSpec), so a failed asset fetch can't leave an invisible blocker
|
|
176
|
+
p.cr = (collider && !bvhOwned) ? collider : 0;
|
|
177
|
+
out.push(p);
|
|
178
|
+
}
|
|
179
|
+
return _rec(_hashPl(out));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A generator that is NOT rejection-sampled. A crop row walks a GRID and a fence panel walks
|
|
183
|
+
// a PERIMETER; throwing darts at a rectangle to find them would be absurd. Same cook contract
|
|
184
|
+
// as placementField, and it must be: on a cook hit it returns the recorded slice and never
|
|
185
|
+
// runs the builder. It consumes no rng, which is fine — the seeded stream's order is only
|
|
186
|
+
// required to be the same on two COLD boots, and this generator is the same no-op on both.
|
|
187
|
+
export function listField(build) {
|
|
188
|
+
if (_cookSlices) return _cookNext();
|
|
189
|
+
return _rec(_hashPl(build()));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── the resident kit — per-build wrappers encoding the collision-by-name contract ──
|
|
193
|
+
// Tree/cactus circle colliders are registered into `colliders` ONLY after a tile writes —
|
|
194
|
+
// a failed asset fetch must not leave an invisible blocker. `occluders` records the same
|
|
195
|
+
// circles synchronously so the grass occupancy grid can still dodge trunks.
|
|
196
|
+
export function makeResidentKit() {
|
|
197
|
+
const occluders = [];
|
|
198
|
+
const noteOcc = (pl) => { for (const p of pl) if (p.cr) occluders.push(p); };
|
|
199
|
+
const regCol = (pl, sink) => { for (const p of pl) if (p.cr) { const c = { x: p.x, z: p.z, r: p.cr * p.s }; colliders.push(c); if (sink) sink(c); } };
|
|
200
|
+
// Each helper returns a RESIDENT SPEC: the game collects these into a MANIFEST and the
|
|
201
|
+
// TileField streams the world from it. ensure(cap) builds the spec's resident mesh set ONCE
|
|
202
|
+
// (empty, capacity-sized); write(k, sub, sink)/free(k) stream one tile's placements in/out.
|
|
203
|
+
// CONTRACT (collision determinism): ensure() must NOT register circle colliders — regCol
|
|
204
|
+
// runs per WRITE, so circles register with their tile and are spliced back out on dispose.
|
|
205
|
+
const residentSpec = (pl, makeResident) => {
|
|
206
|
+
const spec = {
|
|
207
|
+
pl,
|
|
208
|
+
ensure: (cap) => (spec._hp ||= makeResident(cap)
|
|
209
|
+
.then((h) => (spec._h = h))
|
|
210
|
+
.catch((e) => { console.warn('[resident] spec build failed', e); return (spec._h = null); })),
|
|
211
|
+
write: async (key, sub, sink) => {
|
|
212
|
+
const h = spec._hp ? await spec._hp : null; // resolved after boot — a runtime write never suspends
|
|
213
|
+
if (!h) return null;
|
|
214
|
+
const res = h.write(key, sub);
|
|
215
|
+
if (res) regCol(sub, sink);
|
|
216
|
+
return res;
|
|
217
|
+
},
|
|
218
|
+
free: (key) => { spec._h?.free(key); },
|
|
219
|
+
handle: () => spec._h,
|
|
220
|
+
};
|
|
221
|
+
return spec;
|
|
222
|
+
};
|
|
223
|
+
// tag(): stamp each slice array with its source model+atlas — the cook header ships
|
|
224
|
+
// these names so the map editor can render REAL meshes (not marker cones)
|
|
225
|
+
const tag = (pl, url, tex) => { pl._src = { url, tex }; return pl; };
|
|
226
|
+
// res(): SIMPLE resident scatter — one mesh set per species, no impostor/LOD tiers
|
|
227
|
+
const res = (dir, atlas, file, pl, opts = {}) => { noteOcc(tag(pl, `${dir}/${file}.fbx`, atlas)); return residentSpec(pl, (cap) => scatterResident(`${dir}/${file}.fbx`, atlas, cap, pl, opts)); };
|
|
228
|
+
// resFlat(): the same, but the instances NEVER collide. Collision membership is decided by
|
|
229
|
+
// NAME — tileManager/the world facade drop any collider whose name matches /fence|flower|
|
|
230
|
+
// path|brick|ground|floor|grass/i — so ground dressing declares itself with a
|
|
231
|
+
// `scatterGround:` collider name and is skipped by the tile BVH bake. You walk OVER flat
|
|
232
|
+
// ground dressing; a 20cm pebble that stops a horse is a bug, not a feature.
|
|
233
|
+
const resFlat = (dir, atlas, file, pl, opts = {}) => {
|
|
234
|
+
noteOcc(tag(pl, `${dir}/${file}.fbx`, atlas));
|
|
235
|
+
return residentSpec(pl, async (cap) => {
|
|
236
|
+
const h = await scatterResident(`${dir}/${file}.fbx`, atlas, cap, pl, { castShadow: false, ...opts });
|
|
237
|
+
h.colliderName = `scatterGround:${file}.fbx`;
|
|
238
|
+
h.group.name = h.colliderName; // and out of the `^scatter:` BVH scan too
|
|
239
|
+
return h;
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
// resHump(): resFlat for dressing TALL enough to read as ground. Same no-BVH rule (a lump
|
|
243
|
+
// of dirt must never STOP anything), but the placements ALSO feed walkHumps' analytic
|
|
244
|
+
// lift, so everything that walks rides OVER the mound instead of clipping through it.
|
|
245
|
+
// Registration happens here, at declaration, identically on the cooked and computed paths
|
|
246
|
+
// (`pl` is the final rows on both), so collision truth can never drift from the renderer.
|
|
247
|
+
const resHump = (dir, atlas, file, pl, opts = {}) => {
|
|
248
|
+
registerHumps(file, pl);
|
|
249
|
+
return resFlat(dir, atlas, file, pl, opts);
|
|
250
|
+
};
|
|
251
|
+
return { occluders, residentSpec, tag, res, resFlat, resHump };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── the grass OCCUPANCY GRID — every cell covered by a placed object, O(1) per blade ──
|
|
255
|
+
export function makeOccupancy({ cell = 0.5 } = {}) {
|
|
256
|
+
const keyOf = (gx, gz) => gx * 100003 + gz;
|
|
257
|
+
const occ = new Set();
|
|
258
|
+
const mark = (x, z, r) => {
|
|
259
|
+
const r2 = r * r, gx = Math.round(x / cell), gz = Math.round(z / cell), c = Math.ceil(r / cell);
|
|
260
|
+
for (let i = -c; i <= c; i++) for (let j = -c; j <= c; j++) {
|
|
261
|
+
if ((i * cell) ** 2 + (j * cell) ** 2 <= r2) occ.add(keyOf(gx + i, gz + j)); // circle, not square
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
// Rectangular (rotated) footprint — buildings aren't round; marks cells inside the oriented box.
|
|
265
|
+
const markRect = (cx, cz, hx, hz, ang) => {
|
|
266
|
+
const ca = Math.cos(ang), sa = Math.sin(ang), gx = Math.round(cx / cell), gz = Math.round(cz / cell);
|
|
267
|
+
const c = Math.ceil(Math.hypot(hx, hz) / cell);
|
|
268
|
+
for (let i = -c; i <= c; i++) for (let j = -c; j <= c; j++) {
|
|
269
|
+
const wx = (gx + i) * cell - cx, wz = (gz + j) * cell - cz;
|
|
270
|
+
const lx = wx * ca + wz * sa, lz = -wx * sa + wz * ca; // into the box's local frame
|
|
271
|
+
if (Math.abs(lx) <= hx && Math.abs(lz) <= hz) occ.add(keyOf(gx + i, gz + j));
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const rasterTri = (x0, z0, x1, z1, x2, z2) => {
|
|
275
|
+
const gx0 = Math.floor(Math.min(x0, x1, x2) / cell), gx1 = Math.ceil(Math.max(x0, x1, x2) / cell);
|
|
276
|
+
const gz0 = Math.floor(Math.min(z0, z1, z2) / cell), gz1 = Math.ceil(Math.max(z0, z1, z2) / cell);
|
|
277
|
+
for (let gx = gx0; gx <= gx1; gx++) for (let gz = gz0; gz <= gz1; gz++) {
|
|
278
|
+
const px = gx * cell, pz = gz * cell;
|
|
279
|
+
const d1 = (px - x1) * (z0 - z1) - (x0 - x1) * (pz - z1);
|
|
280
|
+
const d2 = (px - x2) * (z1 - z2) - (x1 - x2) * (pz - z2);
|
|
281
|
+
const d3 = (px - x0) * (z2 - z0) - (x2 - x0) * (pz - z0);
|
|
282
|
+
if (!(((d1 < 0) || (d2 < 0) || (d3 < 0)) && ((d1 > 0) || (d2 > 0) || (d3 > 0)))) occ.add(keyOf(gx, gz));
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
// Buildings: stamp each one's REAL ground footprint (floor + low-wall faces) into the
|
|
286
|
+
// grid, so grass meets every wall flush — works for any building shape.
|
|
287
|
+
const _va = new THREE.Vector3(), _vb = new THREE.Vector3(), _vc = new THREE.Vector3();
|
|
288
|
+
const stampBuildings = (objects, wallBand = 1.5) => {
|
|
289
|
+
for (const obj of objects) {
|
|
290
|
+
obj.updateMatrixWorld(true);
|
|
291
|
+
const floorTop = new THREE.Box3().setFromObject(obj).min.y + wallBand; // floor slab + lower walls only
|
|
292
|
+
obj.traverse((o) => {
|
|
293
|
+
if (!o.isMesh || !o.geometry?.attributes.position) return;
|
|
294
|
+
const pos = o.geometry.attributes.position, idx = o.geometry.index, mw = o.matrixWorld;
|
|
295
|
+
const stamp = (i0, i1, i2) => {
|
|
296
|
+
_va.fromBufferAttribute(pos, i0).applyMatrix4(mw);
|
|
297
|
+
_vb.fromBufferAttribute(pos, i1).applyMatrix4(mw);
|
|
298
|
+
_vc.fromBufferAttribute(pos, i2).applyMatrix4(mw);
|
|
299
|
+
if (_va.y > floorTop && _vb.y > floorTop && _vc.y > floorTop) return; // skip roof/upper walls
|
|
300
|
+
rasterTri(_va.x, _va.z, _vb.x, _vb.z, _vc.x, _vc.z);
|
|
301
|
+
};
|
|
302
|
+
if (idx) for (let i = 0; i < idx.count; i += 3) stamp(idx.getX(i), idx.getX(i + 1), idx.getX(i + 2));
|
|
303
|
+
else for (let i = 0; i < pos.count; i += 3) stamp(i, i + 1, i + 2);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const occupied = (x, z) => occ.has(keyOf(Math.round(x / cell), Math.round(z / cell)));
|
|
308
|
+
return { mark, markRect, rasterTri, stampBuildings, occupied };
|
|
309
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// THE RAIL FORMATION SOLVER (world splits, step 2) — the embankment IS the terrain.
|
|
2
|
+
// Moved verbatim from western's terrainData: the navvy's algorithm that turns a centreline
|
|
3
|
+
// over raw ground into a buildable formation, and the corridor index heightAt interrogates
|
|
4
|
+
// 1.74M times per cold cook. Everything here is PURE MACHINERY — the county supplies the
|
|
5
|
+
// nodes, the surveyed ground, the ruling grade and the weave (its applyRail composition
|
|
6
|
+
// stays game-side, where the river guard and road bench live).
|
|
7
|
+
//
|
|
8
|
+
// HOW A LINE IS SOLVED, at every node of the centreline (~1 m apart):
|
|
9
|
+
// 1. the game SURVEYS the ground into g[] (its own choice of surface — pads in, benches out)
|
|
10
|
+
// 2. LEVEL THE YARDS — each contiguous `flat` run pulled to its own mean
|
|
11
|
+
// 3. THE RULING GRADE — ceil/floor Lipschitz envelopes at maxGrade; their midpoint splits
|
|
12
|
+
// every obstacle evenly between a cutting and an embankment
|
|
13
|
+
// 4. VERTICAL CURVES — a diffusion pass is a curve and cannot break the ruling grade
|
|
14
|
+
// 5. optional WELD onto an already-solved line (one bed through the turnout, no seam)
|
|
15
|
+
// 6. e[] — earthworks weight: 1 = full bench, 0 = past fillMax, flying on a trestle
|
|
16
|
+
//
|
|
17
|
+
// HEADLESS BY LAW, like geo.js: no three imports at all.
|
|
18
|
+
import { clamp, lerp, smoothstep } from './geo.js';
|
|
19
|
+
|
|
20
|
+
// Nearest point on a solved line: how far, and how high the formation is there.
|
|
21
|
+
export function nearestOnLine(nodes, Y, x, z) {
|
|
22
|
+
let bd = Infinity, by = 0;
|
|
23
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
24
|
+
const a = nodes[i], b = nodes[i + 1];
|
|
25
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
26
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
27
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
28
|
+
if (d < bd) { bd = d; by = lerp(Y[i], Y[i + 1], t); }
|
|
29
|
+
}
|
|
30
|
+
return { d: bd, y: by };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// nodes: [{x, z, s, w, flat}] — s = arc length, w = bed half-width, flat = yard node.
|
|
34
|
+
// g: Float64Array of surveyed ground per node (the game samples its own surface).
|
|
35
|
+
// mergeWith: { nodes, y } of an already-solved line to weld through the shared bed.
|
|
36
|
+
// Returns { y, g, e }.
|
|
37
|
+
export function solveFormation(nodes, g, { maxGrade, smooth = 90, mergeIn = 7, mergeOut = 32, fillMax, mergeWith = null }) {
|
|
38
|
+
const n = nodes.length;
|
|
39
|
+
g = Float64Array.from(g);
|
|
40
|
+
|
|
41
|
+
// ONE BED THROUGH THE TURNOUT: where this line is still in the other's bed, the ground
|
|
42
|
+
// it is surveyed on IS the other line's formation.
|
|
43
|
+
if (mergeWith) {
|
|
44
|
+
for (let i = 0; i < n; i++) {
|
|
45
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
46
|
+
g[i] = lerp(m.y, g[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// LEVEL THE YARDS — each contiguous run of `flat` nodes pulled to its own mean.
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
if (!nodes[i].flat) continue;
|
|
53
|
+
let j = i; while (j < n && nodes[j].flat) j++;
|
|
54
|
+
let sum = 0; for (let k = i; k < j; k++) sum += g[k];
|
|
55
|
+
const mean = sum / (j - i);
|
|
56
|
+
for (let k = i; k < j; k++) g[k] = mean;
|
|
57
|
+
i = j;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// THE RULING GRADE — the two Lipschitz envelopes, and their midpoint.
|
|
61
|
+
const ceil = Float64Array.from(g), floor = Float64Array.from(g);
|
|
62
|
+
for (let i = 1; i < n; i++) {
|
|
63
|
+
const d = maxGrade * (nodes[i].s - nodes[i - 1].s);
|
|
64
|
+
ceil[i] = Math.max(ceil[i], ceil[i - 1] - d); // may only FALL at the ruling grade
|
|
65
|
+
floor[i] = Math.min(floor[i], floor[i - 1] + d); // may only RISE at it
|
|
66
|
+
}
|
|
67
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
68
|
+
const d = maxGrade * (nodes[i + 1].s - nodes[i].s);
|
|
69
|
+
ceil[i] = Math.max(ceil[i], ceil[i + 1] - d);
|
|
70
|
+
floor[i] = Math.min(floor[i], floor[i + 1] + d);
|
|
71
|
+
}
|
|
72
|
+
const y = new Float64Array(n);
|
|
73
|
+
for (let i = 0; i < n; i++) y[i] = 0.5 * (ceil[i] + floor[i]);
|
|
74
|
+
|
|
75
|
+
// THE VERTICAL CURVES.
|
|
76
|
+
const tmp = new Float64Array(n);
|
|
77
|
+
for (let p = 0; p < smooth; p++) {
|
|
78
|
+
tmp.set(y);
|
|
79
|
+
for (let i = 1; i < n - 1; i++) y[i] = 0.25 * tmp[i - 1] + 0.5 * tmp[i] + 0.25 * tmp[i + 1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// …and weld the result back onto the line it left — bit-identical through the shared bed.
|
|
83
|
+
if (mergeWith) {
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
85
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
86
|
+
y[i] = lerp(m.y, y[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// HOW MUCH EARTH TO MOVE. Past fillMax the bank would be a wall — stop building and fly.
|
|
91
|
+
const e = new Float64Array(n);
|
|
92
|
+
for (let i = 0; i < n; i++) e[i] = 1 - smoothstep(fillMax, fillMax + 1.5, y[i] - g[i]);
|
|
93
|
+
return { y, g, e };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// THE CORRIDOR INDEX — chunked AABBs per line plus ONE box round the whole railway, because
|
|
97
|
+
// the county is huge, the railway is a ribbon, and the common answer must cost four compares.
|
|
98
|
+
// lines: { name: { nodes, y, e } }. Returns { railInfo, railDistance, records } — records is
|
|
99
|
+
// the fixed scratch array (one record per line, rewritten in place, zero allocation per call)
|
|
100
|
+
// the game's own corridor blend iterates.
|
|
101
|
+
export function makeRailIndex(lines, { near, chunk = 32 } = {}) {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
const box = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity };
|
|
104
|
+
const rl = {};
|
|
105
|
+
for (const name of Object.keys(lines)) {
|
|
106
|
+
rl[name] = { d: Infinity, y: 0, w: 0, e: 1 };
|
|
107
|
+
const { nodes: N, y: Y, e: E } = lines[name];
|
|
108
|
+
for (let i = 0; i < N.length - 1; i += chunk) {
|
|
109
|
+
const end = Math.min(i + chunk, N.length - 1);
|
|
110
|
+
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
111
|
+
for (let j = i; j <= end; j++) {
|
|
112
|
+
const p = N[j];
|
|
113
|
+
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
|
114
|
+
if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
|
|
115
|
+
}
|
|
116
|
+
chunks.push({ name, N, Y, E, i0: i, i1: end, minX: minX - near, maxX: maxX + near, minZ: minZ - near, maxZ: maxZ + near });
|
|
117
|
+
box.minX = Math.min(box.minX, minX - near); box.maxX = Math.max(box.maxX, maxX + near);
|
|
118
|
+
box.minZ = Math.min(box.minZ, minZ - near); box.maxZ = Math.max(box.maxZ, maxZ + near);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const records = Object.values(rl);
|
|
122
|
+
|
|
123
|
+
function railInfo(x, z) {
|
|
124
|
+
for (const r of records) r.d = Infinity;
|
|
125
|
+
if (x < box.minX || x > box.maxX || z < box.minZ || z > box.maxZ) return rl;
|
|
126
|
+
for (const c of chunks) {
|
|
127
|
+
if (x < c.minX || x > c.maxX || z < c.minZ || z > c.maxZ) continue;
|
|
128
|
+
const R = rl[c.name], N = c.N, Y = c.Y, E = c.E;
|
|
129
|
+
for (let i = c.i0; i < c.i1; i++) {
|
|
130
|
+
const a = N[i], b = N[i + 1];
|
|
131
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
132
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
133
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
134
|
+
if (d < R.d) {
|
|
135
|
+
R.d = d;
|
|
136
|
+
R.y = lerp(Y[i], Y[i + 1], t);
|
|
137
|
+
R.w = lerp(a.w, b.w, t);
|
|
138
|
+
R.e = lerp(E[i], E[i + 1], t);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return rl;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function railDistance(x, z) {
|
|
146
|
+
const r = railInfo(x, z);
|
|
147
|
+
let d = Infinity;
|
|
148
|
+
for (const name in r) if (r[name].d < d) d = r[name].d;
|
|
149
|
+
return d;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { railInfo, railDistance, records };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// WHERE THE ROADS CROSS THE RAILS — found, not assumed: every road segment against every
|
|
156
|
+
// rail segment. Returns [{x, z, y, tx, tz}] with y = the formation there and (tx,tz) the
|
|
157
|
+
// rail's unit tangent (the axis the deck is measured along).
|
|
158
|
+
export function findCrossings(roads, lines, { minSpacing = 30 } = {}) {
|
|
159
|
+
const xings = [];
|
|
160
|
+
const hit = (a, b, c, d) => {
|
|
161
|
+
const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
|
|
162
|
+
const den = rx * sz - rz * sx;
|
|
163
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
164
|
+
const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / den;
|
|
165
|
+
const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / den;
|
|
166
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
167
|
+
return { x: a.x + rx * t, z: a.z + rz * t };
|
|
168
|
+
};
|
|
169
|
+
for (const road of roads) {
|
|
170
|
+
for (let i = 0; i < road.length - 1; i++) {
|
|
171
|
+
for (const name of Object.keys(lines)) {
|
|
172
|
+
const { nodes: N, y: Y } = lines[name];
|
|
173
|
+
for (let j = 0; j < N.length - 1; j++) {
|
|
174
|
+
const p = hit(road[i], road[i + 1], N[j], N[j + 1]);
|
|
175
|
+
if (p && !xings.some((q) => Math.hypot(q.x - p.x, q.z - p.z) < minSpacing)) {
|
|
176
|
+
const tx0 = N[j + 1].x - N[j].x, tz0 = N[j + 1].z - N[j].z;
|
|
177
|
+
const tl = Math.hypot(tx0, tz0) || 1;
|
|
178
|
+
xings.push({ x: p.x, z: p.z, y: nearestOnLine(N, Y, p.x, p.z).y, tx: tx0 / tl, tz: tz0 / tl });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return xings;
|
|
185
|
+
}
|