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,95 @@
|
|
|
1
|
+
// THE WATER-LEVEL BAKE (world splits, step 3b) — R.wy, the per-vertex water surface every
|
|
2
|
+
// carve, sheet, swimmer and bridge reads. Moved verbatim from the games (identical bodies;
|
|
3
|
+
// western adds PASS 0 + the R.bd gates, which subsume fable's ungated loops — an unsolved
|
|
4
|
+
// course simply runs the live passes).
|
|
5
|
+
//
|
|
6
|
+
// PASS 0 — THE SURVEY GOVERNS (solved courses, R.bd present): wy = solved bed + baked
|
|
7
|
+
// reach depth, source/terminus lake pins, then strictly monotone — the uphill
|
|
8
|
+
// forgiveness below does not apply to a solved course.
|
|
9
|
+
// PASS 1 — raw level = lowest of the ±ww cross-section on the river-free ground, −1 m
|
|
10
|
+
// (cross-section reach capped at 10 m — the wide-trunk lesson: ±26 m banks
|
|
11
|
+
// reached distant low ground and PASS 2 dragged whole reaches to sea level).
|
|
12
|
+
// PASS 2 — terminus pin (end lake surface −0.8 / parent trunk's level at the join / the
|
|
13
|
+
// open sea), monotone-downhill, floor at sea level. Trunks must precede their
|
|
14
|
+
// tributaries in the array so a join reads a FINALIZED parent level.
|
|
15
|
+
// PASS 3 — riffle relief: water rides within riffleCut of the river-free ground (highest
|
|
16
|
+
// of centreline AND both banks at ±(ww+4)) so the monotone floor cannot gouge a
|
|
17
|
+
// slot-gorge across a rise ('rivers underground').
|
|
18
|
+
// PASS 4 — estuary descent: sea-reaching trunks ease to sea level anchored at the
|
|
19
|
+
// SHORELINE node (tails run out to sea), pin seaward, re-monotone the reach.
|
|
20
|
+
//
|
|
21
|
+
// groundFn(x, z) is the game's river-free pristine ground — rawHeight(x, z, true) in both
|
|
22
|
+
// counties. Call this at the game's bake site (BELOW its rawHeight, per the TDZ law both
|
|
23
|
+
// files document). Headless by law.
|
|
24
|
+
import { lerp, smoothstep } from './geo.js';
|
|
25
|
+
|
|
26
|
+
function riverWyAt(R, p) { // baked level of R nearest p — for confluences
|
|
27
|
+
let best = Infinity, wy = R.wy[0];
|
|
28
|
+
for (let i = 0; i < R.pts.length; i++) {
|
|
29
|
+
const d = Math.hypot(R.pts[i].x - p.x, R.pts[i].z - p.z);
|
|
30
|
+
if (d < best) { best = d; wy = R.wy[i]; }
|
|
31
|
+
}
|
|
32
|
+
return wy;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bakeWaterLevels(rivers, groundFn, { seaLevel, coastZ, riffleCut = 1.6 } = {}) {
|
|
36
|
+
// PASS 0
|
|
37
|
+
for (const R of rivers) {
|
|
38
|
+
if (!R.bd) continue;
|
|
39
|
+
const n = R.pts.length;
|
|
40
|
+
R.wy = R.bd.map((b, i) => b + R.wdep[i]);
|
|
41
|
+
if (R.srcLake) R.wy[0] = R.srcLake.level - 0.3;
|
|
42
|
+
if (R.endLake) R.wy[n - 1] = R.endLake.level - 0.8;
|
|
43
|
+
for (let i = 1; i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
|
|
44
|
+
}
|
|
45
|
+
// PASS 1
|
|
46
|
+
for (const R of rivers) {
|
|
47
|
+
if (R.bd) continue;
|
|
48
|
+
R.wy = R.pts.map((p, i, a) => {
|
|
49
|
+
const pa = a[Math.max(0, i - 1)], pb = a[Math.min(a.length - 1, i + 1)];
|
|
50
|
+
let tx = pb.x - pa.x, tz = pb.z - pa.z; const tl = Math.hypot(tx, tz) || 1; const nx = -tz / tl, nz = tx / tl;
|
|
51
|
+
const w = Math.min(R.ww[i], 10);
|
|
52
|
+
return Math.min(groundFn(p.x, p.z), groundFn(p.x + nx * w, p.z + nz * w), groundFn(p.x - nx * w, p.z - nz * w)) - 1.0;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// PASS 2
|
|
56
|
+
for (const R of rivers) {
|
|
57
|
+
if (R.bd) continue;
|
|
58
|
+
const n = R.wy.length;
|
|
59
|
+
R.wy[n - 1] = R.endLake ? R.endLake.level - 0.8
|
|
60
|
+
: R.parent != null ? riverWyAt(rivers[R.parent], R.pts[n - 1])
|
|
61
|
+
: seaLevel;
|
|
62
|
+
for (let i = 1; i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
|
|
63
|
+
for (let i = 0; i < n; i++) R.wy[i] = Math.max(R.wy[i], seaLevel);
|
|
64
|
+
}
|
|
65
|
+
// PASS 3
|
|
66
|
+
for (const R of rivers) {
|
|
67
|
+
if (R.bd) continue;
|
|
68
|
+
for (let i = 0; i < R.wy.length; i++) {
|
|
69
|
+
const p2 = R.pts[i], pa2 = R.pts[Math.max(0, i - 1)], pb2 = R.pts[Math.min(R.pts.length - 1, i + 1)];
|
|
70
|
+
let tx2 = pb2.x - pa2.x, tz2 = pb2.z - pa2.z; const tl2 = Math.hypot(tx2, tz2) || 1;
|
|
71
|
+
const nx2 = -tz2 / tl2, nz2 = tx2 / tl2, off2 = R.ww[i] + 4;
|
|
72
|
+
const gRef = Math.max(
|
|
73
|
+
groundFn(p2.x, p2.z),
|
|
74
|
+
groundFn(p2.x + nx2 * off2, p2.z + nz2 * off2),
|
|
75
|
+
groundFn(p2.x - nx2 * off2, p2.z - nz2 * off2),
|
|
76
|
+
);
|
|
77
|
+
R.wy[i] = Math.max(R.wy[i], gRef - riffleCut);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// PASS 4
|
|
81
|
+
for (const R of rivers) {
|
|
82
|
+
if (R.bd || R.parent != null || R.endLake) continue;
|
|
83
|
+
const n = R.wy.length;
|
|
84
|
+
if (R.pts[n - 1].z < coastZ) continue;
|
|
85
|
+
let iShore = n - 1;
|
|
86
|
+
while (iShore > 0 && groundFn(R.pts[iShore].x, R.pts[iShore].z) < seaLevel + 0.3) iShore--;
|
|
87
|
+
const K = Math.min(14, iShore);
|
|
88
|
+
for (let i = iShore - K; i <= iShore; i++) {
|
|
89
|
+
const t = smoothstep(iShore - K, iShore, i);
|
|
90
|
+
R.wy[i] = Math.max(seaLevel, lerp(R.wy[i], seaLevel, t));
|
|
91
|
+
}
|
|
92
|
+
for (let i = iShore + 1; i < n; i++) R.wy[i] = seaLevel;
|
|
93
|
+
for (let i = Math.max(1, iShore - Math.min(24, iShore)); i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// THE WATER MESHES (world splits, step 6) — river ribbons, lake sheets and the sea plane,
|
|
2
|
+
// moved verbatim from western's zones.js. All geometry + per-vertex hydraulic bakes; the
|
|
3
|
+
// MATERIALS stay the game's choice (one shared waterMaterial across lakes, one shared
|
|
4
|
+
// riverMaterial across courses — each distinct refracting material pays its own viewport
|
|
5
|
+
// copy per frame, so sharing collapses the copies however many bodies the county gains).
|
|
6
|
+
import * as THREE from 'three/webgpu';
|
|
7
|
+
|
|
8
|
+
// A river ribbon: walk the path in ~2m steps, emit left/right cross-section verts at ±w,
|
|
9
|
+
// bake the still-water depth (level − bed) + downstream UVs per vertex, strip the quads.
|
|
10
|
+
// Emitted as ~200 m CHUNKS (each its own geometry + bounding sphere) rather than one
|
|
11
|
+
// course-long mesh: a 3.6 km course has a county-sized bounding sphere, so frustumCulled
|
|
12
|
+
// never actually culled, and refraction pays a viewport copy per DRAW. Boundary
|
|
13
|
+
// cross-section rows are duplicated into both neighbouring chunks — identical baked values
|
|
14
|
+
// from identical inputs, so the strip has no seam.
|
|
15
|
+
export function buildRiverMesh(scene, R, mat, { heightAt }) {
|
|
16
|
+
const L = THREE.MathUtils.lerp, pts = [];
|
|
17
|
+
// Reference half-width for the flow bake: the course's own source→mouth hydraulic ramp,
|
|
18
|
+
// PER STATION — not one course-wide constant. The solved widths are that ramp ±25 %
|
|
19
|
+
// pool/riffle modulation, and it is the MODULATION continuity should answer to: against a
|
|
20
|
+
// constant reference, a narrow upstream pool computes faster than a wide downstream riffle
|
|
21
|
+
// (measured: pools averaged 0.50 m/s to the riffles' 0.44), which is backwards. Against
|
|
22
|
+
// the local ramp, wide-for-here runs slow and narrow-for-here runs quick, the whole course over.
|
|
23
|
+
const wEnd = R.pts.length - 1 || 1;
|
|
24
|
+
const wrefAt = (f) => L(R.wsrc ?? R.w, R.w, f);
|
|
25
|
+
for (let i = 0; i < R.pts.length - 1; i++) {
|
|
26
|
+
const a = R.pts[i], b = R.pts[i + 1], steps = Math.max(1, Math.round(Math.hypot(b.x - a.x, b.z - a.z) / 2));
|
|
27
|
+
for (let s = 0; s < steps; s++) { const t = s / steps; pts.push({ x: L(a.x, b.x, t), z: L(a.z, b.z, t), wy: L(R.wy[i], R.wy[i + 1], t), ww: L(R.ww[i], R.ww[i + 1], t), wr: wrefAt((i + t) / wEnd) }); }
|
|
28
|
+
}
|
|
29
|
+
const e = R.pts.length - 1; pts.push({ x: R.pts[e].x, z: R.pts[e].z, wy: R.wy[e], ww: R.ww[e], wr: wrefAt(1) });
|
|
30
|
+
|
|
31
|
+
// cross-section resolution scales with the river's width — ~2.4m across (odd → centreline)
|
|
32
|
+
const K = Math.max(9, Math.min(41, 2 * Math.round(Math.max(...R.ww) / 2.4) + 1));
|
|
33
|
+
const CHUNK_LEN = 200; // m of course per geometry
|
|
34
|
+
const meshes = [];
|
|
35
|
+
const newChunk = () => ({ verts: [], depths: [], uvs: [], flows: [], drops: [], bWy: [], bX: [], bZ: [], rows: 0 });
|
|
36
|
+
const pushRow = (c, row) => {
|
|
37
|
+
for (const [dst, src] of [[c.verts, row.verts], [c.depths, row.depths], [c.uvs, row.uvs],
|
|
38
|
+
[c.flows, row.flows], [c.drops, row.drops], [c.bWy, row.bWy], [c.bX, row.bX], [c.bZ, row.bZ]]) dst.push(...src);
|
|
39
|
+
c.rows++;
|
|
40
|
+
};
|
|
41
|
+
const flush = (c) => {
|
|
42
|
+
const idx = [];
|
|
43
|
+
for (let i = 0; i < c.rows - 1; i++) for (let k = 0; k < K - 1; k++) {
|
|
44
|
+
const a = i * K + k, b = a + 1, q = (i + 1) * K + k, d = q + 1;
|
|
45
|
+
idx.push(a, q, b, b, q, d);
|
|
46
|
+
}
|
|
47
|
+
const geo = new THREE.BufferGeometry();
|
|
48
|
+
geo.setAttribute('position', new THREE.Float32BufferAttribute(c.verts, 3));
|
|
49
|
+
geo.setAttribute('aDepth', new THREE.Float32BufferAttribute(c.depths, 1));
|
|
50
|
+
geo.setAttribute('uv', new THREE.Float32BufferAttribute(c.uvs, 2));
|
|
51
|
+
geo.setAttribute('aFlow', new THREE.Float32BufferAttribute(c.flows, 2));
|
|
52
|
+
geo.setAttribute('aDrop', new THREE.Float32BufferAttribute(c.drops, 1));
|
|
53
|
+
geo.setIndex(idx);
|
|
54
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
55
|
+
// per-chunk bake data — refreshRiverDepths already iterates N meshes, so each chunk
|
|
56
|
+
// re-derives its own aDepth after the terrain pads mutate heightAt
|
|
57
|
+
mesh.userData.riverVerts = { wy: Float32Array.from(c.bWy), x: Float32Array.from(c.bX), z: Float32Array.from(c.bZ) };
|
|
58
|
+
geo.computeBoundingSphere();
|
|
59
|
+
mesh.frustumCulled = true; // refraction pays a viewport copy per DRAW — never draw off-screen chunks
|
|
60
|
+
scene.add(mesh);
|
|
61
|
+
meshes.push(mesh);
|
|
62
|
+
};
|
|
63
|
+
let cur = newChunk(), chunkStart = 0, along = 0;
|
|
64
|
+
for (let i = 0; i < pts.length; i++) {
|
|
65
|
+
const p = pts[i], pa = pts[Math.max(0, i - 1)], pb = pts[Math.min(pts.length - 1, i + 1)];
|
|
66
|
+
let tx = pb.x - pa.x, tz = pb.z - pa.z; const tl = Math.hypot(tx, tz) || 1; tx /= tl; tz /= tl;
|
|
67
|
+
const nx = -tz, nz = tx; // perpendicular in the XZ plane
|
|
68
|
+
if (i > 0) along += Math.hypot(p.x - pts[i - 1].x, p.z - pts[i - 1].z);
|
|
69
|
+
// rapids strength: how fast the water level DROPS just downstream of here (per metre)
|
|
70
|
+
const pd = pts[Math.min(pts.length - 1, i + 3)];
|
|
71
|
+
const dDist = Math.hypot(pd.x - p.x, pd.z - p.z) || 1;
|
|
72
|
+
const drop = Math.max(0, (p.wy - pd.wy) / dDist);
|
|
73
|
+
const row = newChunk(); // one cross-section, built once…
|
|
74
|
+
for (let k = 0; k < K; k++) {
|
|
75
|
+
const side = (k / (K - 1)) * 2 - 1; // -1 (left bank) … 0 (centre) … 1 (right bank)
|
|
76
|
+
const vx = p.x + nx * p.ww * side, vz = p.z + nz * p.ww * side;
|
|
77
|
+
const g = heightAt(vx, vz);
|
|
78
|
+
// wet verts sit at water level; DRY verts dive a fixed metre BELOW the surface, so the
|
|
79
|
+
// sheet submerges and the waterline is the smooth surface/terrain intersection
|
|
80
|
+
row.verts.push(vx, g < p.wy ? p.wy : p.wy - 1.0, vz);
|
|
81
|
+
row.depths.push(p.wy - g); // ≤0 on dry verts → transparent + bank foam at the line
|
|
82
|
+
row.bWy.push(p.wy); row.bX.push(vx); row.bZ.push(vz);
|
|
83
|
+
row.uvs.push((side + 1) * 0.5, along * 0.1); // V runs down the WHOLE course — seamless across chunks
|
|
84
|
+
// downstream velocity (m/s), BAKED FROM THE HYDRAULICS instead of a universal
|
|
85
|
+
// constant (every reach of every course at the same speed — a pool as fast as a
|
|
86
|
+
// rapid). Slope sets the pace (drop is already per-metre, from the reach-baked wy
|
|
87
|
+
// three nodes downstream), width modulates it by continuity against the local ramp
|
|
88
|
+
// (wr above): the solved courses run ±25 % pool/riffle width, so a narrow riffle
|
|
89
|
+
// visibly quickens and a wide pool slows to a crawl.
|
|
90
|
+
const v = THREE.MathUtils.clamp(0.4 + 10 * drop, 0.3, 2.4) * (p.wr / p.ww);
|
|
91
|
+
row.flows.push(tx * v, tz * v);
|
|
92
|
+
row.drops.push(drop);
|
|
93
|
+
}
|
|
94
|
+
pushRow(cur, row);
|
|
95
|
+
if (along - chunkStart >= CHUNK_LEN && i < pts.length - 1) {
|
|
96
|
+
flush(cur);
|
|
97
|
+
cur = newChunk(); chunkStart = along;
|
|
98
|
+
pushRow(cur, row); // …and shared by both sides of the cut
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
flush(cur);
|
|
102
|
+
return meshes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A lake sheet: one plane per body at the survey level − drop, per-vertex aDepth bake, dry
|
|
106
|
+
// verts dived below the surface, per-vertex aRClip so ONE shared material keeps per-lake
|
|
107
|
+
// sprawl clip. The shared material is the game's (waterMaterial({ rClip: 'attribute' })).
|
|
108
|
+
export function buildLake(scene, Lk, mat, { heightAt, drop = 0.8 }) {
|
|
109
|
+
const rClip = Lk.rClip ?? Lk.r * 0.85;
|
|
110
|
+
const wr = rClip + 4;
|
|
111
|
+
const wseg = THREE.MathUtils.clamp(Math.round((wr * 2) / 1.8), 16, 64); // ~1.8m facets
|
|
112
|
+
const wgeo = new THREE.PlaneGeometry(wr * 2, wr * 2, wseg, wseg);
|
|
113
|
+
const waterY = Lk.level - drop;
|
|
114
|
+
const dp = wgeo.attributes.position, aDepth = new Float32Array(dp.count);
|
|
115
|
+
const aRClip = new Float32Array(dp.count).fill(rClip); // per-vertex so the shared shader keeps per-lake sprawl clip
|
|
116
|
+
for (let i = 0; i < dp.count; i++) {
|
|
117
|
+
const g = heightAt(Lk.x + dp.getX(i), Lk.z - dp.getY(i));
|
|
118
|
+
aDepth[i] = waterY - g;
|
|
119
|
+
if (g >= waterY) dp.setZ(i, -drop); // dry verts dive below the surface (plane local +z is world up)
|
|
120
|
+
}
|
|
121
|
+
wgeo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
|
|
122
|
+
wgeo.setAttribute('aRClip', new THREE.BufferAttribute(aRClip, 1));
|
|
123
|
+
const lake = new THREE.Mesh(wgeo, mat);
|
|
124
|
+
lake.rotation.x = -Math.PI / 2;
|
|
125
|
+
lake.position.set(Lk.x, waterY, Lk.z);
|
|
126
|
+
scene.add(lake);
|
|
127
|
+
return lake;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The sea: a flat ocean plane at seaLevel, shown only where land dips below it past coastZ.
|
|
131
|
+
export function buildSea(scene, mat, { worldSize, seaLevel, coastZ, heightAt }) {
|
|
132
|
+
const SEG = 256;
|
|
133
|
+
const geo = new THREE.PlaneGeometry(worldSize, worldSize, SEG, SEG);
|
|
134
|
+
const pos = geo.attributes.position, aDepth = new Float32Array(pos.count);
|
|
135
|
+
for (let i = 0; i < pos.count; i++) {
|
|
136
|
+
const X = pos.getX(i), Z = -pos.getY(i);
|
|
137
|
+
aDepth[i] = Z > coastZ ? Math.max(0, seaLevel - heightAt(X, Z)) : 0;
|
|
138
|
+
}
|
|
139
|
+
geo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
|
|
140
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
141
|
+
mesh.rotation.x = -Math.PI / 2;
|
|
142
|
+
mesh.position.y = seaLevel;
|
|
143
|
+
mesh.frustumCulled = false;
|
|
144
|
+
scene.add(mesh);
|
|
145
|
+
return mesh;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Rivers v2: re-derive every river vert's depth from the CURRENT terrain — pads that
|
|
149
|
+
// register during world.build mutate heightAt AFTER the ribbons bake, leaving stale aDepth.
|
|
150
|
+
// Called at the END of world.build; re-runnable whenever terrain changes.
|
|
151
|
+
export function refreshRiverDepths(waters, { heightAt }) {
|
|
152
|
+
let fixed = 0;
|
|
153
|
+
for (const m of waters ?? []) {
|
|
154
|
+
const rb = m.userData?.riverVerts;
|
|
155
|
+
if (!rb) continue;
|
|
156
|
+
const pos = m.geometry.attributes.position, aD = m.geometry.attributes.aDepth;
|
|
157
|
+
for (let i = 0; i < aD.count; i++) {
|
|
158
|
+
const g = heightAt(rb.x[i], rb.z[i]);
|
|
159
|
+
const d = rb.wy[i] - g;
|
|
160
|
+
if (Math.abs(d - aD.getX(i)) > 0.02) fixed++;
|
|
161
|
+
aD.setX(i, d);
|
|
162
|
+
pos.setY(i, g < rb.wy[i] ? rb.wy[i] : rb.wy[i] - 1.0);
|
|
163
|
+
}
|
|
164
|
+
aD.needsUpdate = true; pos.needsUpdate = true;
|
|
165
|
+
m.geometry.computeBoundingSphere();
|
|
166
|
+
}
|
|
167
|
+
if (fixed) console.log(`[rivers] depth refresh: ${fixed} verts re-derived after terrain pads`);
|
|
168
|
+
}
|