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.
- package/CHANGELOG.md +129 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +32 -9
- package/src/vehicle/car.js +324 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +152 -0
- package/src/world/roadLink.js +162 -16
- package/src/world/roadNetwork.js +40 -0
- package/src/world/roadTerrain.js +150 -8
- package/src/world/roadWorld.js +205 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unityPackageExtract.mjs +56 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
|
@@ -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
|
+
}
|
package/src/world/roadLink.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
// ──
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
package/src/world/roadNetwork.js
CHANGED
|
@@ -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); };
|
package/src/world/roadTerrain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
}
|