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,205 @@
1
+ // ROADS AS A WORLD-BUILD STEP.
2
+ //
3
+ // The pieces exist — a kit to read, a network to solve, links to generate, earthworks to cut.
4
+ // This is the one call that runs them in the right order and hands back everything a world
5
+ // needs, so a game does not have to know that order (and cannot get it wrong):
6
+ //
7
+ // const roads = await buildRoadWorld({ sites, kit, candidates, ground, placePiece });
8
+ // const heightAt = roads.heightAt; // ← give THIS to buildHeightfield
9
+ // scene.add(...roads.meshes); // ← and stamp these BEFORE the BVH bake
10
+ // for (const m of roads.colliders) bvhSources.push(m);
11
+ //
12
+ // THE ORDER IS THE POINT, and it is not obvious:
13
+ // 1. site the junctions on flat ground — before anything is placed
14
+ // 2. seat each piece at ITS grade level — multi-level pieces are authored high
15
+ // 3. wrap the ground with the pads — so links can follow a padded surface
16
+ // 4. solve and generate the links — they read that padded ground
17
+ // 5. wrap again with pads AND corridors — the final heightAt the world uses
18
+ // Do 4 before 3 and links follow raw terrain into the sides of junctions; do 5 before 4 and
19
+ // there are no corridors to add yet.
20
+ //
21
+ // `placePiece(name, { x, y, z, yaw })` is the game's: it loads the assembly, adds it to the
22
+ // scene and returns `{ object, sockets, deckPoints }`. The engine never loads assets.
23
+ import { makeRoadTerrain, seatHeight, pickManySites } from './roadTerrain.js';
24
+ import { solveNetwork, planLoop, loopTangents } from './roadNetwork.js';
25
+ import { buildRoadLink, buildDeck } from './roadLink.js';
26
+ import { seatY, gradeLevel } from './roadKit.js';
27
+
28
+ const footprintOf = (piece, margin = 6) => ({
29
+ halfX: (piece.bbox.max[0] - piece.bbox.min[0]) / 2 + margin,
30
+ halfZ: (piece.bbox.max[2] - piece.bbox.min[2]) / 2 + margin,
31
+ cx: (piece.bbox.max[0] + piece.bbox.min[0]) / 2,
32
+ cz: (piece.bbox.max[2] + piece.bbox.min[2]) / 2,
33
+ });
34
+
35
+ export async function buildRoadWorld({
36
+ kit, // from makeRoadKit
37
+ ground, // the game's natural heightAt
38
+ placePiece, // (name, transform) => { object, sockets, deckPoints }
39
+ sites = null, // explicit sites, or let pickManySites choose
40
+ candidates = [], // junction piece names the solver may use
41
+ count = 6, // how many places, when choosing them
42
+ half = 600, minGap = 420, // where they may go
43
+ loop = false, // a closed circuit rather than a network
44
+ maxGrade = 0.07,
45
+ fade = 90, // how far a junction pad eases back into the landscape
46
+ drop = 0.12, // ground below a deck: a hair, so they do not z-fight
47
+ linkStep = 3,
48
+ handleScale = 0.4,
49
+ } = {}) {
50
+ const pieceOf = (n) => (kit.piece ? kit.piece(n) : kit.pieces?.[n]);
51
+ const pad = Math.max(...candidates.map((n) => pieceOf(n)?.size?.[0] ?? 100)) * 0.55;
52
+
53
+ // 1. WHERE THE PLACES GO — flat enough to stand a junction on, far enough apart to be
54
+ // separate places
55
+ const chosen = sites ?? pickManySites(ground, { count, half, step: 70, radius: pad, minGap, maxGrade });
56
+ if (chosen.length < 2) return { heightAt: ground, meshes: [], colliders: [], network: null, sites: chosen };
57
+
58
+ // 2. THE NETWORK — who joins whom, and what junction each place needs
59
+ const order = loop ? planLoop(chosen) : null;
60
+ const facing = loop ? loopTangents(chosen, order) : null;
61
+ const net = loop ? null : solveNetwork({ sites: chosen, kit, candidates, kinds: ['road'] });
62
+
63
+ // 3. PLACE EACH JUNCTION, seated at its own grade level
64
+ const placed = new Map();
65
+ const pads = [];
66
+ const placeOne = async (index, name, yaw) => {
67
+ const piece = pieceOf(name);
68
+ if (!piece) return;
69
+ const st = chosen[index];
70
+ const p = await placePiece(name, { x: st.x, y: seatY(piece, st.y), z: st.z, yaw });
71
+ if (!p) return;
72
+ const foot = footprintOf(piece);
73
+ const c = Math.cos(yaw), s = Math.sin(yaw);
74
+ pads.push({
75
+ x: st.x + foot.cx * c + foot.cz * s, z: st.z - foot.cx * s + foot.cz * c,
76
+ y: st.y, yaw, halfX: foot.halfX, halfZ: foot.halfZ, drop, fade,
77
+ });
78
+ placed.set(index, { ...p, site: st, yaw, name, piece });
79
+ };
80
+ if (loop) for (const { index, yaw } of facing) await placeOne(index, candidates[0], yaw);
81
+ else for (const node of net.nodes) if (node.name) await placeOne(node.index, node.name, node.yaw);
82
+
83
+ // 4. THE LINKS — generated over ground that already knows about the pads, so a road
84
+ // approaches a junction over its own levelled apron rather than raw hillside
85
+ const padded = makeRoadTerrain(ground, { pads });
86
+ const kinds = loop ? ['motorway'] : ['road'];
87
+ const facingSocket = (p, tx, tz) => (p.sockets ?? [])
88
+ .filter((s) => kinds.includes(s.kind ?? 'road'))
89
+ .reduce((best, s) => {
90
+ const v = [tx - s.pos[0], tz - s.pos[2]];
91
+ const len = Math.hypot(...v) || 1;
92
+ const sc = (s.dir[0] * v[0] + s.dir[1] * v[1]) / len;
93
+ return !best || sc > best.sc ? { s, sc } : best;
94
+ }, null)?.s ?? null;
95
+
96
+ // IN A LOOP THE TWO ENDS ARE ASSIGNED, not chosen independently. A through-piece has
97
+ // exactly two motorway sockets and exactly two neighbours; picking the best-facing socket
98
+ // for each neighbour separately lets both pick the SAME end on a turn, and the second road
99
+ // is then dropped for want of an arm — a circuit with holes in it.
100
+ const assigned = new Map();
101
+ if (loop) {
102
+ for (let k = 0; k < order.length; k++) {
103
+ const me = placed.get(order[k]);
104
+ if (!me) continue;
105
+ const prev = placed.get(order[(k - 1 + order.length) % order.length]);
106
+ const next = placed.get(order[(k + 1) % order.length]);
107
+ const mw = (me.sockets ?? []).filter((sk) => sk.kind === 'motorway');
108
+ if (mw.length < 2 || !prev || !next) continue;
109
+ const toward = (sk, t) => {
110
+ const v = [t.site.x - sk.pos[0], t.site.z - sk.pos[2]];
111
+ const len = Math.hypot(...v) || 1;
112
+ return (sk.dir[0] * v[0] + sk.dir[1] * v[1]) / len;
113
+ };
114
+ // take whichever pairing serves both neighbours better overall
115
+ const straight = toward(mw[0], prev) + toward(mw[1], next);
116
+ const swapped = toward(mw[1], prev) + toward(mw[0], next);
117
+ assigned.set(order[k], straight >= swapped
118
+ ? { prev: mw[0], next: mw[1] }
119
+ : { prev: mw[1], next: mw[0] });
120
+ }
121
+ }
122
+
123
+ const links = [];
124
+ const pairs = loop
125
+ ? order.map((a, k) => [a, order[(k + 1) % order.length]])
126
+ : net.edges.map((e) => [e.a, e.b]);
127
+ const used = new Set();
128
+ for (const [ia, ib] of pairs) {
129
+ const A = placed.get(ia), B = placed.get(ib);
130
+ if (!A || !B) continue;
131
+ const from = assigned.get(ia)?.next ?? facingSocket(A, B.site.x, B.site.z);
132
+ const to = assigned.get(ib)?.prev ?? facingSocket(B, A.site.x, A.site.z);
133
+ if (!from || !to) continue;
134
+ const ka = `${ia}:${from.id}`, kb = `${ib}:${to.id}`;
135
+ if (used.has(ka) || used.has(kb)) continue; // one road per arm
136
+ used.add(ka); used.add(kb);
137
+ links.push({
138
+ a: ia, b: ib, from, to,
139
+ link: buildRoadLink({
140
+ from, to, groundAt: (x, z) => padded(x, z) + 0.15,
141
+ handleScale, step: linkStep, maxGrade,
142
+ }),
143
+ });
144
+ }
145
+
146
+ // 5. THE EARTHWORKS the world actually uses: pads AND the corridors those links carry,
147
+ // plus a trench for anything a piece keeps below its own grade
148
+ const corridors = links.map(({ link, from }) => ({
149
+ centreline: link.centreline, halfWidth: from.width / 2 + 5, drop, batter: 0.5,
150
+ }));
151
+ for (const [, p] of placed) {
152
+ const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
153
+ if (mw.length < 2 || !p.deckPoints?.length) continue;
154
+ const a = mw[0].pos, b = mw[1].pos;
155
+ const ax = [b[0] - a[0], b[2] - a[2]];
156
+ const al = Math.hypot(...ax) || 1;
157
+ ax[0] /= al; ax[1] /= al;
158
+ const perp = [-ax[1], ax[0]];
159
+ const grade = p.site.y;
160
+ let wide = 0, lowest = Infinity;
161
+ for (const q of p.deckPoints) {
162
+ if (q[1] > grade - 1.5) continue;
163
+ const lat = Math.abs((q[0] - p.site.x) * perp[0] + (q[2] - p.site.z) * perp[1]);
164
+ if (lat > wide) wide = lat;
165
+ if (q[1] < lowest) lowest = q[1];
166
+ }
167
+ if (!(wide > 0)) continue;
168
+ // KEEP THE TRENCH TIGHT TO THE CARRIAGEWAY. Sizing it from everything below grade lets
169
+ // the slip roads — which sweep far out to either side as they climb — widen the cut until
170
+ // it swallows the whole junction, dropping the entire pad to motorway level and leaving
171
+ // every piece standing 10 m above its own ground.
172
+ const bed = Math.min(wide, Math.max(mw[0].width, mw[1].width) / 2 + 18) + 5;
173
+ corridors.push({
174
+ centreline: [
175
+ [a[0] - ax[0] * 30, Math.min(a[1], lowest), a[2] - ax[1] * 30],
176
+ [b[0] + ax[0] * 30, Math.min(b[1], lowest), b[2] + ax[1] * 30],
177
+ ],
178
+ halfWidth: bed, drop, batter: 4,
179
+ // EARTHWORK ONLY. The tarmac through here belongs to the junction prefab; this just
180
+ // opens the ground under it, and runs 30 m past each socket so the batter has room.
181
+ // Called a deck it would hide the terrain over that overshoot — a hole past the end
182
+ // of the motorway, which is exactly how it was reported.
183
+ deck: false,
184
+ });
185
+ }
186
+
187
+ return {
188
+ heightAt: makeRoadTerrain(ground, { pads, corridors }),
189
+ links,
190
+ pads,
191
+ corridors,
192
+ placed,
193
+ sites: chosen,
194
+ network: net,
195
+ order,
196
+ // the median strip through each junction: its two carriageways are separate meshes, and
197
+ // the ground would otherwise be visible dropping away between them
198
+ medianDecks: [...placed.values()].flatMap((p) => {
199
+ const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
200
+ if (mw.length < 2) return [];
201
+ const inner = (mw[0].section?.lines ?? []).map((b) => Math.abs(b.at)).sort((x, y) => x - y)[0] ?? 1.1;
202
+ return [buildDeck({ from: mw[0].pos, to: mw[1].pos, width: inner * 2 + 0.6 })];
203
+ }),
204
+ };
205
+ }
@@ -0,0 +1,254 @@
1
+ // SETTLEMENTS — buildings along the roads that already exist.
2
+ //
3
+ // A town is not a scatter of houses; it is frontage. Buildings stand back from a kerb by a
4
+ // consistent distance, face the road, sit shoulder to shoulder without overlapping, and stop
5
+ // short of a junction so they do not block sightlines or stand in the carriageway. Give this
6
+ // the roads and a catalogue of what you own, and it returns where each building goes.
7
+ //
8
+ // const plots = planFrontage({ links, catalogue, groundAt });
9
+ // for (const p of plots) place(p.model, p); // { model, x, y, z, yaw }
10
+ //
11
+ // It places nothing and loads nothing: a game owns its assets, and a designer may take this
12
+ // list, edit it, and save it as a layout.
13
+ import { lcgRng } from './permanentWay.js';
14
+
15
+ const EPS = 0.01;
16
+
17
+ export const FRONTAGE_DEFAULTS = {
18
+ setback: 6, // metres from the kerb to the front wall
19
+ gap: 2.5, // between neighbours
20
+ junctionClear: 45, // no frontage within this of a link's end
21
+ depthClear: 4, // keep this much behind a building free of the next road
22
+ maxSlope: 0.18, // a plot steeper than this is not built on
23
+ maxBuilding: 60, // the widest thing that may stand on a frontage
24
+ seed: 1,
25
+ };
26
+
27
+ // Every plot along one side of one road. Walks the centreline, and for each step chooses a
28
+ // building from the catalogue that fits the gap it has left.
29
+ function frontageOf(link, side, catalogue, cfg, groundAt, taken) {
30
+ const line = link.centreline;
31
+ if (line.length < 4) return [];
32
+ const half = link.width / 2;
33
+ const plots = [];
34
+
35
+ // arc positions along the line, so buildings are spaced by distance rather than by index
36
+ const seg = [];
37
+ let total = 0;
38
+ for (let i = 1; i < line.length; i++) {
39
+ const d = Math.hypot(line[i][0] - line[i - 1][0], line[i][2] - line[i - 1][2]);
40
+ seg.push({ i, s: total, d });
41
+ total += d;
42
+ }
43
+ const at = (s) => {
44
+ let k = 0;
45
+ while (k < seg.length - 1 && seg[k].s + seg[k].d < s) k++;
46
+ const a = line[seg[k].i - 1], b = line[seg[k].i];
47
+ const t = Math.min(1, Math.max(0, (s - seg[k].s) / (seg[k].d || 1)));
48
+ const p = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
49
+ const fx = b[0] - a[0], fz = b[2] - a[2];
50
+ const L = Math.hypot(fx, fz) || 1;
51
+ return { p, fwd: [fx / L, fz / L], right: [-fz / L, fx / L] };
52
+ };
53
+
54
+ const rng = lcgRng(cfg.seed * 7919 + (side > 0 ? 13 : 31) + Math.round(total));
55
+ const models = Object.entries(catalogue);
56
+ if (!models.length) return [];
57
+
58
+ let s = cfg.junctionClear;
59
+ const end = total - cfg.junctionClear;
60
+ let guard = 0;
61
+ while (s < end && guard++ < 500) {
62
+ const { p, fwd, right } = at(s);
63
+ // pick something that fits the road it faces: nothing enormous on a country lane
64
+ const room = end - s;
65
+ const fits = models.filter(([, m]) => Math.max(m.size[0], m.size[2]) <= Math.min(room, 26));
66
+ if (!fits.length) break;
67
+ const [model, m] = fits[Math.floor(rng() * fits.length)];
68
+ const width = Math.max(m.size[0], m.size[2]);
69
+ const depth = Math.min(m.size[0], m.size[2]);
70
+
71
+ // stand it back from the kerb, centred on its own frontage
72
+ const off = side * (half + cfg.setback + depth / 2);
73
+ const cx = p[0] + right[0] * off + fwd[0] * (width / 2);
74
+ const cz = p[2] + right[1] * off + fwd[1] * (width / 2);
75
+
76
+ // refuse a plot the ground will not take, and one already spoken for
77
+ const g = groundAt(cx, cz);
78
+ const corner = groundAt(cx + right[0] * depth / 2, cz + right[1] * depth / 2);
79
+ const slope = Math.abs(corner - g) / Math.max(depth / 2, 1);
80
+ const clash = taken.some((t) => Math.hypot(t.x - cx, t.z - cz) < (t.r + width / 2) * 0.85);
81
+ if (slope <= cfg.maxSlope && !clash) {
82
+ // face the road: the building's back is to the outside, its front to the kerb
83
+ const yaw = Math.atan2(-right[0] * side, -right[1] * side);
84
+ plots.push({ model, x: +cx.toFixed(2), y: +g.toFixed(2), z: +cz.toFixed(2), yaw: +yaw.toFixed(4), width, depth });
85
+ taken.push({ x: cx, z: cz, r: width / 2 });
86
+ s += width + cfg.gap;
87
+ } else {
88
+ s += 8; // step past a plot that will not do
89
+ }
90
+ }
91
+ return plots;
92
+ }
93
+
94
+ // WHICH ROADS HAVE FRONTAGE AT ALL. Houses face a street, a lane, a high road through a
95
+ // village. NOTHING fronts a motorway or a dual carriageway — no door opens onto one anywhere
96
+ // in the world, because you cannot stop, turn across, or walk out of it. A planner handed
97
+ // every road in the network will happily line a motorway with offices, and it looks exactly
98
+ // as wrong as it is.
99
+ //
100
+ // Judged on what the road IS, in this order: what the kit called the socket, then how many
101
+ // lanes it carries, then how wide it is — so a road that arrives with no classification at
102
+ // all is still caught by its size.
103
+ export const FRONTAGE_KINDS = new Set(['road', 'street', 'lane', undefined, null]);
104
+
105
+ export function hasFrontage(entry, maxWidth = 16) {
106
+ const kind = entry?.from?.kind ?? entry?.kind ?? entry?.link?.kind;
107
+ if (kind && !FRONTAGE_KINDS.has(kind)) return false;
108
+ const lanes = entry?.from?.lanes?.length ?? 0;
109
+ if (lanes > 2) return false; // more than one each way is a through route
110
+ const width = entry?.from?.width ?? entry?.width ?? entry?.link?.width ?? 10;
111
+ return width <= maxWidth;
112
+ }
113
+
114
+ export function planFrontage({ links = [], catalogue = {}, groundAt = () => 0, ...opts } = {}) {
115
+ const cfg = { ...FRONTAGE_DEFAULTS, ...opts };
116
+ const taken = [];
117
+ const plots = [];
118
+ let refused = 0;
119
+ for (const entry of links) {
120
+ if (!hasFrontage(entry, cfg.maxFrontageWidth ?? 16)) { refused++; continue; }
121
+ // accept either a roadWorld link or a plain { centreline, width }
122
+ const link = entry.link ?? entry;
123
+ const width = entry.from?.width ?? entry.width ?? link.width ?? 10;
124
+ for (const side of [-1, 1]) {
125
+ plots.push(...frontageOf({ centreline: link.centreline, width }, side, catalogue, cfg, groundAt, taken));
126
+ }
127
+ }
128
+ plots.refused = refused; // say so rather than silently building none
129
+ return plots;
130
+ }
131
+
132
+ // WHAT BELONGS WHERE. A tower block on a country lane and a barn in the financial district
133
+ // are the same mistake. Kiwi zoned its blocks and stopped; this is what the zone is FOR.
134
+ // Names are the only signal a pack gives us, so read them, and fall back on height — a thing
135
+ // thirty metres tall is downtown wherever it was filed.
136
+ export const ZONE_RULES = {
137
+ downtown: /office|tower|skyscraper|bank|hall|corporate/i,
138
+ commercial: /shop|store|market|mall|retail|cafe|restaurant|hotel|station|cinema/i,
139
+ residential: /house|home|apartment|flat|residential|villa|cottage|terrace/i,
140
+ industrial: /factory|warehouse|depot|industrial|workshop|garage|silo/i,
141
+ };
142
+
143
+ export function zoneOf(model, entry) {
144
+ for (const [zone, re] of Object.entries(ZONE_RULES)) if (re.test(model)) return zone;
145
+ const h = entry?.size?.[1] ?? 0;
146
+ return h > 24 ? 'downtown' : h > 14 ? 'commercial' : 'residential';
147
+ }
148
+
149
+ // The models a zone will accept, widest choice first. A zone with nothing of its own takes
150
+ // whatever fits rather than leaving the block bare — an empty plot reads as a bug.
151
+ function catalogueFor(catalogue, zone, maxWidth) {
152
+ const all = Object.entries(catalogue).filter(([, m]) => Math.max(m.size[0], m.size[2]) <= maxWidth);
153
+ if (zone === 'park') return [];
154
+ const mine = all.filter(([n, m]) => zoneOf(n, m) === zone);
155
+ return mine.length >= 3 ? mine : all;
156
+ }
157
+
158
+ // BUILDINGS FILL A BLOCK FROM ITS EDGES INWARD. They stand along the block's boundary with
159
+ // their fronts to the street and their backs to the middle, which is how a city block is
160
+ // built and why its interior is yards and car parks rather than more buildings.
161
+ export function planBlockFrontage({ blocks = [], catalogue = {}, groundAt = () => 0, ...opts } = {}) {
162
+ const cfg = { ...FRONTAGE_DEFAULTS, ...opts };
163
+ const plots = [];
164
+ const taken = [];
165
+ const used = new Map(); // model -> how many are up, so variety spreads itself
166
+ let b = 0;
167
+ for (const block of blocks) {
168
+ const poly = block.polygon;
169
+ const rng = lcgRng(cfg.seed * 7919 + (b++) * 131);
170
+ const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length;
171
+ const cz = poly.reduce((a, p) => a + p[1], 0) / poly.length;
172
+ for (let e = 0; e < poly.length; e++) {
173
+ const A = poly[e], B = poly[(e + 1) % poly.length];
174
+ const ex = B[0] - A[0], ez = B[1] - A[1];
175
+ const len = Math.hypot(ex, ez);
176
+ if (len < 8) continue;
177
+ const fx = ex / len, fz = ez / len;
178
+ // outward: away from the block's middle, i.e. toward the street this edge faces
179
+ let nx = -fz, nz = fx;
180
+ if ((A[0] + fx * len / 2 - cx) * nx + (A[1] + fz * len / 2 - cz) * nz < 0) { nx = -nx; nz = -nz; }
181
+ // NOTHING SO WIDE IT EATS A WHOLE FRONTAGE, and nothing so deep it meets the building
182
+ // backing onto it from the far side. The ceiling belongs to the pack, not to this file:
183
+ // a kit whose smallest building is thirty metres across needs bigger blocks, and a cap
184
+ // of twenty-six would silently place none of them.
185
+ const edgeCap = Math.min(len * 0.55, cfg.maxBuilding ?? 60, (block.depth ?? Infinity));
186
+
187
+ // leave the corners clear so two frontages do not meet at right angles inside a wall
188
+ let s = cfg.gap;
189
+ const end = len - cfg.gap;
190
+ let guard = 0;
191
+ while (s < end && guard++ < 80) {
192
+ const room = end - s;
193
+ // PICK SOMETHING THAT FILLS THE RUN, WITHOUT BUILDING THE SAME STREET TWICE.
194
+ // Choosing at random from everything that fits leaves a block mostly empty, because a
195
+ // small building drawn early strands a gap nothing else wants. Choosing only from the
196
+ // widest few — as this did — fills the run and turns a city into four repeated towers,
197
+ // since the widest few are the same four every time.
198
+ //
199
+ // So weight by width rather than truncating by it — and DECAY THE WEIGHT AS A TYPE
200
+ // GETS USED, or the widest still wins nearly every roll and a sixteen-building pack
201
+ // reads as five. Dividing by how often a model has already gone up makes the
202
+ // distribution spread itself: the first roll favours the big ones, and by the tenth
203
+ // the ones that have not been built yet are the likeliest.
204
+ const fits = catalogueFor(catalogue, block.zone, Math.min(room, edgeCap));
205
+ if (!fits.length) break;
206
+ const weights = fits.map(([n, m]) =>
207
+ Math.max(m.size[0], m.size[2]) / (1 + (used.get(n) ?? 0) * 2));
208
+ const total = weights.reduce((a, b) => a + b, 0);
209
+ let pick = rng() * total, k = 0;
210
+ while (k < weights.length - 1 && (pick -= weights[k]) > 0) k++;
211
+ const [model, m] = fits[k];
212
+ const width = Math.max(m.size[0], m.size[2]);
213
+ const depth = Math.min(m.size[0], m.size[2]);
214
+ // FRONT WALL ON THE BLOCK EDGE. Set back by half its own depth and no more: a
215
+ // building pushed further in leaves a bald strip along every street and, with a
216
+ // deep enough model, ends up standing in the middle of the block instead of on it.
217
+ const px = A[0] + fx * (s + width / 2) - nx * (depth / 2);
218
+ const pz = A[1] + fz * (s + width / 2) - nz * (depth / 2);
219
+
220
+ // AND CLASH ON THE FOOTPRINT, NOT ON A CIRCLE ROUND IT. A circle big enough to hold
221
+ // a 22 m tower keeps the next building 20 m away in every direction, so a block
222
+ // fits three of them and looks abandoned. The blocks are axis-aligned and so are
223
+ // their frontages, so the real footprints compare directly.
224
+ const along = Math.abs(fx) > Math.abs(fz);
225
+ const hw = (along ? width : depth) / 2, hd = (along ? depth : width) / 2;
226
+ const box = { x0: px - hw, x1: px + hw, z0: pz - hd, z1: pz + hd };
227
+ const clash = taken.some((t) =>
228
+ box.x0 < t.x1 - EPS && box.x1 > t.x0 + EPS && box.z0 < t.z1 - EPS && box.z1 > t.z0 + EPS);
229
+ if (!clash) {
230
+ used.set(model, (used.get(model) ?? 0) + 1);
231
+ plots.push({
232
+ model, x: +px.toFixed(2), y: +groundAt(px, pz).toFixed(2), z: +pz.toFixed(2),
233
+ yaw: +Math.atan2(nx, nz).toFixed(4), width, depth, zone: block.zone,
234
+ });
235
+ taken.push({ x0: box.x0 - cfg.gap / 2, x1: box.x1 + cfg.gap / 2, z0: box.z0 - cfg.gap / 2, z1: box.z1 + cfg.gap / 2 });
236
+ s += width + cfg.gap;
237
+ } else {
238
+ s += 4;
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return plots;
244
+ }
245
+
246
+ // A town is frontage plus a name and a middle. Handy for a map, and for deciding which
247
+ // buildings belong where — a bank downtown, a barn on a lane.
248
+ export function summariseSettlement(plots) {
249
+ if (!plots.length) return null;
250
+ const x = plots.reduce((a, p) => a + p.x, 0) / plots.length;
251
+ const z = plots.reduce((a, p) => a + p.z, 0) / plots.length;
252
+ const spread = Math.max(...plots.map((p) => Math.hypot(p.x - x, p.z - z)));
253
+ return { x: +x.toFixed(1), z: +z.toFixed(1), radius: +spread.toFixed(1), buildings: plots.length };
254
+ }
@@ -22,6 +22,10 @@
22
22
 
23
23
  // Footprint half-extents (x, z) and bbox apex, measured off the GLBs (probe: gltf-transform bbox).
24
24
  // The apex rides above the −0.08 m seat placementField sinks every pile by.
25
+ // THE MOUNDS THIS ENGINE HAPPENS TO KNOW. Five Dustwater dust piles, measured off their GLBs.
26
+ // A game with its own mounds — sandbags, rubble, a spoil heap — is not doing anything wrong by
27
+ // having them, so `resHump()` must not crash the world build over one. Games add their own with
28
+ // setHumpDims(); anything still unknown is placed as ordinary scenery and said so once.
25
29
  const DIMS = {
26
30
  SM_Env_DustPile_Large_01: [5.66, 5.66, 0.82],
27
31
  SM_Env_DustPile_Small_02: [1.35, 1.41, 0.82],
@@ -34,9 +38,26 @@ const CELL = 16, HALF = 512;
34
38
  const key = (cx, cz) => (cx + HALF) * 4096 + (cz + HALF);
35
39
  const _grid = new Map(); // int key → flat rows [x, z, cos, sin, 1/rx, 1/rz, apex] × n
36
40
 
41
+ // A game declares the footprint of its own mounds: { file: [radiusX, radiusZ, height] } in
42
+ // metres at scale 1, measured off the GLB. Merged over the built-in table, never replacing it.
43
+ export function setHumpDims(dims = {}) { Object.assign(DIMS, dims); }
44
+
45
+ const UNKNOWN = new Set();
46
+
37
47
  export function registerHumps(file, placements) {
38
48
  const dims = DIMS[file];
39
- if (!dims) throw new Error(`[walkHumps] no dome dims for ${file} — measure the GLB and add it to DIMS`);
49
+ if (!dims) {
50
+ // NOT AN ERROR. Throwing from a registration API means a game whose props differ from
51
+ // western's cannot build its world at all; the honest outcome is that this prop is scenery
52
+ // you walk past rather than a mound you walk over.
53
+ if (!UNKNOWN.has(file)) {
54
+ UNKNOWN.add(file);
55
+ console.warn(`[walkHumps] no dome dimensions for ${file} — placed as flat scenery. `
56
+ + 'Measure the GLB and register it with setHumpDims({ [file]: [rx, rz, height] }) '
57
+ + 'to make it walkable-over.');
58
+ }
59
+ return;
60
+ }
40
61
  for (const p of placements) {
41
62
  const rx = dims[0] * p.s, rz = dims[1] * p.s, h = dims[2] * p.s - SEAT;
42
63
  if (h <= 0.05) continue; // a 5 cm mound is texture, not terrain
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // MODEL CATALOGUE — measure a pack's GLBs so a placer knows what it is putting down.
3
+ //
4
+ // node tools/modelCatalogue.mjs <packDir> [filterRegex]
5
+ //
6
+ // Writes <packDir>/catalogue.json: for each model, its size and where its origin sits within
7
+ // it. Placing a building without knowing its footprint means either guessing (and overlapping
8
+ // its neighbour) or measuring it at load in every game that uses it.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const [packDir, filter = '.', exclude = null] = process.argv.slice(2);
13
+ if (!packDir) { console.error('usage: modelCatalogue.mjs <packDir> [filterRegex] [excludeRegex]'); process.exit(1); }
14
+ const re = new RegExp(filter, 'i');
15
+ // Synty ships COMPONENTS as well as whole things — a roof, a floor slab, a length of pipe.
16
+ // A placer handed those puts a chimney on a grass verge, so a catalogue meant for placement
17
+ // needs to exclude them (the assembled versions live in the pack's prefabs).
18
+ const ex = exclude ? new RegExp(exclude, 'i') : null;
19
+ const modelsDir = path.join(packDir, 'models');
20
+
21
+ function bounds(file) {
22
+ const buf = fs.readFileSync(path.join(modelsDir, file));
23
+ const jsonLen = buf.readUInt32LE(12);
24
+ const gltf = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
25
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
26
+ for (const mesh of gltf.meshes ?? []) {
27
+ for (const prim of mesh.primitives ?? []) {
28
+ const acc = gltf.accessors[prim.attributes.POSITION];
29
+ if (!acc?.min) continue;
30
+ for (let d = 0; d < 3; d++) {
31
+ if (acc.min[d] < min[d]) min[d] = acc.min[d];
32
+ if (acc.max[d] > max[d]) max[d] = acc.max[d];
33
+ }
34
+ }
35
+ }
36
+ return Number.isFinite(min[0]) ? { min, max } : null;
37
+ }
38
+
39
+ const out = {};
40
+ let n = 0;
41
+ for (const f of fs.readdirSync(modelsDir)) {
42
+ if (!f.endsWith('.glb') || !re.test(f) || (ex && ex.test(f))) continue;
43
+ const b = bounds(f);
44
+ if (!b) continue;
45
+ // and nothing that is obviously not a building in its own right
46
+ const sz = [0, 1, 2].map((d) => b.max[d] - b.min[d]);
47
+ if (sz[1] < 5 || Math.max(sz[0], sz[2]) < 5) continue;
48
+ out[f] = {
49
+ size: [0, 1, 2].map((d) => +(b.max[d] - b.min[d]).toFixed(2)),
50
+ // where the model's own origin sits in its footprint — a building modelled around its
51
+ // centre and one modelled from a corner need different treatment to stand on a plot
52
+ centre: [0, 1, 2].map((d) => +((b.max[d] + b.min[d]) / 2).toFixed(2)),
53
+ base: +b.min[1].toFixed(2),
54
+ };
55
+ n++;
56
+ }
57
+ fs.writeFileSync(path.join(packDir, 'catalogue.json'), JSON.stringify(out, null, 0));
58
+ const sizes = Object.values(out).map((v) => Math.max(v.size[0], v.size[2]));
59
+ console.log(`${n} models catalogued · footprints ${Math.min(...sizes).toFixed(1)}–${Math.max(...sizes).toFixed(1)} m`);
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // UNITYPACKAGE EXTRACT — pull NAMED assets out of a .unitypackage.
3
+ //
4
+ // The dev-server importer takes a whole pack; this is for when you want a handful of things
5
+ // out of a 90 MB archive (the cars from POLYGON City, say) without staging the other 2,000
6
+ // assets. A .unitypackage is a gzip tar of GUID directories, each holding `pathname`,
7
+ // `asset` and `asset.meta`; this rebuilds the real filenames for whatever matches.
8
+ //
9
+ // node tools/unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]
10
+ //
11
+ // The .meta travels with each asset because Unity keeps the FBX import scale there, and
12
+ // Synty packs mix metre- and centimetre-authored models (see tools/fbxToGlb.mjs).
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { execFileSync } from 'node:child_process';
17
+
18
+ const [pkg, outDir, pattern, ...rest] = process.argv.slice(2);
19
+ if (!pkg || !outDir || !pattern) {
20
+ console.error('usage: unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]');
21
+ process.exit(1);
22
+ }
23
+ const listOnly = rest.includes('--list');
24
+ const re = new RegExp(pattern, 'i');
25
+
26
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'unitypkg-'));
27
+ try {
28
+ execFileSync('tar', ['xzf', pkg, '-C', work], { stdio: ['ignore', 'ignore', 'pipe'] });
29
+ const hits = [];
30
+ for (const dir of fs.readdirSync(work)) {
31
+ const base = path.join(work, dir);
32
+ const pnFile = path.join(base, 'pathname');
33
+ if (!fs.existsSync(pnFile)) continue;
34
+ const assetPath = fs.readFileSync(pnFile, 'utf8').split('\n')[0].trim();
35
+ if (!re.test(assetPath)) continue;
36
+ if (!fs.existsSync(path.join(base, 'asset'))) continue; // a folder entry, not a file
37
+ hits.push({ dir: base, assetPath });
38
+ }
39
+ hits.sort((a, b) => a.assetPath.localeCompare(b.assetPath));
40
+ if (listOnly) {
41
+ for (const h of hits) console.log(h.assetPath);
42
+ console.log(`\n${hits.length} matching assets`);
43
+ } else {
44
+ fs.mkdirSync(outDir, { recursive: true });
45
+ for (const h of hits) {
46
+ const name = path.basename(h.assetPath);
47
+ fs.copyFileSync(path.join(h.dir, 'asset'), path.join(outDir, name));
48
+ const meta = path.join(h.dir, 'asset.meta');
49
+ if (fs.existsSync(meta)) fs.copyFileSync(meta, path.join(outDir, `${name}.meta`));
50
+ console.log(`${name} ← ${h.assetPath}`);
51
+ }
52
+ console.log(`\n${hits.length} assets extracted to ${outDir}`);
53
+ }
54
+ } finally {
55
+ fs.rmSync(work, { recursive: true, force: true });
56
+ }