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,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,110 @@
|
|
|
1
|
+
// ROAD KIT — reading the measured kit a pack ships (roadkit.json from tools/roadKitAnalyze).
|
|
2
|
+
//
|
|
3
|
+
// The kit stores geometry only. Which side of the road traffic drives on is a RUNTIME
|
|
4
|
+
// choice: a game may ship left-hand-drive, a designer may flip a map mid-edit to see how it
|
|
5
|
+
// reads, and neither should require re-analysing a single mesh. So every lane records the
|
|
6
|
+
// SIDE of the centre line it sits on ('L' or 'R', looking outward along its socket) and the
|
|
7
|
+
// direction of travel is derived here.
|
|
8
|
+
//
|
|
9
|
+
// const kit = await loadRoadKit('/assets/packs/KiwiRoads');
|
|
10
|
+
// kit.driveOnLeft = false; // flip the whole world, instantly
|
|
11
|
+
// for (const lane of kit.lanesOf(socket)) console.log(lane.flow); // 'out' | 'in'
|
|
12
|
+
|
|
13
|
+
export const laneFlow = (side, driveOnLeft) => ((side === 'L') === !!driveOnLeft ? 'out' : 'in');
|
|
14
|
+
|
|
15
|
+
export function makeRoadKit(data, { driveOnLeft = data.driveOnLeft ?? true } = {}) {
|
|
16
|
+
const kit = {
|
|
17
|
+
pieces: data.pieces ?? {},
|
|
18
|
+
laneWidth: data.laneWidth ?? 5,
|
|
19
|
+
driveOnLeft,
|
|
20
|
+
piece(name) { return kit.pieces[name] ?? null; },
|
|
21
|
+
// lanes with their direction of travel resolved against the current hand of the road
|
|
22
|
+
lanesOf(socket) {
|
|
23
|
+
return (socket?.lanes ?? []).map((l) => ({ ...l, flow: laneFlow(l.side, kit.driveOnLeft) }));
|
|
24
|
+
},
|
|
25
|
+
// the lanes leaving / arriving at a socket, in kerb-to-median order
|
|
26
|
+
outboundOf(socket) { return kit.lanesOf(socket).filter((l) => l.flow === 'out'); },
|
|
27
|
+
inboundOf(socket) { return kit.lanesOf(socket).filter((l) => l.flow === 'in'); },
|
|
28
|
+
// sockets a given socket may legally join: same kind, compatible width
|
|
29
|
+
compatible(a, b, tol = 0.25) {
|
|
30
|
+
if (!a || !b) return false;
|
|
31
|
+
if ((a.kind ?? 'road') !== (b.kind ?? 'road')) return false;
|
|
32
|
+
return Math.abs(a.width - b.width) <= Math.max(a.width, b.width) * tol;
|
|
33
|
+
},
|
|
34
|
+
// every socket in the kit of a given kind, as { piece, socket } pairs
|
|
35
|
+
socketsOfKind(kind = 'road') {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, p] of Object.entries(kit.pieces)) {
|
|
38
|
+
for (const s of p.sockets ?? []) if ((s.kind ?? 'road') === kind) out.push({ piece: name, socket: s });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return kit;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loadRoadKit(packDir, opts) {
|
|
47
|
+
const data = await fetch(`${packDir}/roadkit.json`, { cache: 'no-store' }).then((r) => r.json());
|
|
48
|
+
return makeRoadKit(data, opts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// WHICH LEVEL OF A PIECE MEETS THE GROUND.
|
|
52
|
+
//
|
|
53
|
+
// Multi-level pieces are authored around their own origin, not around grade: an overpass
|
|
54
|
+
// interchange carries its local roads 10 m above origin and its motorway at origin; a
|
|
55
|
+
// dumbbell carries the motorway 20 m up. Seat either by its origin and the parts that should
|
|
56
|
+
// be at ground level hang in the air. Kiwi's answer was to sink the junction until the roads
|
|
57
|
+
// that meet the outside world are at grade — so the level to seat by is the level of the
|
|
58
|
+
// LOCAL road sockets when a piece has them, and the motorway's otherwise.
|
|
59
|
+
export function gradeLevel(piece) {
|
|
60
|
+
const sockets = piece?.sockets ?? [];
|
|
61
|
+
if (!sockets.length) return 0;
|
|
62
|
+
const local = sockets.filter((s) => (s.kind ?? 'road') === 'road');
|
|
63
|
+
const use = local.length ? local : sockets;
|
|
64
|
+
return use.reduce((a, s) => a + s.pos[1], 0) / use.length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The y to place a piece at so its grade level lands on the given ground height.
|
|
68
|
+
export const seatY = (piece, groundY) => groundY - gradeLevel(piece);
|
|
69
|
+
|
|
70
|
+
// Any part of a piece that sits BELOW its grade level needs a trench: the motorway running
|
|
71
|
+
// under an overpass roundabout, for instance. Returns corridors (in the piece's own frame)
|
|
72
|
+
// that carve it, ready to be transformed with the piece.
|
|
73
|
+
export function cuttings(piece, { margin = 3 } = {}) {
|
|
74
|
+
const grade = gradeLevel(piece);
|
|
75
|
+
const low = (piece?.sockets ?? []).filter((s) => s.pos[1] < grade - 1.5);
|
|
76
|
+
const out = [];
|
|
77
|
+
// pair opposite sockets: a motorway runs through, so its two ends define the alignment
|
|
78
|
+
for (let i = 0; i < low.length; i++) {
|
|
79
|
+
for (let j = i + 1; j < low.length; j++) {
|
|
80
|
+
const a = low[i], b = low[j];
|
|
81
|
+
if (a.dir[0] * b.dir[0] + a.dir[1] * b.dir[1] > -0.8) continue; // not opposite ends
|
|
82
|
+
out.push({
|
|
83
|
+
from: a.pos, to: b.pos,
|
|
84
|
+
halfWidth: Math.max(a.width, b.width) / 2 + margin,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Place a piece in the world and get its sockets in world space. Junction pieces are rigid:
|
|
92
|
+
// they translate and yaw, never bend, so this is the whole transform.
|
|
93
|
+
export function placeSockets(piece, { x = 0, y = 0, z = 0, yaw = 0 } = {}) {
|
|
94
|
+
const cos = Math.cos(yaw), sin = Math.sin(yaw);
|
|
95
|
+
const xf = (p) => [x + p[0] * cos + p[2] * sin, y + p[1], z - p[0] * sin + p[2] * cos];
|
|
96
|
+
return (piece?.sockets ?? []).map((s) => ({
|
|
97
|
+
...s,
|
|
98
|
+
pos: xf(s.pos),
|
|
99
|
+
dir: [s.dir[0] * cos + s.dir[1] * sin, -s.dir[0] * sin + s.dir[1] * cos],
|
|
100
|
+
lanes: (s.lanes ?? []).map((l) => ({ ...l, centre: xf(l.centre) })),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The yaw that turns a piece's socket to face a target point — how a junction is aimed at
|
|
105
|
+
// the thing it is about to be connected to.
|
|
106
|
+
export function yawToFace(socket, tx, tz, { x = 0, z = 0 } = {}) {
|
|
107
|
+
const want = Math.atan2(tx - x, tz - z);
|
|
108
|
+
const have = Math.atan2(socket.dir[0], socket.dir[1]);
|
|
109
|
+
return want - have;
|
|
110
|
+
}
|