sindicate 0.16.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,500 @@
1
+ // A CITY IS A STREET NETWORK, AND A BLOCK IS A HOLE IN IT.
2
+ //
3
+ // This is Kiwi's model, brought over: a settlement is a grid of streets laid on ground flat
4
+ // enough to take them, the faces enclosed by those streets are BLOCKS, each block is zoned,
5
+ // and buildings fill the blocks. Kiwi got as far as the blocks — its `BlockData` carries the
6
+ // comment "For building placement later" and nothing ever filled them. `settlement.js` is
7
+ // that later.
8
+ //
9
+ // const city = planCity({ centre: { x: 0, z: 0 }, population: 12000, groundAt: ground });
10
+ // city.streets // [{ a, b, width, kind }] centrelines between junctions
11
+ // city.junctions // [{ x, y, z, arms, type, yaw }] where streets meet, benched
12
+ // city.blocks // [{ polygon, centre, area, zone }] what the streets enclose
13
+ // const heightAt = makeRoadTerrain(ground, city.earthworks);
14
+ //
15
+ // WHY A GRID AND NOT NOISE. A city read from above is legible: you can see where you are
16
+ // because the streets agree with each other. Scattered buildings on winding paths read as
17
+ // woodland with houses in it. The grid is what makes it a town.
18
+ //
19
+ // TERRAIN IS A VETO, NOT A SUGGESTION (Kiwi's rule): a cell whose ground is under water or
20
+ // steeper than a street can climb is not built on, so a city grows along its valley and stops
21
+ // at the hillside rather than paving over it. Population then decides how many of the cells
22
+ // that pass actually get used.
23
+ import { lcgRng } from './permanentWay.js';
24
+
25
+ export const CITY_DEFAULTS = {
26
+ blockSize: 50, // Kiwi's BLOCK_SIZE — everything else derives from it
27
+ streetWidth: 10, // kerb to kerb — the kit's city junctions are authored for this
28
+ avenueWidth: 10, // the kit has ONE street width, so an avenue is a name, not a size
29
+ junctionSize: 20, // Kiwi's JUNCTION_SIZE, and the City junction pieces' footprint
30
+ pavement: 5, // the footway between the kerb and the buildings, each side
31
+ latticeClear: 8, // margin beyond the section — must exceed a terrain lattice cell
32
+ waterLevel: 5, // below this is water and cannot be built on
33
+ maxSlope: 0.16, // a cell this steep will not hold a level block
34
+ maxStreetGrade: 0.08, // 8% — what a street actually climbs, not what one can survive
35
+ relax: 60, // passes of the grade relaxation below
36
+ peoplePerBlock: null, // Kiwi's rate was 200 at its 50 m block — see below
37
+ population: 8000,
38
+ maxRadius: 900, // a hard stop, whatever the population asks for
39
+ bench: 1.0, // Kiwi's ROAD_BED_OFFSET. A tenth of this is what I had, and on a
40
+ // terrain lattice several metres across the interpolation between
41
+ // two vertices eats 12 cm of clearance without noticing.
42
+ fade: 5, // Kiwi's INDENT_WIDTH 2 + SURROUNDING_WIDTH 3
43
+ blockFade: 5, // one block's bench easing into its neighbour's
44
+ batter: 3.5, // metres of skirt per metre of fill — how gently it comes down
45
+ seed: 1,
46
+ };
47
+
48
+ const key = (i, j) => `${i},${j}`;
49
+
50
+ // WHICH PIECE STANDS HERE, AND WHICH WAY ROUND. Kiwi classified a junction purely by how
51
+ // many streets met at it, and so does this. The kit's pieces are authored with a fixed set of
52
+ // arms — a 4-way has all four, a T is missing its north arm, an L-corner keeps east and south
53
+ // — so the rotation is whatever best lines those up with the streets that actually arrive.
54
+ //
55
+ // TRY ALL FOUR AND SCORE, rather than deriving the angle. A derivation has a special case per
56
+ // piece and gets one of them backwards; scoring cannot, and costs sixteen cosines.
57
+ export const PIECE_ARMS = {
58
+ cross: [0, Math.PI / 2, Math.PI, -Math.PI / 2], // N E S W
59
+ tee: [Math.PI / 2, Math.PI, -Math.PI / 2], // E S W — the missing arm is north
60
+ corner: [Math.PI / 2, Math.PI], // E S
61
+ };
62
+
63
+ function fitPiece(arms, type) {
64
+ const want = PIECE_ARMS[type];
65
+ if (!want) return { piece: null, yaw: arms[0] ?? 0 };
66
+ let best = { yaw: 0, score: -Infinity };
67
+ for (let k = 0; k < 4; k++) {
68
+ const yaw = (k * Math.PI) / 2;
69
+ let score = 0;
70
+ for (const a of arms) {
71
+ score += Math.max(...want.map((w) => Math.cos(a - (w + yaw))));
72
+ }
73
+ if (score > best.score) best = { yaw, score };
74
+ }
75
+ return { piece: type, yaw: best.yaw };
76
+ }
77
+
78
+ // WHAT THE GROUND WILL TAKE. Sampled at the cell's corners and centre: a cell that straddles
79
+ // a stream bank has a fine average and is still unbuildable.
80
+ function cellSlope(cx, cz, r, groundAt, cfg) {
81
+ const at = [
82
+ [cx, cz], [cx - r, cz - r], [cx + r, cz - r], [cx - r, cz + r], [cx + r, cz + r],
83
+ ].map(([x, z]) => groundAt(x, z));
84
+ if (Math.min(...at) < cfg.waterLevel) return null;
85
+ const slope = (Math.max(...at) - Math.min(...at)) / (r * 2);
86
+ return { y: at.reduce((a, b) => a + b, 0) / at.length, slope };
87
+ }
88
+
89
+ function cellUsable(cx, cz, r, groundAt, cfg) {
90
+ const s = cellSlope(cx, cz, r, groundAt, cfg);
91
+ return s && s.slope <= cfg.maxSlope ? s.y : null;
92
+ }
93
+
94
+ // Kiwi kept two rejection registers, and the distinction is the useful part: a cell rejected
95
+ // for SLOPE could be recovered by flattening the terrain, a cell rejected for WATER could not.
96
+ function cellWet(cx, cz, r, groundAt, cfg) {
97
+ return cellSlope(cx, cz, r, groundAt, cfg) == null;
98
+ }
99
+
100
+ export function planCity({ centre = { x: 0, z: 0 }, groundAt = () => 0, ...opts } = {}) {
101
+ const cfg = { ...CITY_DEFAULTS, ...opts };
102
+ const B = cfg.blockSize;
103
+ // HOW MANY PEOPLE A BLOCK HOLDS SCALES WITH ITS AREA. Kiwi's figure was 200 for its own
104
+ // 50 m block; carried over unchanged to an 80 m block it asks for two and a half times the
105
+ // blocks the population needs, and the town reports itself two thirds short of a demand
106
+ // that was never real.
107
+ const perBlock = cfg.peoplePerBlock ?? 200 * (B / 50) ** 2;
108
+ const want = Math.max(4, Math.round(cfg.population / perBlock));
109
+ const reach = Math.min(cfg.maxRadius, Math.sqrt(want / Math.PI) * B * 1.6);
110
+ // LOOK FURTHER THAN THE TOWN WILL NEED. If the ground it wants is half hillside, a search
111
+ // stopping at the ideal radius hands back half a city; searching wider lets the same
112
+ // population spread along the flat instead, which is what a real town on a valley floor
113
+ // does. The frontier is nearest-first, so a town with good ground all round stays compact.
114
+ const span = Math.ceil(Math.min(cfg.maxRadius, reach * 2.2) / B);
115
+ const scan = span * B;
116
+
117
+ // 1. WHICH CELLS THE LAND ALLOWS
118
+ const usable = new Map();
119
+ for (let i = -span; i <= span; i++) {
120
+ for (let j = -span; j <= span; j++) {
121
+ const cx = centre.x + i * B, cz = centre.z + j * B;
122
+ if (Math.hypot(cx - centre.x, cz - centre.z) > scan) continue;
123
+ const y = cellUsable(cx, cz, B / 2, groundAt, cfg);
124
+ if (y != null) usable.set(key(i, j), { i, j, x: cx, z: cz, y });
125
+ }
126
+ }
127
+
128
+ // 2. HOW MANY OF THEM GET BUILT ON — grow outward from the middle, taking only cells that
129
+ // touch what is already town, so a city is one place and not a rash of hamlets.
130
+ const seedCell = usable.get(key(0, 0))
131
+ ?? [...usable.values()].sort((a, b) => (a.i ** 2 + a.j ** 2) - (b.i ** 2 + b.j ** 2))[0];
132
+ const built = new Map();
133
+ if (seedCell) {
134
+ const frontier = [seedCell];
135
+ const queued = new Set([key(seedCell.i, seedCell.j)]);
136
+ while (frontier.length && built.size < want) {
137
+ // nearest-first, so the town fills in rather than growing a tentacle
138
+ frontier.sort((a, b) => Math.hypot(a.x - centre.x, a.z - centre.z) - Math.hypot(b.x - centre.x, b.z - centre.z));
139
+ const c = frontier.shift();
140
+ built.set(key(c.i, c.j), c);
141
+ for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
142
+ const k = key(c.i + di, c.j + dj);
143
+ if (usable.has(k) && !queued.has(k)) { queued.add(k); frontier.push(usable.get(k)); }
144
+ }
145
+ }
146
+ }
147
+
148
+ // 3. THE STREETS ARE THE CELL EDGES, and a street too steep to climb is not built.
149
+ //
150
+ // THIS IS KIWI'S RULE, and it is the one that matters. Kiwi benched each junction at the
151
+ // average terrain height in its own box, then measured the grade BETWEEN JUNCTION
152
+ // HEIGHTS — "this is the actual slope the built road will have" — and rejected the road
153
+ // outright if it was too steep, recording the cell as one that terrain flattening might
154
+ // recover later. Grading each block against its own patch of hillside and then joining
155
+ // them up regardless is what produces a town full of ski jumps.
156
+ //
157
+ // So: bench, measure, drop the cell, and do it again, because dropping a cell changes
158
+ // the average at every corner it touched and can make a neighbour too steep in turn.
159
+ let corners, rejectedForSlope = 0;
160
+ const cornersOf = (cells) => {
161
+ const map = new Map();
162
+ const at = (i, j) => {
163
+ const k = key(i, j);
164
+ if (!map.has(k)) {
165
+ map.set(k, { i, j, x: centre.x + (i - 0.5) * B, z: centre.z + (j - 0.5) * B, y: 0, arms: [], n: 0 });
166
+ }
167
+ return map.get(k);
168
+ };
169
+ for (const c of cells.values()) {
170
+ for (const q of [at(c.i, c.j), at(c.i + 1, c.j), at(c.i, c.j + 1), at(c.i + 1, c.j + 1)]) {
171
+ q.y += c.y; q.n++;
172
+ }
173
+ }
174
+ for (const q of map.values()) if (q.n) q.y /= q.n;
175
+ return { map, at };
176
+ };
177
+ const edgesOf = (c, at) => {
178
+ const c00 = at(c.i, c.j), c10 = at(c.i + 1, c.j), c01 = at(c.i, c.j + 1), c11 = at(c.i + 1, c.j + 1);
179
+ return [[c00, c10], [c10, c11], [c11, c01], [c01, c00]];
180
+ };
181
+ for (let pass = 0; pass < 24; pass++) {
182
+ const { map, at } = cornersOf(built);
183
+ const drop = [];
184
+ for (const c of built.values()) {
185
+ const steep = edgesOf(c, at).some(([a, b]) =>
186
+ Math.abs(a.y - b.y) / (Math.hypot(b.x - a.x, b.z - a.z) || 1) > cfg.maxStreetGrade);
187
+ if (steep) drop.push(key(c.i, c.j));
188
+ }
189
+ corners = map;
190
+ if (!drop.length) break;
191
+ // never take the last of it: a city reduced to nothing is worse than a slightly steep one
192
+ if (built.size - drop.length < 4) break;
193
+ for (const k of drop) { built.delete(k); rejectedForSlope++; }
194
+ }
195
+
196
+ // 3b. ONE PLACE, NOT SEVERAL. Dropping cells can cut the town in half; Kiwi finished its
197
+ // grid with a largest-connected-component filter, and so does this.
198
+ if (built.size) {
199
+ const seen = new Set();
200
+ let biggest = [];
201
+ for (const start of built.values()) {
202
+ const k0 = key(start.i, start.j);
203
+ if (seen.has(k0)) continue;
204
+ const group = [], queue = [start];
205
+ seen.add(k0);
206
+ while (queue.length) {
207
+ const c = queue.pop();
208
+ group.push(c);
209
+ for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
210
+ const k = key(c.i + di, c.j + dj);
211
+ if (built.has(k) && !seen.has(k)) { seen.add(k); queue.push(built.get(k)); }
212
+ }
213
+ }
214
+ if (group.length > biggest.length) biggest = group;
215
+ }
216
+ const keep = new Set(biggest.map((c) => key(c.i, c.j)));
217
+ for (const k of [...built.keys()]) if (!keep.has(k)) built.delete(k);
218
+ }
219
+
220
+ // 3bb. FILL THE HOLES. A cell the slope gate refused, with town on three sides of it, is not
221
+ // countryside — it is a crater, and a town with craters in it reads as bomb damage.
222
+ // This is the recovery Kiwi described and never wrote: it tracked slope-rejected cells
223
+ // separately from water-rejected ones precisely because "cells rejected due to slope
224
+ // COULD be flattened" to make the population back up. Water still cannot be filled.
225
+ // The cell takes its level from the neighbours that enclose it, so the earthworks make
226
+ // up the difference and the streets around it already agree with each other.
227
+ let recovered = 0;
228
+ for (let pass = 0; pass < 8; pass++) {
229
+ const add = [];
230
+ for (const c of built.values()) {
231
+ for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
232
+ const i = c.i + di, j = c.j + dj, k = key(i, j);
233
+ if (built.has(k) || add.some((q) => q.i === i && q.j === j)) continue;
234
+ const around = [[1, 0], [-1, 0], [0, 1], [0, -1]]
235
+ .map(([ai, aj]) => built.get(key(i + ai, j + aj))).filter(Boolean);
236
+ if (around.length < 3) continue;
237
+ const x = centre.x + i * B, z = centre.z + j * B;
238
+ if (cellWet(x, z, B / 2, groundAt, cfg)) continue; // water is not recoverable
239
+ add.push({ i, j, x, z, y: around.reduce((a, q) => a + q.y, 0) / around.length });
240
+ }
241
+ }
242
+ if (!add.length) break;
243
+ for (const c of add) { built.set(key(c.i, c.j), c); recovered++; }
244
+ }
245
+ corners = cornersOf(built).map;
246
+
247
+ const cornerAt = (i, j) => corners.get(key(i, j)) ?? { i, j, x: centre.x + (i - 0.5) * B, z: centre.z + (j - 0.5) * B, y: 0, arms: [], n: 0 };
248
+ const streets = new Map();
249
+ for (const c of built.values()) {
250
+ const c00 = cornerAt(c.i, c.j), c10 = cornerAt(c.i + 1, c.j);
251
+ const c01 = cornerAt(c.i, c.j + 1), c11 = cornerAt(c.i + 1, c.j + 1);
252
+ for (const [a, b] of [[c00, c10], [c10, c11], [c11, c01], [c01, c00]]) {
253
+ const k = a.i < b.i || (a.i === b.i && a.j < b.j)
254
+ ? `${a.i},${a.j}|${b.i},${b.j}` : `${b.i},${b.j}|${a.i},${a.j}`;
255
+ if (!streets.has(k)) streets.set(k, { a, b });
256
+ }
257
+ }
258
+
259
+ // 3b. AND THEN FLATTEN THEM INTO A CITY. Averaging the terrain under each block
260
+ // independently gives every block its own level and every street between two of them
261
+ // whatever grade the hillside happened to have — which on rolling ground is a set of
262
+ // ramps no street in the world climbs. A city on a slope is TERRACED: each junction
263
+ // sits at a height that differs from its neighbours' by no more than a street can
264
+ // climb, and the earthworks make up the difference.
265
+ //
266
+ // Same Lipschitz envelope the railway formation uses, run over the street graph
267
+ // instead of along a line: repeatedly pull the two ends of any over-steep street
268
+ // toward each other until none is left. It converges because every pass strictly
269
+ // reduces the worst violation, and it settles near the terrain because that is where
270
+ // it started.
271
+ const edges = [...streets.values()].map((s) => ({
272
+ a: s.a, b: s.b, len: Math.hypot(s.b.x - s.a.x, s.b.z - s.a.z) || 1,
273
+ }));
274
+ for (let pass = 0; pass < cfg.relax; pass++) {
275
+ let worst = 0;
276
+ for (const e of edges) {
277
+ const limit = e.len * cfg.maxStreetGrade;
278
+ const d = e.a.y - e.b.y;
279
+ const over = Math.abs(d) - limit;
280
+ if (over <= 0) continue;
281
+ worst = Math.max(worst, over);
282
+ const shift = (over / 2) * Math.sign(d);
283
+ e.a.y -= shift;
284
+ e.b.y += shift;
285
+ }
286
+ if (worst < 0.01) break;
287
+ }
288
+
289
+ // 4. JUNCTIONS: what meets here decides which piece stands here (Kiwi classified purely by
290
+ // connection count, and so does this).
291
+ for (const s of streets.values()) {
292
+ const ang = (from, to) => Math.atan2(to.x - from.x, to.z - from.z);
293
+ s.a.arms.push(ang(s.a, s.b));
294
+ s.b.arms.push(ang(s.b, s.a));
295
+ }
296
+ const junctions = [];
297
+ for (const q of corners.values()) {
298
+ if (!q.arms.length) continue;
299
+ const arms = q.arms.slice().sort((x, y) => x - y);
300
+ // TWO ARMS IS NOT ONE CASE. Kiwi branches on WHICH two (_CityJunctions.cs): two opposite
301
+ // arms are a street running straight through and get back-to-back straights, two
302
+ // PERPENDICULAR arms are a corner and get the L-piece. Mapping arm count straight to a
303
+ // name — as this did — means a corner is never produced at all, so at every turn in the
304
+ // grid the ribbons simply stop and nothing joins them round.
305
+ const opposed = arms.length === 2 && Math.cos(arms[0] - arms[1]) < -0.5;
306
+ const type = arms.length >= 4 ? 'cross'
307
+ : arms.length === 3 ? 'tee'
308
+ : arms.length === 2 ? (opposed ? 'through' : 'corner')
309
+ : arms.length === 1 ? 'end' : 'none';
310
+ const fit = fitPiece(arms, type);
311
+ q.piece = fit.piece; // remembered so the streets know where to stop
312
+ junctions.push({ x: q.x, y: q.y, z: q.z, arms, type, ...fit, size: cfg.junctionSize });
313
+ }
314
+
315
+ // 5. AND THE BLOCKS — for a grid the faces are the cells themselves, inset by the streets
316
+ // around them. Zoned by how central they are: offices downtown, houses at the edge.
317
+ const rng = lcgRng(cfg.seed * 104729 + built.size);
318
+ const half = B / 2;
319
+ // zone against the town that EXISTS: a city that had to sprawl has its downtown at the
320
+ // middle of what got built, not at the middle of what it hoped for
321
+ const spread = Math.max(B, ...[...built.values()].map((c) => Math.hypot(c.x - centre.x, c.z - centre.z)));
322
+ const blocks = [];
323
+ for (const c of built.values()) {
324
+ // A BLOCK NEVER STANDS ABOVE THE STREETS AROUND IT. Its own terrain average would undo
325
+ // the relaxation; the average of its corners still leaves it proud wherever the two
326
+ // streets it sits between are lower than the two it does not, and then its paving stands
327
+ // over the carriageway with a lip you can see from the air. The LOWEST corner is the only
328
+ // height that cannot do that: the block tucks under every street that touches it.
329
+ const own = [cornerAt(c.i, c.j), cornerAt(c.i + 1, c.j), cornerAt(c.i, c.j + 1), cornerAt(c.i + 1, c.j + 1)];
330
+ c.y = Math.min(...own.map((q) => q.y));
331
+ const central = Math.hypot(c.x - centre.x, c.z - centre.z) / spread;
332
+ const wide = central < 0.3 ? cfg.avenueWidth : cfg.streetWidth;
333
+ // A BLOCK STOPS AT THE BACK OF THE PAVEMENT, not at the kerb. Inset by the carriageway
334
+ // alone and the buildings stand in the gutter with nowhere to walk — which is what a city
335
+ // with no footways looks like, and it looks wrong before you can say why.
336
+ const in_ = half - wide / 2 - cfg.pavement;
337
+ blocks.push({
338
+ centre: { x: c.x, y: c.y, z: c.z },
339
+ polygon: [[c.x - in_, c.z - in_], [c.x + in_, c.z - in_], [c.x + in_, c.z + in_], [c.x - in_, c.z + in_]],
340
+ area: (in_ * 2) ** 2,
341
+ street: wide,
342
+ pavement: cfg.pavement,
343
+ zone: central < 0.28 ? 'downtown' : central < 0.55 ? 'commercial' : central < 0.85 ? 'residential' : 'industrial',
344
+ rank: central,
345
+ });
346
+ }
347
+ // one square with nothing on it: every town has somewhere to stand
348
+ if (blocks.length > 6) blocks[Math.floor(rng() * Math.min(4, blocks.length))].zone = 'park';
349
+
350
+ // 6. THE EARTHWORKS, ready for makeRoadTerrain: a bench under each block and a corridor
351
+ // under each street. Same machinery the inter-city roads use — a city street is a road.
352
+ // A BLOCK'S BENCH MUST STOP AT ITS OWN BOUNDARY. Made the full width of the cell it
353
+ // reaches to the middle of every street around it, so each half of a street is benched
354
+ // to a different block's level and the carriageway has a step down its centre line —
355
+ // metres of it where the town is terraced. The street belongs to its own corridor.
356
+ //
357
+ // And a fade is for the edge of town, not for the inside of it: eased over thirty metres
358
+ // every interior block would drag its neighbours' levels back toward raw hillside,
359
+ // putting the ground above the pavement it was cut for. Interior blocks ease over a few
360
+ // metres into the block next door; only the edge of town blends into the countryside.
361
+ const neighbours = (c) => [[1, 0], [-1, 0], [0, 1], [0, -1]]
362
+ .filter(([di, dj]) => built.has(key(c.i + di, c.j + dj))).length;
363
+
364
+ // HOW FAR THE GROUND NEEDS TO GET BACK DOWN. Kiwi's skirt is a fixed 3 m because Kiwi never
365
+ // fills much: it REJECTS a cell it cannot sit on. This relaxes instead, terracing the city
366
+ // so it keeps cells Kiwi would have thrown away — and where the land falls away from a
367
+ // terrace, that is an embankment several metres tall. A fixed skirt then leaves it standing
368
+ // as a cliff with the ground overhanging at the edge of town.
369
+ //
370
+ // So the skirt is the fill it has to lose, laid back at `batter`. A block sitting level with
371
+ // its own ground keeps the short skirt; one holding up six metres of fill gets the width to
372
+ // come down at a walkable slope.
373
+ const skirtFor = (c) => {
374
+ const fill = c.y - groundAt(c.x, c.z);
375
+ return Math.max(neighbours(c) === 4 ? cfg.blockFade : cfg.fade, Math.abs(fill) * cfg.batter);
376
+ };
377
+ const earthworks = {
378
+ pads: [...built.values()].map((c) => {
379
+ const b = blocks.find((q) => q.centre.x === c.x && q.centre.z === c.z);
380
+ const inset = (B - (b?.street ?? cfg.streetWidth)) / 2;
381
+ return {
382
+ x: c.x, z: c.z, y: c.y, yaw: 0,
383
+ halfX: inset, halfZ: inset, drop: cfg.bench,
384
+ fade: skirtFor(c),
385
+ };
386
+ }),
387
+ corridors: [...streets.values()].map((s) => ({
388
+ // the corridor uses the FULL span: the ground has to be right under the junction too,
389
+ // and wide enough to meet the block benches on either side with nothing left between
390
+ centreline: [[s.a.x, s.a.y, s.a.z], [s.b.x, s.b.y, s.b.z]],
391
+ // WIDER THAN THE SECTION BY MORE THAN ONE LATTICE CELL. A street is not just its
392
+ // carriageway — the ribbon carries a kerb, a footway and a verge either side — and
393
+ // clearing that by a metre or two is still not enough: the terrain is DRAWN as a
394
+ // lattice, and between two vertices that straddle the edge of the cut the mesh
395
+ // interpolates straight across, riding over the kerb even though the height function
396
+ // it was sampled from never does. Every analytic probe says clean and the picture
397
+ // still shows grass over the pavement. Clear the whole section by a good margin.
398
+ halfWidth: cfg.streetWidth / 2 + cfg.pavement + cfg.latticeClear,
399
+ drop: cfg.bench, batter: 0.6,
400
+ })),
401
+ };
402
+
403
+ // 7. WHERE A ROAD OUT OF TOWN ATTACHES. A junction on the edge of the grid has a cardinal
404
+ // arm with no street on it, pointing at open country — that is a socket, in the same
405
+ // shape the road kit uses, so an inter-city link can be built to it with no special case.
406
+ // Kiwi never did this: its inter-city window was a manual tool you typed two points into,
407
+ // so nothing ever joined a town to the road that passes it.
408
+ const CARDINAL = [[0, [0, 1]], [Math.PI / 2, [1, 0]], [Math.PI, [0, -1]], [-Math.PI / 2, [-1, 0]]];
409
+ const exits = [];
410
+ for (const j of junctions) {
411
+ if (j.arms.length >= 4) continue;
412
+ for (const [ang, dir] of CARDINAL) {
413
+ if (j.arms.some((a) => Math.cos(a - ang) > 0.99)) continue; // a street already uses it
414
+ // and it must face OUT of town, or a link leaves through the middle of the place
415
+ if ((j.x - centre.x) * dir[0] + (j.z - centre.z) * dir[1] < spread * 0.15) continue;
416
+ exits.push({
417
+ id: `exit_${exits.length}`,
418
+ pos: [j.x + dir[0] * cfg.junctionSize / 2, j.y, j.z + dir[1] * cfg.junctionSize / 2],
419
+ dir, width: cfg.streetWidth, kind: 'road',
420
+ lanes: [{ side: 'L', width: cfg.streetWidth / 2 }, { side: 'R', width: cfg.streetWidth / 2 }],
421
+ junction: j,
422
+ });
423
+ }
424
+ }
425
+
426
+ return {
427
+ centre: { x: centre.x, z: centre.z },
428
+ radius: +spread.toFixed(1),
429
+ exits,
430
+ population: cfg.population,
431
+ blocks,
432
+ junctions,
433
+ // A STREET RUNS BETWEEN THE PIECES, NOT THROUGH THEM. The junction prefab already owns
434
+ // its own twenty metres of tarmac; a ribbon drawn corner to corner would lie on top of it
435
+ // and the two would fight for the same pixels all the way across the town.
436
+ streets: [...streets.values()].map((s) => {
437
+ const dx = s.b.x - s.a.x, dz = s.b.z - s.a.z;
438
+ const L = Math.hypot(dx, dz) || 1;
439
+ // ONLY where a piece stands. A straight-through corner has no prefab on it — Kiwi laid
440
+ // two back-to-back straights there and this kit has none — so the ribbon must run
441
+ // through it unbroken or the street has a hole in the middle of the tarmac.
442
+ const cut = Math.min(cfg.junctionSize / 2, L / 2 - 1);
443
+ const ta = s.a.piece ? cut : 0, tb = s.b.piece ? cut : 0;
444
+ // MEET THE JUNCTION AT THE JUNCTION'S HEIGHT. A junction piece is a flat slab seated at
445
+ // its corner's level, so its arm ends at that level however the street beyond it
446
+ // climbs. Interpolating the height along the trim starts the ribbon ten metres in at a
447
+ // height the piece never reaches — on an 8% street that is a 0.8 m step at every arm,
448
+ // which is exactly what it looks like. The grade belongs to the span BETWEEN the
449
+ // pieces; the ends are the pieces' own.
450
+ const lerp = (k, y) => [s.a.x + (dx / L) * k, y, s.a.z + (dz / L) * k];
451
+ return {
452
+ a: lerp(ta, s.a.y), b: lerp(L - tb, s.b.y),
453
+ full: [[s.a.x, s.a.y, s.a.z], [s.b.x, s.b.y, s.b.z]],
454
+ width: cfg.streetWidth,
455
+ kind: Math.hypot((s.a.x + s.b.x) / 2 - centre.x, (s.a.z + s.b.z) / 2 - centre.z) < spread * 0.3 ? 'avenue' : 'street',
456
+ };
457
+ }),
458
+ earthworks,
459
+ // how much of what the population asked for the land actually allowed — a city squeezed
460
+ // into a valley is a fact worth reporting, not a silent shortfall
461
+ demand: want,
462
+ supply: built.size,
463
+ rejectedForSlope,
464
+ recovered,
465
+ // the steepest street the plan ended up with, so a caller can see the relaxation worked
466
+ grade: +Math.max(0, ...[...streets.values()].map((s) =>
467
+ Math.abs(s.a.y - s.b.y) / (Math.hypot(s.b.x - s.a.x, s.b.z - s.a.z) || 1))).toFixed(4),
468
+ };
469
+ }
470
+
471
+ // Where a city should stand: flat, dry, and not on top of the last one.
472
+ export function pickCitySites(groundAt, { count = 3, half = 900, step = 120, minGap = 700, ...opts } = {}) {
473
+ const cfg = { ...CITY_DEFAULTS, ...opts };
474
+ const scored = [];
475
+ for (let x = -half; x <= half; x += step) {
476
+ for (let z = -half; z <= half; z += step) {
477
+ const here = cellSlope(x, z, cfg.blockSize * 1.5, groundAt, cfg);
478
+ if (!here || here.slope > cfg.maxSlope) continue;
479
+ // HOW MUCH GOOD GROUND IS WITHIN A TOWN'S REACH, scored on a continuum rather than
480
+ // counted. A tally ties everywhere on gentle terrain and the winner is then whichever
481
+ // corner of the map the loop happened to visit first; flatness has to be a number.
482
+ let good = 0;
483
+ for (let a = 0; a < 8; a++) {
484
+ const th = (a / 8) * Math.PI * 2;
485
+ for (const r of [120, 260, 400]) {
486
+ const s = cellSlope(x + Math.cos(th) * r, z + Math.sin(th) * r, cfg.blockSize / 2, groundAt, cfg);
487
+ if (s && s.slope <= cfg.maxSlope) good += 1 - s.slope / cfg.maxSlope;
488
+ }
489
+ }
490
+ scored.push({ x, y: here.y, z, good: +good.toFixed(3), slope: +here.slope.toFixed(3) });
491
+ }
492
+ }
493
+ scored.sort((a, b) => b.good - a.good);
494
+ const out = [];
495
+ for (const s of scored) {
496
+ if (out.length >= count) break;
497
+ if (out.every((o) => Math.hypot(o.x - s.x, o.z - s.z) >= minGap)) out.push(s);
498
+ }
499
+ return out;
500
+ }
@@ -0,0 +1,120 @@
1
+ // A MAP: SEVERAL PLACES, AND THE ROADS BETWEEN THEM.
2
+ //
3
+ // `roadWorld.js` builds a road system out of junction prefabs. `cityPlan.js` builds a town.
4
+ // This is the thing they were both for — a map that reads like a real one, with towns where
5
+ // the land allows and roads joining them across the country in between:
6
+ //
7
+ // const map = await buildMapWorld({ ground, kit, placePiece });
8
+ // const heightAt = map.heightAt; // ← give THIS to buildHeightfield
9
+ // for (const c of map.cities) drawCity(c);
10
+ // for (const l of map.links) drawRoad(l);
11
+ //
12
+ // THE ORDER MATTERS, for the same reason it does in roadWorld:
13
+ // 1. choose the sites — flat, dry, far enough apart to be separate places
14
+ // 2. plan each city — each one benches its own ground
15
+ // 3. wrap the ground with the towns — so a road out of one leaves over its real level
16
+ // 4. plan and build the links — they read that ground
17
+ // 5. wrap again with the corridors — the final heightAt the world uses
18
+ // Build the links before the towns exist in the ground and every road arrives at a town by
19
+ // running up the hillside the town was cut into.
20
+ //
21
+ // Kiwi had both halves and never joined them: its inter-city window was a manual tool you
22
+ // typed two points into, so a road never started at a town. A city's `exits` — a junction on
23
+ // the edge of the grid with a cardinal arm facing open country — is what makes the join
24
+ // automatic, and it is deliberately shaped like a road-kit socket so `buildRoadLink` needs
25
+ // no special case for it.
26
+ import { planCity, pickCitySites } from './cityPlan.js';
27
+ import { makeRoadTerrain } from './roadTerrain.js';
28
+ import { planEdges } from './roadNetwork.js';
29
+ import { buildRoadLink } from './roadLink.js';
30
+
31
+ export async function buildMapWorld({
32
+ ground, // the game's natural heightAt
33
+ cities = 3, // how many places
34
+ population = 7000, // each
35
+ half = 900, // where they may go
36
+ minGap = 700, // and how far apart they must be
37
+ cityOptions = {}, // passed through to planCity
38
+ maxGrade = 0.07, // for the roads BETWEEN towns — a country road, not a street
39
+ linkStep = 3,
40
+ handleScale = 0.4,
41
+ drop = 0.12,
42
+ extra = 0.35, // how many links beyond a spanning tree, so the map has loops
43
+ } = {}) {
44
+ // 1. WHERE THE PLACES GO
45
+ const sites = pickCitySites(ground, { count: cities, half, minGap, ...cityOptions });
46
+ if (!sites.length) return { heightAt: ground, cities: [], links: [], sites: [] };
47
+
48
+ // 2. EACH PLACE, PLANNED ON THE RAW LAND. A town benches its own ground; it does not care
49
+ // what the neighbouring town did, because they are a kilometre apart.
50
+ const plans = sites.map((s) => planCity({ centre: s, groundAt: ground, population, ...cityOptions }))
51
+ .filter((c) => c.blocks.length);
52
+ if (!plans.length) return { heightAt: ground, cities: [], links: [], sites };
53
+
54
+ // 3. THE GROUND WITH THE TOWNS IN IT
55
+ const townworks = {
56
+ pads: plans.flatMap((c) => c.earthworks.pads),
57
+ corridors: plans.flatMap((c) => c.earthworks.corridors),
58
+ };
59
+ const townGround = makeRoadTerrain(ground, townworks);
60
+
61
+ // 4. WHO JOINS WHOM, and through which gate. A town has many exits; the one a given road
62
+ // uses is the one whose arm points closest to where the road is going — anything else
63
+ // leaves through the back of the town and doubles round it.
64
+ const centres = plans.map((c) => ({ x: c.centre.x, z: c.centre.z }));
65
+ const pairs = planEdges(centres, { extra });
66
+ const used = new Set();
67
+ const facing = (city, tx, tz, taken) => {
68
+ let best = null;
69
+ for (const e of city.exits) {
70
+ const k = `${city.centre.x},${city.centre.z}:${e.id}`;
71
+ if (taken.has(k)) continue;
72
+ const vx = tx - e.pos[0], vz = tz - e.pos[2];
73
+ const len = Math.hypot(vx, vz) || 1;
74
+ // point the right way FIRST, then prefer the exit nearest the target: an exit facing
75
+ // north is no use to a road going south however close to it the far town happens to be
76
+ const aim = (e.dir[0] * vx + e.dir[1] * vz) / len;
77
+ if (aim < 0.2) continue;
78
+ const score = aim * 2 - len / 4000;
79
+ if (!best || score > best.score) best = { e, k, score };
80
+ }
81
+ return best;
82
+ };
83
+
84
+ const links = [];
85
+ for (const { a, b } of pairs) {
86
+ const A = plans[a], B = plans[b];
87
+ const from = facing(A, B.centre.x, B.centre.z, used);
88
+ const to = facing(B, A.centre.x, A.centre.z, used);
89
+ if (!from || !to) continue;
90
+ used.add(from.k); used.add(to.k);
91
+ links.push({
92
+ a, b, from: from.e, to: to.e,
93
+ link: buildRoadLink({
94
+ from: from.e, to: to.e,
95
+ groundAt: (x, z) => townGround(x, z) + 0.15,
96
+ handleScale, step: linkStep, maxGrade,
97
+ }),
98
+ });
99
+ }
100
+
101
+ // 5. THE GROUND THE WORLD ACTUALLY USES
102
+ const corridors = [
103
+ ...townworks.corridors,
104
+ ...links.map(({ link, from }) => ({
105
+ centreline: link.centreline, halfWidth: from.width / 2 + 5, drop, batter: 0.5,
106
+ })),
107
+ ];
108
+
109
+ return {
110
+ heightAt: makeRoadTerrain(ground, { pads: townworks.pads, corridors }),
111
+ cities: plans,
112
+ links,
113
+ sites,
114
+ pads: townworks.pads,
115
+ corridors,
116
+ // what the map came out as, for a caller that wants to say so
117
+ summary: `${plans.length} places · ${plans.reduce((n, c) => n + c.blocks.length, 0)} blocks · `
118
+ + `${links.length} roads between them · ${links.filter((l) => l.link.feasible === false).length} over grade`,
119
+ };
120
+ }
@@ -95,7 +95,34 @@ export function auditRoadWorld(roads, { ground, samples = 24, tolerance = 0.06 }
95
95
  steep.length ? fail('every link is drivable at its grade', `${steep.length} link(s) needed more grade than allowed`)
96
96
  : pass('every link is drivable at its grade', `${links.length} links within grade`);
97
97
 
98
- // 5. JUNCTIONS SIT ON THEIR OWN GROUND. A piece seated at the wrong level is the difference
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
99
126
  // between a slip road and a diving board.
100
127
  let hanging = 0;
101
128
  for (const [, p] of placed) {