sindicate 0.15.0 → 0.17.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.
@@ -0,0 +1,152 @@
1
+ // WHAT MUST BE TRUE OF A ROAD WORLD.
2
+ //
3
+ // Every fault in this file was found by a person driving into it, one at a time, over an
4
+ // afternoon. Each was silent: nothing threw, nothing logged, the world simply looked wrong in
5
+ // one particular place. That is the argument for checking them at build — a road system has
6
+ // no natural failure mode, so it needs assertions or it needs somebody's evening.
7
+ //
8
+ // const report = auditRoadWorld(roads, { ground });
9
+ // if (report.failures.length) console.warn(report.summary);
10
+ //
11
+ // Cheap by design: it samples rather than proves, so a game can afford to run it on every
12
+ // cold build and still boot.
13
+ const NEAR = (a, b, tol) => Math.abs(a - b) <= tol;
14
+
15
+ export function auditRoadWorld(roads, { ground, samples = 24, tolerance = 0.06 } = {}) {
16
+ const checks = [];
17
+ const fail = (rule, detail) => checks.push({ rule, ok: false, detail });
18
+ const pass = (rule, detail) => checks.push({ rule, ok: true, detail });
19
+ const { heightAt, links = [], placed = new Map() } = roads ?? {};
20
+ if (!heightAt || !links.length) return { failures: [], checks, summary: 'no roads to audit' };
21
+
22
+ // 1. NO DIRT ABOVE TARMAC. The ground is cut below every deck; if it is not, a wedge of
23
+ // landscape lies across the carriageway.
24
+ let poke = 0, worstPoke = 0, pokeAt = null, n1 = 0;
25
+ for (const { link, from } of links) {
26
+ const half = from.width / 2;
27
+ const line = link.centreline;
28
+ for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
29
+ const p = line[i], q = line[i + 1];
30
+ const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
31
+ const rx = -fz / L, rz = fx / L;
32
+ for (const off of [-half * 0.95, 0, half * 0.95]) {
33
+ const x = p[0] + rx * off, z = p[2] + rz * off;
34
+ n1++;
35
+ const over = heightAt(x, z) - p[1];
36
+ if (over > tolerance) {
37
+ poke++;
38
+ if (over > worstPoke) { worstPoke = over; pokeAt = [Math.round(x), Math.round(z)]; }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ poke ? fail('no ground above a road', `${poke}/${n1} samples, worst ${worstPoke.toFixed(2)} m at ${pokeAt}`)
44
+ : pass('no ground above a road', `${n1} samples clean`);
45
+
46
+ // 2. NO FLOATING ROADS. Beside the tarmac the ground must come up to meet it — a road with
47
+ // a gap of air under its edge exists nowhere in the world.
48
+ let float = 0, worstFloat = 0, floatAt = null, n2 = 0;
49
+ for (const { link, from } of links) {
50
+ const half = from.width / 2;
51
+ const line = link.centreline;
52
+ for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
53
+ const p = line[i], q = line[i + 1];
54
+ const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
55
+ const rx = -fz / L, rz = fx / L;
56
+ for (const off of [-half - 1.5, half + 1.5]) {
57
+ const x = p[0] + rx * off, z = p[2] + rz * off;
58
+ // A CUTTING IS NOT A FLOATING ROAD. Where a lower carriageway runs alongside — the
59
+ // motorway beneath an interchange, say — the ground beside this road drops to meet
60
+ // it, held by a retaining wall. That is a road ON something, not over nothing.
61
+ // look a little wider than the sample itself: the wall of a cutting stands OUTSIDE
62
+ // the lower carriageway's bed, so asking only at this exact point misses it
63
+ let inCutting = false;
64
+ for (const probe of [0, 6, 12]) {
65
+ const b = heightAt.deck?.(x + rx * Math.sign(off) * probe, z + rz * Math.sign(off) * probe);
66
+ if (b != null && b < p[1] - 2) { inCutting = true; break; }
67
+ }
68
+ if (inCutting) continue;
69
+ n2++;
70
+ const under = p[1] - heightAt(x, z);
71
+ if (under > 1.0) {
72
+ float++;
73
+ if (under > worstFloat) { worstFloat = under; floatAt = [Math.round(x), Math.round(z)]; }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ float ? fail('no road floating over a gap', `${float}/${n2} edge samples, worst ${worstFloat.toFixed(2)} m at ${floatAt}`)
79
+ : pass('no road floating over a gap', `${n2} edge samples clean`);
80
+
81
+ // 3. EVERY LINK MEETS ITS SOCKETS. A road that stops short of the junction it was built to
82
+ // reach is the one fault a player cannot drive around.
83
+ let gaps = 0;
84
+ for (const { link, from, to } of links) {
85
+ const a = link.centreline[0], b = link.centreline[link.centreline.length - 1];
86
+ if (!NEAR(a[0], from.pos[0], 0.1) || !NEAR(a[2], from.pos[2], 0.1) || !NEAR(a[1], from.pos[1], 0.1)) gaps++;
87
+ if (!NEAR(b[0], to.pos[0], 0.1) || !NEAR(b[2], to.pos[2], 0.1) || !NEAR(b[1], to.pos[1], 0.1)) gaps++;
88
+ }
89
+ gaps ? fail('every link meets both sockets', `${gaps} loose ends`)
90
+ : pass('every link meets both sockets', `${links.length * 2} ends joined`);
91
+
92
+ // 4. NOTHING UNDRIVABLE. A link whose grade had to exceed what was asked for is a fact the
93
+ // world should know about, not a surprise on the hill.
94
+ const steep = links.filter(({ link }) => link.feasible === false);
95
+ steep.length ? fail('every link is drivable at its grade', `${steep.length} link(s) needed more grade than allowed`)
96
+ : pass('every link is drivable at its grade', `${links.length} links within grade`);
97
+
98
+ // 5. NOTHING CLAIMS TARMAC IT HAS NOT GOT. A game that hides the ground under its roads —
99
+ // the only cure for two surfaces a few centimetres apart fighting for the same pixels —
100
+ // trusts `heightAt.deck` absolutely: wherever it answers, the terrain is thrown away.
101
+ // So an answer past the end of a road does not make a small error, it makes a hole in
102
+ // the world with the sky showing through it. This was reported from the driver's seat
103
+ // as a black patch at the end of every road, twice.
104
+ let phantom = 0, phantomAt = null, n5 = 0;
105
+ for (const { link, from } of links) {
106
+ const line = link.centreline;
107
+ const half = from.width / 2;
108
+ for (const [p, q] of [[line[0], line[1]], [line[line.length - 1], line[line.length - 2]]]) {
109
+ if (!p || !q) continue;
110
+ const ax = p[0] - q[0], az = p[2] - q[2];
111
+ const L = Math.hypot(ax, az) || 1; // outward, away from the road
112
+ for (const past of [1.5, half, half * 2 + 4]) {
113
+ for (const side of [-0.8, 0, 0.8]) {
114
+ const x = p[0] + (ax / L) * past - (az / L) * side * half;
115
+ const z = p[2] + (az / L) * past + (ax / L) * side * half;
116
+ n5++;
117
+ if (heightAt.deck?.(x, z) != null) { phantom++; phantomAt ??= [Math.round(x), Math.round(z)]; }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ phantom ? fail('no phantom tarmac past a road end', `${phantom}/${n5} probes claimed a deck, first at ${phantomAt}`)
123
+ : pass('no phantom tarmac past a road end', `${n5} probes beyond the ends clean`);
124
+
125
+ // 6. JUNCTIONS SIT ON THEIR OWN GROUND. A piece seated at the wrong level is the difference
126
+ // between a slip road and a diving board.
127
+ let hanging = 0;
128
+ for (const [, p] of placed) {
129
+ if (!p.site) continue;
130
+ // sample ACROSS the footprint and take the median: a multi-level piece has a motorway
131
+ // running through its middle in a trench, so the ground at its exact centre is
132
+ // legitimately metres below grade. What matters is that the piece as a whole is seated.
133
+ const r = (p.piece?.size?.[0] ?? 60) * 0.35;
134
+ const around = [];
135
+ for (let k = 0; k < 8; k++) {
136
+ const th = (k / 8) * Math.PI * 2;
137
+ around.push(heightAt(p.site.x + Math.cos(th) * r, p.site.z + Math.sin(th) * r));
138
+ }
139
+ around.sort((x, y) => x - y);
140
+ const median = around[Math.floor(around.length / 2)];
141
+ if (Math.abs(median - p.site.y) > 1.5) hanging++;
142
+ }
143
+ hanging ? fail('junctions seated on their own ground', `${hanging} piece(s) more than 1.5 m off`)
144
+ : pass('junctions seated on their own ground', `${placed.size} pieces seated`);
145
+
146
+ const failures = checks.filter((c) => !c.ok);
147
+ const summary = [
148
+ `road audit: ${checks.length - failures.length}/${checks.length} passed`,
149
+ ...checks.map((c) => ` ${c.ok ? '✓' : '✗'} ${c.rule} — ${c.detail}`),
150
+ ].join('\n');
151
+ return { failures, checks, summary };
152
+ }
@@ -15,14 +15,26 @@
15
15
  // both ends meet their arms tangentially — no kink at the seam.
16
16
  import * as THREE from 'three';
17
17
 
18
- const PALETTE = { // texels in the Simpligon atlas (flat sampling, no tiling)
18
+ // TEXELS IN THE SIMPLIGON ATLAS, flat-sampled Kiwi's own coordinates, from
19
+ // CurvedRoadMeshGenerator: UV_ROAD (0.2871, 0.0361), UV_WHITE (0.2871, 0.1105),
20
+ // UV_CONCRETE top (0.2993, 0.1396) and edge (0.2993, 0.1133).
21
+ //
22
+ // The concrete pair was missing here, so anything that was not tarmac got painted with the
23
+ // WHITE LINE texel — which is why the pavements came out looking like road markings rather
24
+ // than like pavements.
25
+ const PALETTE = {
19
26
  asphalt: [0.2871, 0.0361],
20
27
  line: [0.2871, 0.1105],
28
+ concrete: [0.2993, 0.1396], // footway top
29
+ kerb: [0.2993, 0.1133], // its edge, and the gutter face
21
30
  };
22
31
  const MARK_Y = 0.02; // paint sits this far above the asphalt
23
32
  const EDGE_W = 0.1; // edge-line width
24
- const DASH_W = 0.1; // centre-line width
25
- const DASH_ON = 2.5, DASH_OFF = 2.5;
33
+ // KIWI'S OWN FIGURES (CurvedRoadMeshGenerator: DASH_LENGTH 1, DASH_GAP 1.5, DASH_WIDTH
34
+ // 0.15, MARKING_HEIGHT 0.02). Mine were 2.5 on and 2.5 off, which reads as a lane divider on
35
+ // a motorway and as sleepers on a street.
36
+ const DASH_W = 0.15; // centre-line width
37
+ const DASH_ON = 1.0, DASH_OFF = 1.5;
26
38
 
27
39
  const catmull = (p, t) => {
28
40
  // smooth interpolation through the given heights (duplicated ends = flat entry/exit)
@@ -79,6 +91,34 @@ function limitGrade(samples, gWanted, yStart, yEnd) {
79
91
  return { grade: g, feasible: g <= gWanted * 1.001 };
80
92
  }
81
93
 
94
+ // A PLAIN DECK between two points — no markings, no rails. A junction's two carriageways are
95
+ // separate meshes with a gap down the middle, so the ground shows through under the central
96
+ // reservation and drops away beneath the concrete. A generated link has no such gap because
97
+ // it is one deck across its whole width; this closes the same gap inside a junction.
98
+ export function buildDeck({ from, to, width, y = null, palette = PALETTE }) {
99
+ const ax = from[0], az = from[2], bx = to[0], bz = to[2];
100
+ const dx = bx - ax, dz = bz - az;
101
+ const len = Math.hypot(dx, dz) || 1;
102
+ const rx = -dz / len * (width / 2), rz = dx / len * (width / 2);
103
+ const ay = y ?? from[1], by = y ?? to[1];
104
+ const pos = new Float32Array([
105
+ ax - rx, ay, az - rz, ax + rx, ay, az + rz,
106
+ bx - rx, by, bz - rz, bx + rx, by, bz + rz,
107
+ ]);
108
+ const nrm = new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]);
109
+ const uv = new Float32Array([
110
+ palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
111
+ palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
112
+ ]);
113
+ const g = new THREE.BufferGeometry();
114
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
115
+ g.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
116
+ g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
117
+ g.setIndex([0, 1, 2, 1, 3, 2]);
118
+ g.computeBoundingSphere();
119
+ return g;
120
+ }
121
+
82
122
  export function buildRoadLink({
83
123
  from, to,
84
124
  heights = null, // extra height control points between the two sockets
@@ -87,6 +127,9 @@ export function buildRoadLink({
87
127
  handleScale = 0.42, // Bezier handle length as a fraction of the gap — how gentle the curve is
88
128
  maxGrade = 0.08, // 8% — steep but drivable; motorways want ~0.06
89
129
  skirt = 0.5, // vertical apron under the road edges (0 = paper-thin)
130
+ railStep = 5, // spacing of the shoulder rail sections
131
+ medianStep = 2, // spacing of the median's blocks
132
+ railInset = 0.55, // how far inside the deck edge the shoulder rail stands
90
133
  palette = PALETTE,
91
134
  } = {}) {
92
135
  const P0 = new THREE.Vector3(...from.pos);
@@ -165,16 +208,70 @@ export function buildRoadLink({
165
208
  };
166
209
  };
167
210
 
168
- // ── asphalt deck ──
169
- const deck = [];
170
- for (const { p, right, half } of samples) {
171
- const l = p.clone().addScaledVector(right, -half);
172
- const r = p.clone().addScaledVector(right, half);
173
- deck.push([vert(l, UP, palette.asphalt), vert(r, UP, palette.asphalt)]);
174
- }
175
- for (let i = 0; i < deck.length - 1; i++) {
176
- const [a, b] = deck[i], [c, d] = deck[i + 1];
177
- idx.push(a, b, c, b, d, c);
211
+ // ── the deck ──
212
+ //
213
+ // A CROSS-SECTION WITH HEIGHT IN IT, when the socket carries one. A city street is not a
214
+ // flat ribbon with paint on it: it has a kerb, a gutter that drops below the carriageway,
215
+ // and a footway raised above it. Kiwi builds these by taking a road prefab's front-edge
216
+ // vertices as a profile and extruding that along the spline (CurvedRoadMeshGenerator.
217
+ // ExtractRoadProfile), which is why its streets have kerbs and mine, tiled by hand, did
218
+ // not. `profile` is that list of [lateral, height] pairs; without one the deck is the flat
219
+ // ribbon a motorway wants, where the kerbs are somebody else's barrier props.
220
+ //
221
+ // Lateral offsets scale with the socket width so one profile serves a narrow lane and a
222
+ // wide street, and heights do NOT — a kerb is 125 mm whatever the road.
223
+ const xsec = from.profile ?? to.profile ?? null;
224
+ let refHalfOut = 1;
225
+ if (xsec?.length >= 2) {
226
+ // SCALE AGAINST THE CARRIAGEWAY, NOT THE WHOLE SECTION. A socket's `width` is the
227
+ // driveable width; the profile is wider than that because it includes the kerb, the
228
+ // footway and the verge behind it. Scaling by the outermost point squeezes the entire
229
+ // 20 m section into the 10 m carriageway — every kerb and pavement at half size.
230
+ // The carriageway ends at its OWN outermost vertex, not at the first kerb vertex beyond
231
+ // it. Those are different points — the kerb face begins a few centimetres outside the
232
+ // tarmac edge — and taking the kerb's makes every road that fraction narrow: a 10 m
233
+ // street came out 9.84 m, and every kerb, footway and lane marking with it.
234
+ const kerbAt = xsec.filter(([, y]) => y < -0.01).map(([x]) => Math.abs(x));
235
+ const innerKerb = kerbAt.length ? Math.min(...kerbAt) : Infinity;
236
+ const flat = xsec.filter(([x, y]) => Math.abs(y) <= 0.01 && Math.abs(x) <= innerKerb).map(([x]) => Math.abs(x));
237
+ const refHalf = (flat.length ? Math.max(...flat)
238
+ : Number.isFinite(innerKerb) ? innerKerb
239
+ : Math.max(...xsec.map(([x]) => Math.abs(x)))) || 1;
240
+ refHalfOut = refHalf;
241
+ // WHICH SIDE OF THE KERB IS IT ON? Height alone is not enough, and this is the rule
242
+ // Kiwi's GetSimpligonUV actually uses: cast from the centre line toward the vertex, and
243
+ // if a kerb lies between them, the vertex is footway — even where it is flat. A pavement
244
+ // is mostly level ground BEHIND a kerb, so judging by height alone paints the 0.35 m
245
+ // raised lip as pavement and the four metres of actual footway behind it as tarmac.
246
+ const kerbs = xsec.filter(([, y]) => y < -0.01).map(([x]) => x);
247
+ const pastKerb = (lx) => kerbs.some((k) => (lx > 0 && k > 0 && k < lx) || (lx < 0 && k < 0 && k > lx));
248
+ const rows = samples.map(({ p, right, half }) => xsec.map(([lx, ly]) => {
249
+ const q = p.clone().addScaledVector(right, (lx / refHalf) * half);
250
+ q.y += ly;
251
+ // the same three-way split Kiwi makes: below grade is the kerb face, raised or behind
252
+ // a kerb is footway, everything else is carriageway
253
+ const tex = ly < -0.01 ? palette.kerb
254
+ : (ly > 0.05 || pastKerb(lx)) ? palette.concrete
255
+ : palette.asphalt;
256
+ return vert(q, UP, tex);
257
+ }));
258
+ for (let i = 0; i < rows.length - 1; i++) {
259
+ for (let k = 0; k < xsec.length - 1; k++) {
260
+ const a = rows[i][k], b = rows[i][k + 1], c = rows[i + 1][k], d = rows[i + 1][k + 1];
261
+ idx.push(a, b, c, b, d, c);
262
+ }
263
+ }
264
+ } else {
265
+ const deck = [];
266
+ for (const { p, right, half } of samples) {
267
+ const l = p.clone().addScaledVector(right, -half);
268
+ const r = p.clone().addScaledVector(right, half);
269
+ deck.push([vert(l, UP, palette.asphalt), vert(r, UP, palette.asphalt)]);
270
+ }
271
+ for (let i = 0; i < deck.length - 1; i++) {
272
+ const [a, b] = deck[i], [c, d] = deck[i + 1];
273
+ idx.push(a, b, c, b, d, c);
274
+ }
178
275
  }
179
276
 
180
277
  // ── painted strips: a run of cross-sections at fixed lateral offsets ──
@@ -229,13 +326,20 @@ export function buildRoadLink({
229
326
 
230
327
  // ── side apron so an elevated road is not a floating sheet ──
231
328
  if (skirt > 0) {
329
+ // AT THE OUTSIDE OF THE SECTION, not at the carriageway edge. `half` is the driveable
330
+ // half-width, which with a profile is the middle of the ribbon, not its edge — so the
331
+ // apron came down as a wall right where the kerb is, a grey strip alongside the tarmac
332
+ // that reads as part of the road instead of the side of the pavement.
333
+ const outer = xsec?.length ? Math.max(...xsec.map(([x]) => Math.abs(x))) / (refHalfOut || 1) : 1;
232
334
  for (const side of [-1, 1]) {
233
335
  const wall = [];
234
336
  for (const { p, right, half } of samples) {
235
- const top = p.clone().addScaledVector(right, side * half);
337
+ const top = p.clone().addScaledVector(right, side * half * outer);
236
338
  const bot = top.clone(); bot.y -= skirt;
237
339
  const n = [right.x * side, 0, right.z * side];
238
- wall.push([vert(top, n, palette.asphalt), vert(bot, n, palette.asphalt)]);
340
+ // and it is the side of the PAVEMENT out here, not the side of the road
341
+ const tex = xsec?.length ? palette.kerb : palette.asphalt;
342
+ wall.push([vert(top, n, tex), vert(bot, n, tex)]);
239
343
  }
240
344
  for (let i = 0; i < wall.length - 1; i++) {
241
345
  const [a, b] = wall[i], [c, d] = wall[i + 1];
@@ -252,6 +356,48 @@ export function buildRoadLink({
252
356
  geometry.setIndex(idx);
253
357
  geometry.computeBoundingSphere();
254
358
 
359
+ // WHERE THE BARRIERS GO. A motorway's median is the gap between its two innermost solid
360
+ // lines; its shoulders are outside the outermost. Returned as posts a game can instance
361
+ // its own guardrail along — the generator lays road, not other people's props.
362
+ const railings = { median: [], left: [], right: [] };
363
+ if (section?.lines?.length >= 4) {
364
+ const byInner = section.lines.map((b) => b.at).sort((a, b) => Math.abs(a) - Math.abs(b));
365
+ const medianHalf = Math.abs(byInner[0]);
366
+ const outer = Math.max(...section.lines.map((b) => Math.abs(b.at)));
367
+ // each line is sampled at ITS OWN prop's length — a concrete median block is not a
368
+ // steel rail section, and posting them at the same spacing leaves gaps or overlaps
369
+ const lay = (into, spacing, offsetOf) => {
370
+ const out = [];
371
+ for (let s = spacing / 2; s <= length - spacing / 2; s += spacing) {
372
+ const sm = along(s);
373
+ const off = offsetOf(sm);
374
+ // PITCH. A rail section is a rigid bar: leave it level on a graded road and each
375
+ // one steps down from the last, which reads as a gap at every joint. Tilt it to the
376
+ // local slope and consecutive sections meet end to end.
377
+ const back = along(Math.max(s - spacing / 2, 0));
378
+ const fore = along(Math.min(s + spacing / 2, length));
379
+ const rise = fore.p.y - back.p.y;
380
+ const run = Math.hypot(fore.p.x - back.p.x, fore.p.z - back.p.z) || 1e-3;
381
+ out.push({
382
+ pos: [
383
+ +(sm.p.x + sm.right.x * off).toFixed(3), +sm.p.y.toFixed(3),
384
+ +(sm.p.z + sm.right.z * off).toFixed(3),
385
+ ],
386
+ // a prop modelled along its own X lies along the road at this yaw
387
+ yaw: Math.atan2(sm.right.x, sm.right.z) + (into ? Math.PI : 0),
388
+ pitch: +(Math.atan2(rise, run) * (into ? -1 : 1)).toFixed(5),
389
+ });
390
+ }
391
+ return out;
392
+ };
393
+ if (medianHalf > 0.5) railings.median = lay(false, medianStep, () => 0);
394
+ // ON THE SHOULDER, not off it. A rail set beyond the deck edge stands in the grass and
395
+ // wanders off the road wherever the carriageway narrows; it belongs on the grey strip
396
+ // outside the outer line, which is still road.
397
+ railings.left = lay(false, railStep, (sm) => -(sm.half - railInset));
398
+ railings.right = lay(true, railStep, (sm) => sm.half - railInset);
399
+ }
400
+
255
401
  // the centreline is what traffic, benching and the minimap all consume
256
402
  const centreline = samples.map(({ p }) => [+p.x.toFixed(3), +p.y.toFixed(3), +p.z.toFixed(3)]);
257
403
  const grades = [];
@@ -261,7 +407,7 @@ export function buildRoadLink({
261
407
  grades.push(dy / dxz);
262
408
  }
263
409
  return {
264
- geometry, centreline, length,
410
+ geometry, centreline, length, railings,
265
411
  maxGrade: grades.length ? Math.max(...grades.map(Math.abs)) : 0,
266
412
  // false when the two sockets could not be joined within the requested grade: the road
267
413
  // still connects, but the caller should re-site, re-pair, or accept the climb
@@ -50,6 +50,46 @@ export function planEdges(sites, { extra = 0.35, maxLength = Infinity } = {}) {
50
50
  return edges;
51
51
  }
52
52
 
53
+ // A CLOSED CIRCUIT through every site — a ring road or an orbital motorway. Nearest-neighbour
54
+ // tour, then 2-opt until no crossing pair remains, which is enough to stop a small loop from
55
+ // doubling back on itself. Returns the sites in travel order.
56
+ export function planLoop(sites) {
57
+ const n = sites.length;
58
+ if (n < 3) return sites.map((_, i) => i);
59
+ const d = (a, b) => Math.hypot(sites[a].x - sites[b].x, sites[a].z - sites[b].z);
60
+ const tour = [0];
61
+ const left = new Set(sites.map((_, i) => i).slice(1));
62
+ while (left.size) {
63
+ const from = tour[tour.length - 1];
64
+ let best = null;
65
+ for (const i of left) { const dd = d(from, i); if (!best || dd < best.d) best = { i, d: dd }; }
66
+ tour.push(best.i);
67
+ left.delete(best.i);
68
+ }
69
+ const tourLen = (t) => t.reduce((a, _, i) => a + d(t[i], t[(i + 1) % n]), 0);
70
+ for (let pass = 0; pass < 40; pass++) {
71
+ let improved = false;
72
+ for (let i = 1; i < n - 1; i++) {
73
+ for (let j = i + 1; j < n; j++) {
74
+ const cand = [...tour.slice(0, i), ...tour.slice(i, j + 1).reverse(), ...tour.slice(j + 1)];
75
+ if (tourLen(cand) < tourLen(tour) - 1e-6) { tour.splice(0, n, ...cand); improved = true; }
76
+ }
77
+ }
78
+ if (!improved) break;
79
+ }
80
+ return tour;
81
+ }
82
+
83
+ // Where a piece must face to sit ON a loop: along the local tangent, so its through-
84
+ // carriageway continues the circuit rather than pointing across it.
85
+ export function loopTangents(sites, order) {
86
+ const n = order.length;
87
+ return order.map((idx, k) => {
88
+ const prev = sites[order[(k - 1 + n) % n]], next = sites[order[(k + 1) % n]];
89
+ return { index: idx, yaw: Math.atan2(next.x - prev.x, next.z - prev.z) };
90
+ });
91
+ }
92
+
53
93
  const TAU = Math.PI * 2;
54
94
  const wrap = (a) => ((a % TAU) + TAU) % TAU;
55
95
  const angleGap = (a, b) => { const d = Math.abs(wrap(a) - wrap(b)); return Math.min(d, TAU - d); };
@@ -14,6 +14,24 @@ import { smoothstep } from './geo.js';
14
14
 
15
15
  // segment lookup on a uniform grid — a world's worth of road is far too many segments to
16
16
  // walk per lattice vertex, and the lattice is ~1.7M vertices
17
+ // HOW FAR ALONG A SEGMENT A POINT SITS, or null if the segment does not cover it. The two
18
+ // callers below both used to inline this and both got the ends wrong in the same way; the
19
+ // terrain went missing in a rounded blob past the end of every road, because a point out
20
+ // there was told it had tarmac over it and its triangles were dropped as invisible.
21
+ //
22
+ // `narrow` pulls the edges in: a corridor's BED is wider than its carriageway, so anything
23
+ // asking "is the ground hidden here?" must ask about the road, not the earthwork.
24
+ function along(s, x, z, narrow = 0) {
25
+ const dx = s.bx - s.ax, dz = s.bz - s.az;
26
+ const len2 = dx * dx + dz * dz || 1e-6;
27
+ const raw = ((x - s.ax) * dx + (z - s.az) * dz) / len2;
28
+ // past either END of the whole line there is no road, however close the last vertex is
29
+ if (raw < 0 && s.first) return null;
30
+ if (raw > 1 && s.last) return null;
31
+ const t = Math.min(1, Math.max(0, raw));
32
+ return Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) <= s.half - narrow ? t : null;
33
+ }
34
+
17
35
  function segmentIndex(corridors, cell) {
18
36
  const buckets = new Map();
19
37
  const key = (i, j) => `${i},${j}`;
@@ -28,6 +46,19 @@ function segmentIndex(corridors, cell) {
28
46
  const s = {
29
47
  ax: a[0], ay: a[1], az: a[2], bx: b[0], by: b[1], bz: b[2],
30
48
  half: c.halfWidth ?? 5, drop: c.drop ?? 0, batter: c.batter ?? 0.5,
49
+ // DOES THIS CORRIDOR CARRY TARMAC? Most do — a corridor is normally the cut and fill
50
+ // under a road. Some are pure earthwork: the trench that opens the ground under an
51
+ // interchange's lower carriageway is shaped like a road and is not one, and it runs
52
+ // on past the sockets at either end so the batter has somewhere to go. Told that was
53
+ // tarmac, the "don't draw ground you cannot see" pass threw away the terrain over the
54
+ // whole trench and left a hole in the world past the end of the road.
55
+ carries: c.deck !== false,
56
+ // WHERE THE ROAD STOPS. A segment answers questions about points near it by
57
+ // clamping the projection to its own span, which turns each end of a polyline into
58
+ // a half-disc of phantom road reaching a full half-width past the last vertex.
59
+ // Harmless in the middle of a line — the next segment covers that ground anyway —
60
+ // and wrong at the two ends, where it claims tarmac over open country.
61
+ first: i === 0, last: i === line.length - 2,
31
62
  };
32
63
  const id = segs.push(s) - 1;
33
64
  const i0 = Math.floor((Math.min(a[0], b[0]) - reach) / cell), i1 = Math.floor((Math.max(a[0], b[0]) + reach) / cell);
@@ -114,20 +145,110 @@ function rasterAt(r, x, z) {
114
145
  return { y: r.h[k], d: r.d[k] + Math.hypot(outX, outZ) };
115
146
  }
116
147
 
117
- export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfaces = [], cell = 32 } = {}) {
148
+ // Pads get a bucket grid too. A world has tens of junctions and the lattice asks this
149
+ // function ~1.7 million times for a cold cook: scanning every pad per call is 40 million
150
+ // distance tests nobody needs, and it showed up as a 25x cost over the bare terrain.
151
+ function padIndex(pads, cell) {
152
+ const buckets = new Map();
153
+ pads.forEach((p, id) => {
154
+ const reach = (p.halfX != null ? Math.hypot(p.halfX, p.halfZ) : (p.radius ?? 0))
155
+ + Math.max(p.fade ?? 0, p.maxEarthwork ?? 120);
156
+ const i0 = Math.floor((p.x - reach) / cell), i1 = Math.floor((p.x + reach) / cell);
157
+ const j0 = Math.floor((p.z - reach) / cell), j1 = Math.floor((p.z + reach) / cell);
158
+ for (let i = i0; i <= i1; i++) for (let j = j0; j <= j1; j++) {
159
+ const k = `${i},${j}`;
160
+ if (!buckets.has(k)) buckets.set(k, []);
161
+ buckets.get(k).push(id);
162
+ }
163
+ });
164
+ return (x, z) => buckets.get(`${Math.floor(x / cell)},${Math.floor(z / cell)}`) ?? null;
165
+ }
166
+
167
+ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfaces = [], cell = 32, clearance = 0.08 } = {}) {
118
168
  const index = corridors.length ? segmentIndex(corridors, cell) : null;
169
+ const nearPads = pads.length ? padIndex(pads, cell) : null;
119
170
 
120
- return function heightAt(x, z) {
171
+ // WHERE THE DRIVEABLE SURFACE IS. The ground is deliberately cut a little below the road so
172
+ // the two do not fight for the same pixels, which means anything that drives — the player's
173
+ // car, traffic, a pedestrian on a bridge — must ask for the road, not the dirt.
174
+ // Returns null away from any road, so the caller falls back to the ground.
175
+ heightAt.road = function roadAt(x, z, preferY = null) {
176
+ const found = [];
177
+ // pads are driveable too: a junction's deck is where its own roads sit
178
+ for (const pid of nearPads?.(x, z) ?? []) {
179
+ const p = pads[pid];
180
+ if (padDistance(p, x, z) <= 0) found.push(p.y);
181
+ }
182
+ if (index) {
183
+ const ids = index.near(x, z);
184
+ for (const id of ids ?? []) {
185
+ const s = index.segs[id];
186
+ const t = along(s, x, z);
187
+ if (t == null) continue;
188
+ found.push(s.ay + (s.by - s.ay) * t);
189
+ }
190
+ }
191
+ if (!found.length) return null;
192
+ // MULTI-LEVEL: where a bridge crosses a cutting, or a link overlaps an interchange's
193
+ // trench, several surfaces cover the same spot. Answer with the one nearest the height
194
+ // the caller is already at — you stay on the deck you are driving on, you do not fall
195
+ // through it onto the road below.
196
+ if (preferY == null) return Math.max(...found);
197
+ // 'low' / 'high' ask for a level outright. (A non-finite preference cannot do this by
198
+ // arithmetic: |y - -Infinity| is Infinity for EVERY candidate, so the nearest-wins
199
+ // comparison is always false and the first deck found is returned regardless.)
200
+ if (preferY === 'low' || preferY === -Infinity) return Math.min(...found);
201
+ if (preferY === 'high' || preferY === Infinity) return Math.max(...found);
202
+ return found.reduce((best, y) => (Math.abs(y - preferY) < Math.abs(best - preferY) ? y : best));
203
+ };
204
+
205
+ // JUST THE CARRIAGEWAYS — no pads. A pad is a levelled area a junction stands on, not a
206
+ // road deck: anything asking "is this ground hidden under tarmac?" must not be told yes
207
+ // for the whole footprint of an interchange, or it will remove ground you can plainly see.
208
+ // `shrink` narrows the corridor before answering. A corridor's bed is deliberately wider
209
+ // than its tarmac (the road needs level ground beside it), so anything using this to decide
210
+ // "is the ground hidden here?" must ask about the ROAD, not the bed — or it cuts away a
211
+ // strip of terrain along both edges that nothing is covering, leaving a gap at the kerb.
212
+ heightAt.deck = function deckAt(x, z, shrink = 0) {
213
+ if (!index) return null;
214
+ const ids = index.near(x, z);
215
+ if (!ids) return null;
216
+ let lowest = null;
217
+ for (const id of ids) {
218
+ const s = index.segs[id];
219
+ if (!s.carries) continue; // an earthwork is not a road surface
220
+ const t = along(s, x, z, shrink);
221
+ if (t == null) continue;
222
+ const y = s.ay + (s.by - s.ay) * t;
223
+ if (lowest == null || y < lowest) lowest = y;
224
+ }
225
+ return lowest;
226
+ };
227
+
228
+ function heightAt(x, z) {
121
229
  let h = baseHeightAt(x, z);
122
230
 
123
231
  // ── junction pads: level, because a prefab junction cannot bend ──
124
- for (const p of pads) {
232
+ for (const pid of nearPads?.(x, z) ?? []) {
233
+ const p = pads[pid];
125
234
  const d = padDistance(p, x, z);
126
235
  // sit the dirt a little under the deck, or the two surfaces fight for the same pixels
127
236
  const y = p.y - (p.drop ?? 0);
128
237
  if (d <= 0) { h = y; continue; } // inside the footprint: level
129
- const reach = d * (p.batter ?? 0.5); // same earthwork law as a link
130
- h = Math.min(Math.max(h, y - reach), y + reach);
238
+ // A FADE, NOT A CUT. An embankment batter is right for a road crossing a valley and
239
+ // wrong for the ground around a junction: it leaves a visible rim, and the eye reads
240
+ // the whole pad as a platform someone dropped on the landscape. Kiwi's answer, and
241
+ // the right one, is to ease back to the ORIGINAL height over a generous distance —
242
+ // far enough that you cannot tell the ground was levelled at all.
243
+ const fade = p.fade ?? 0;
244
+ if (fade > 0) {
245
+ if (d >= fade) continue; // beyond the fade: natural ground
246
+ const t = smoothstep(0, 1, d / fade);
247
+ h = y + (h - y) * t;
248
+ } else {
249
+ const reach = d * (p.batter ?? 0.5); // the old earthwork law
250
+ h = Math.min(Math.max(h, y - reach), y + reach);
251
+ }
131
252
  }
132
253
 
133
254
  // ── piece decks: cut the plateau open wherever a deck runs BELOW it ──
@@ -173,8 +294,20 @@ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfa
173
294
  }
174
295
  }
175
296
 
297
+ // THE GUARANTEE: ground never sits above a road. The clearance is a hair — just enough
298
+ // that the two surfaces do not fight for the same pixels. It was much larger while this
299
+ // clamp was silently broken, and that showed as a step along every road edge: the ground
300
+ // beside a kerb sitting half a metre below the tarmac it should meet. Corridor and pad bookkeeping alone
301
+ // cannot promise it — a terrain lattice is metres wide, so a vertex just outside a
302
+ // road's flat bed drags its triangle up across the carriageway and lays a wedge of dirt
303
+ // over the tarmac. Every consumer of this function (the mesh, the cook, collision,
304
+ // grass) gets the same promise, in every game, without having to know the rule exists.
305
+ const deck = heightAt.road(x, z, 'low');
306
+ if (deck != null && h > deck - clearance) h = deck - clearance;
176
307
  return h;
177
- };
308
+ }
309
+
310
+ return heightAt;
178
311
  }
179
312
 
180
313
  // The height a junction should sit at: the average ground across its footprint, so it is
@@ -216,7 +349,7 @@ export function seatHeight(heightAt, x, z, radius, rings = 3, spokes = 8) {
216
349
  // law from the road plan, applied automatically.
217
350
  // Several sites at once, for a whole map: flat enough to stand a junction on, spread out
218
351
  // enough to be separate places. Greedy farthest-point selection keeps them from clumping.
219
- export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300 } = {}) {
352
+ export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300, maxGrade = 0 } = {}) {
220
353
  const cands = [];
221
354
  for (let x = -half; x <= half; x += step) {
222
355
  for (let z = -half; z <= half; z += step) {
@@ -230,7 +363,16 @@ export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radi
230
363
  const chosen = [cands[0]];
231
364
  for (const c of cands) {
232
365
  if (chosen.length >= count) break;
233
- if (chosen.every((s) => Math.hypot(s.x - c.x, s.z - c.z) >= minGap)) chosen.push(c);
366
+ const ok = chosen.every((s) => {
367
+ const gap = Math.hypot(s.x - c.x, s.z - c.z);
368
+ if (gap < minGap) return false;
369
+ // AND A ROAD MUST BE ABLE TO REACH IT. Picking the flattest spots regardless of each
370
+ // other's height cheerfully pairs a hilltop with a valley floor, and no road between
371
+ // them can be built at a sane grade — the link then quietly exceeds it.
372
+ if (maxGrade > 0 && Math.abs(s.y - c.y) > gap * maxGrade * 0.55) return false;
373
+ return true;
374
+ });
375
+ if (ok) chosen.push(c);
234
376
  }
235
377
  return chosen;
236
378
  }