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,273 @@
1
+ #!/usr/bin/env node
2
+ // UNITY SCENE → BUILDINGS. Mine a pack's own demo scene for the things its artists built.
3
+ //
4
+ // node tools/unitySceneToBuildings.mjs <Scene.unity> <metaDir> <outDir> [--prefix SM_Bld]
5
+ //
6
+ // A Synty building is not a model, it is a STACK: a floor module, corner pieces, a door
7
+ // module, more floors above, a roof, roof corners, a fire escape up the back. Place one
8
+ // module on its own and you get a building with half its walls missing, which is exactly
9
+ // what happens if you read the pack as a list of meshes. The pack ships no assembled
10
+ // buildings — but its demo scene is full of them, built by the people who made the parts.
11
+ //
12
+ // So this reads the scene, composes every instance's transform up its parent chain, groups
13
+ // the building modules into the structures they form, and writes each one out as an assembly
14
+ // in the same shape as the road pieces: a list of parts with local transforms.
15
+ //
16
+ // THE PARENT CHAIN IS THE WHOLE JOB. A scene is a flat list of PrefabInstances, each with a
17
+ // LOCAL transform and an `m_TransformParent` pointing at a stripped Transform, which in turn
18
+ // names the PrefabInstance that owns it. Read the local positions alone — as a first pass at
19
+ // this did — and a whole tower's worth of modules land on the same square metre.
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ const args = process.argv.slice(2);
24
+ const [scene, metaDir, outDir] = args.filter((a) => !a.startsWith('--'));
25
+ const prefix = (args.find((a) => a.startsWith('--prefix')) ?? '--prefix=SM_Bld').split('=')[1] ?? 'SM_Bld';
26
+ const modelsDir = (args.find((a) => a.startsWith('--models')) ?? '--models=').split('=')[1];
27
+ const touch = +((args.find((a) => a.startsWith('--touch')) ?? '--touch=0.6').split('=')[1]);
28
+ if (!scene || !metaDir || !outDir || !modelsDir) {
29
+ console.error('usage: unitySceneToBuildings.mjs <Scene.unity> <metaDir> <outDir> --models=<glbDir> [--prefix=SM_Bld] [--touch=0.6]');
30
+ process.exit(1);
31
+ }
32
+
33
+ // ── guid → prefab name, from the .meta files that travel with each asset ──
34
+ const byGuid = new Map();
35
+ for (const f of fs.readdirSync(metaDir)) {
36
+ if (!f.endsWith('.prefab.meta')) continue;
37
+ const g = /^guid: (\w+)/m.exec(fs.readFileSync(path.join(metaDir, f), 'utf8'));
38
+ if (g) byGuid.set(g[1], f.slice(0, -12));
39
+ }
40
+
41
+ // ── the scene ──
42
+ const text = fs.readFileSync(scene, 'utf8');
43
+ const docs = text.split(/^--- !u!(\d+) &(\d+)( stripped)?$/m);
44
+ const instances = new Map(); // fileID → { name, parent, pos, quat, scale }
45
+ const transformOwner = new Map(); // stripped Transform fileID → PrefabInstance fileID
46
+
47
+ for (let i = 1; i < docs.length; i += 4) {
48
+ const cls = docs[i], id = docs[i + 1], body = docs[i + 3];
49
+ if (cls === '4' && docs[i + 2]) {
50
+ const owner = /m_PrefabInstance: \{fileID: (\d+)\}/.exec(body);
51
+ if (owner) transformOwner.set(id, owner[1]);
52
+ continue;
53
+ }
54
+ if (cls !== '1001') continue;
55
+ const guid = /m_SourcePrefab: \{fileID: \d+, guid: (\w+)/.exec(body);
56
+ const name = guid ? byGuid.get(guid[1]) : null;
57
+ const parent = /m_TransformParent: \{fileID: (\d+)\}/.exec(body);
58
+ const num = (p) => {
59
+ const m = new RegExp(`propertyPath: ${p}\\n\\s+value: ([-\\d.eE+]+)`).exec(body);
60
+ return m ? +m[1] : null;
61
+ };
62
+ instances.set(id, {
63
+ id, name,
64
+ parent: parent && parent[1] !== '0' ? parent[1] : null,
65
+ pos: [num('m_LocalPosition\\.x') ?? 0, num('m_LocalPosition\\.y') ?? 0, num('m_LocalPosition\\.z') ?? 0],
66
+ quat: [num('m_LocalRotation\\.x') ?? 0, num('m_LocalRotation\\.y') ?? 0, num('m_LocalRotation\\.z') ?? 0, num('m_LocalRotation\\.w') ?? 1],
67
+ scale: [num('m_LocalScale\\.x') ?? 1, num('m_LocalScale\\.y') ?? 1, num('m_LocalScale\\.z') ?? 1],
68
+ });
69
+ }
70
+
71
+ // ── compose each instance's transform up its chain ──
72
+ const qmul = (a, b) => [
73
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
74
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
75
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
76
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
77
+ ];
78
+ const qrot = (q, v) => {
79
+ const [x, y, z, w] = q, [vx, vy, vz] = v;
80
+ const ix = w * vx + y * vz - z * vy, iy = w * vy + z * vx - x * vz;
81
+ const iz = w * vz + x * vy - y * vx, iw = -x * vx - y * vy - z * vz;
82
+ return [
83
+ ix * w + iw * -x + iy * -z - iz * -y,
84
+ iy * w + iw * -y + iz * -x - ix * -z,
85
+ iz * w + iw * -z + ix * -y - iy * -x,
86
+ ];
87
+ };
88
+
89
+ const world = new Map();
90
+ const resolve = (id, seen = new Set()) => {
91
+ if (world.has(id)) return world.get(id);
92
+ const it = instances.get(id);
93
+ if (!it || seen.has(id)) return null;
94
+ seen.add(id);
95
+ const parentInst = it.parent ? transformOwner.get(it.parent) : null;
96
+ const p = parentInst ? resolve(parentInst, seen) : null;
97
+ const out = p
98
+ ? {
99
+ pos: (() => {
100
+ const s = [it.pos[0] * p.scale[0], it.pos[1] * p.scale[1], it.pos[2] * p.scale[2]];
101
+ const r = qrot(p.quat, s);
102
+ return [p.pos[0] + r[0], p.pos[1] + r[1], p.pos[2] + r[2]];
103
+ })(),
104
+ quat: qmul(p.quat, it.quat),
105
+ scale: [it.scale[0] * p.scale[0], it.scale[1] * p.scale[1], it.scale[2] * p.scale[2]],
106
+ }
107
+ : { pos: it.pos, quat: it.quat, scale: it.scale };
108
+ world.set(id, out);
109
+ return out;
110
+ };
111
+
112
+ const parts = [];
113
+ for (const [id, it] of instances) {
114
+ if (!it.name?.startsWith(prefix)) continue;
115
+ const w = resolve(id);
116
+ if (w) parts.push({ name: it.name, ...w });
117
+ }
118
+
119
+ // ── what each module actually occupies ──
120
+ // The grouping below needs real extents, so measure the baked GLB and put its eight corners
121
+ // through the module's own transform.
122
+ const bboxCache = new Map();
123
+ function localBox(file) {
124
+ if (bboxCache.has(file)) return bboxCache.get(file);
125
+ let out = null;
126
+ try {
127
+ const buf = fs.readFileSync(path.join(modelsDir, file));
128
+ const gltf = JSON.parse(buf.slice(20, 20 + buf.readUInt32LE(12)).toString('utf8'));
129
+ const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
130
+ for (const mesh of gltf.meshes ?? []) for (const prim of mesh.primitives ?? []) {
131
+ const acc = gltf.accessors[prim.attributes.POSITION];
132
+ if (!acc?.min) continue;
133
+ for (let d = 0; d < 3; d++) { mn[d] = Math.min(mn[d], acc.min[d]); mx[d] = Math.max(mx[d], acc.max[d]); }
134
+ }
135
+ if (Number.isFinite(mn[0])) out = { mn, mx };
136
+ } catch { /* a module we never baked has no box */ }
137
+ bboxCache.set(file, out);
138
+ return out;
139
+ }
140
+ function worldBox(p) {
141
+ const b = localBox(`${p.name}.glb`);
142
+ if (!b) return null;
143
+ const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
144
+ for (const cx of [b.mn[0], b.mx[0]]) for (const cy of [b.mn[1], b.mx[1]]) for (const cz of [b.mn[2], b.mx[2]]) {
145
+ const s = [cx * p.scale[0], cy * p.scale[1], cz * p.scale[2]];
146
+ const r = qrot(p.quat, s);
147
+ for (let d = 0; d < 3; d++) {
148
+ const v = r[d] + p.pos[d];
149
+ mn[d] = Math.min(mn[d], v); mx[d] = Math.max(mx[d], v);
150
+ }
151
+ }
152
+ return { mn, mx };
153
+ }
154
+
155
+ // ── group the modules into the structures they form ──
156
+ // BY WHAT THEY TOUCH, not by how close their origins are. A first version unioned modules
157
+ // whose centres were within five metres in plan, ignoring height — which merges a tower's
158
+ // ground floor with the fourth floor of the low building next door, and that floor then
159
+ // renders hanging in the air beside the tower with nothing under it. 2.6% of all parts came
160
+ // out floating that way.
161
+ //
162
+ // Two modules belong to the same structure when their world boxes actually meet, in ALL
163
+ // THREE axes. A storey sits on the one below it, a fire escape touches the wall it clings to,
164
+ // and a neighbour across a gap touches nothing.
165
+ const boxes = parts.map(worldBox);
166
+ const parent = parts.map((_, i) => i);
167
+ const find = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
168
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
169
+ const CELL = 8;
170
+ const cells = new Map();
171
+ parts.forEach((p, i) => {
172
+ const b = boxes[i];
173
+ if (!b) return;
174
+ for (let ci = Math.floor(b.mn[0] / CELL); ci <= Math.floor(b.mx[0] / CELL); ci++) {
175
+ for (let cj = Math.floor(b.mn[2] / CELL); cj <= Math.floor(b.mx[2] / CELL); cj++) {
176
+ const k = `${ci},${cj}`;
177
+ if (!cells.has(k)) cells.set(k, []);
178
+ cells.get(k).push(i);
179
+ }
180
+ }
181
+ });
182
+ const meets = (a, b) => a && b
183
+ && a.mn[0] - touch <= b.mx[0] && a.mx[0] + touch >= b.mn[0]
184
+ && a.mn[1] - touch <= b.mx[1] && a.mx[1] + touch >= b.mn[1]
185
+ && a.mn[2] - touch <= b.mx[2] && a.mx[2] + touch >= b.mn[2];
186
+ for (const ids of cells.values()) {
187
+ for (let a = 0; a < ids.length; a++) for (let b = a + 1; b < ids.length; b++) {
188
+ if (meets(boxes[ids[a]], boxes[ids[b]])) union(ids[a], ids[b]);
189
+ }
190
+ }
191
+ const groups = new Map();
192
+ parts.forEach((p, i) => {
193
+ const r = find(i);
194
+ if (!groups.has(r)) groups.set(r, []);
195
+ groups.get(r).push(p);
196
+ });
197
+
198
+ // ── DROP ANYTHING NOTHING HOLDS UP ──
199
+ // The demo scene is a showcase, staged for the angles its screenshots were taken from, not a
200
+ // structurally complete city: it contains storeys with the floor below them missing and roof
201
+ // pieces over open air, because from the camera you never saw. Placed in a game you walk
202
+ // around, each one is a slab hanging in the sky.
203
+ //
204
+ // Supported means: standing on the ground, OR overlapping in plan something whose top reaches
205
+ // its underside, OR touching something to the side (a fire escape is held by the wall it is
206
+ // bolted to, a roof trim by the parapet it caps). Iterated, because removing a slab can leave
207
+ // what sat on it holding nothing.
208
+ function prune(group, boxOf) {
209
+ const box = group.map(boxOf);
210
+ const keep = group.map(() => true);
211
+ for (let pass = 0; pass < 12; pass++) {
212
+ let cut = 0;
213
+ for (let i = 0; i < group.length; i++) {
214
+ if (!keep[i] || !box[i]) continue;
215
+ const a = box[i];
216
+ if (a.mn[1] <= floorTol) continue; // on the ground
217
+ let held = false;
218
+ for (let j = 0; j < group.length && !held; j++) {
219
+ if (i === j || !keep[j] || !box[j]) continue;
220
+ const b = box[j];
221
+ const overlaps = b.mx[0] > a.mn[0] + 0.3 && b.mn[0] < a.mx[0] - 0.3
222
+ && b.mx[2] > a.mn[2] + 0.3 && b.mn[2] < a.mx[2] - 0.3;
223
+ if (overlaps && b.mx[1] >= a.mn[1] - floorTol) held = true; // stands on it
224
+ else if (!overlaps && meets(a, b) && b.mn[1] <= a.mn[1] + floorTol) held = true; // leans on it
225
+ }
226
+ if (!held) { keep[i] = false; cut++; }
227
+ }
228
+ if (!cut) break;
229
+ }
230
+ return group.filter((_, i) => keep[i]);
231
+ }
232
+ const floorTol = 0.6;
233
+ let pruned = 0;
234
+
235
+ // ── write each building out, in the road pieces' assembly shape ──
236
+ fs.mkdirSync(outDir, { recursive: true });
237
+ const index = {};
238
+ let n = 0, skipped = 0;
239
+ const byPart = new Map(parts.map((p, i) => [p, boxes[i]]));
240
+ for (const raw of [...groups.values()].sort((a, b) => b.length - a.length)) {
241
+ const g = prune(raw, (p) => byPart.get(p));
242
+ pruned += raw.length - g.length;
243
+ // a lone module is a fence panel or an air-conditioning unit, not a building
244
+ if (g.length < 4) { skipped++; continue; }
245
+ const cx = g.reduce((a, p) => a + p.pos[0], 0) / g.length;
246
+ const cz = g.reduce((a, p) => a + p.pos[2], 0) / g.length;
247
+ const floor = Math.min(...g.map((p) => p.pos[1]));
248
+ // named for what it is mostly made of, so a catalogue can tell a shop from a tower
249
+ const kinds = {};
250
+ for (const p of g) {
251
+ const k = /^SM_Bld_([A-Za-z]+)/.exec(p.name)?.[1] ?? 'Building';
252
+ kinds[k] = (kinds[k] ?? 0) + 1;
253
+ }
254
+ const kind = Object.entries(kinds).sort((a, b) => b[1] - a[1])[0][0];
255
+ const name = `${kind}_${String(++n).padStart(2, '0')}`;
256
+ const body = {
257
+ name,
258
+ parts: g.map((p) => ({
259
+ file: `${p.name}.glb`,
260
+ pos: [+(p.pos[0] - cx).toFixed(4), +(p.pos[1] - floor).toFixed(4), +(p.pos[2] - cz).toFixed(4)],
261
+ quat: p.quat.map((v) => +v.toFixed(6)),
262
+ scale: p.scale.map((v) => +v.toFixed(4)),
263
+ })),
264
+ };
265
+ fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify(body));
266
+ index[name] = { parts: g.length, kind };
267
+ }
268
+ fs.writeFileSync(path.join(outDir, 'index.json'), JSON.stringify(index, null, 0));
269
+
270
+ const models = new Set(parts.map((p) => p.name));
271
+ console.log(`${parts.length} ${prefix}* modules · ${n} buildings written · ${skipped} loose modules skipped · ${pruned} unsupported parts dropped`);
272
+ console.log(`${models.size} distinct models needed — bake these to GLB`);
273
+ fs.writeFileSync(path.join(outDir, 'models-needed.txt'), [...models].sort().join('\n'));
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ // WHICH MODELS ARE A WHOLE BUILDING — decided by measuring the mesh, not by reading its name.
3
+ //
4
+ // node tools/wholeBuildings.mjs <glbDir> <outDir>
5
+ //
6
+ // A modular building kit ships walls, floors, roofs and corners alongside the few models that
7
+ // are a finished building on their own. Place a wall panel as a building and you get exactly
8
+ // what it is: a building with most of its walls missing. Naming conventions do not separate
9
+ // them reliably — `SM_Bld_OfficeOld_Large_01` is one storey, `SM_Bld_OfficeRound_01` is a
10
+ // whole tower, and nothing in the name says so.
11
+ //
12
+ // So measure. A model is a whole building when:
13
+ // 1. it is at least MIN_H tall and MIN_W across in BOTH horizontal axes — rules out panels,
14
+ // slabs, kerbs and trims;
15
+ // 2. it has geometry in every horizontal slice from its base to its top — a floor slab has
16
+ // all its vertices in one thin band and fails here;
17
+ // 3. it has geometry on ALL FOUR SIDES of its footprint — the missing-walls test;
18
+ // It is then CAPPED if it needs it. These towers are open-topped shells you can see straight
19
+ // down into from above, and the pack has no cap for them: its `<Family>_Roof_01` models are
20
+ // parapets that cover 0-19% of the footprint, because Synty built this kit to be seen from
21
+ // street level in a game and never from the air. Rejecting every uncapped model leaves zero
22
+ // buildings — measured, all 75 fail — so the tool measures the hole and records a lid for the
23
+ // consumer to draw, rather than pretending the asset has one.
24
+ //
25
+ // The output is one single-part assembly per building plus a catalogue, in the same shape the
26
+ // multi-part assemblies use, so a consumer needs no special case for either.
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ const [glbDir, outDir] = process.argv.slice(2);
31
+ if (!glbDir || !outDir) {
32
+ console.error('usage: wholeBuildings.mjs <glbDir> <outDir>');
33
+ process.exit(1);
34
+ }
35
+
36
+ const MIN_H = 6; // metres — anything shorter is a wall, a kerb or a bit of trim
37
+ const MIN_W = 6; // and anything narrower is a panel seen edge-on
38
+ const BANDS = 6; // vertical slices that must all contain geometry
39
+ const SIDE_FILL = 0.6; // how much of each side of the footprint must carry geometry
40
+ const LID_BAND = 0.12; // the top slice of the model that counts as its roof
41
+ const LID_FILL = 0.55; // how much of the footprint's middle that slice must cover
42
+
43
+ // every vertex position in a GLB, as a flat sequence — the test needs the actual points, not
44
+ // just the bounding box a panel and a tower can share
45
+ function positions(file) {
46
+ const buf = fs.readFileSync(file);
47
+ const jsonLen = buf.readUInt32LE(12);
48
+ const gltf = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
49
+ const binOffset = 20 + jsonLen + 8;
50
+ const out = [];
51
+ for (const mesh of gltf.meshes ?? []) {
52
+ for (const prim of mesh.primitives ?? []) {
53
+ const acc = gltf.accessors[prim.attributes.POSITION];
54
+ if (!acc || acc.componentType !== 5126 || acc.type !== 'VEC3') continue;
55
+ const view = gltf.bufferViews[acc.bufferView];
56
+ const start = binOffset + (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
57
+ const stride = view.byteStride ?? 12;
58
+ for (let i = 0; i < acc.count; i++) {
59
+ const o = start + i * stride;
60
+ if (o + 12 > buf.length) break;
61
+ out.push(buf.readFloatLE(o), buf.readFloatLE(o + 4), buf.readFloatLE(o + 8));
62
+ }
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function judge(p) {
69
+ if (p.length < 3 * 12) return { whole: false, why: 'almost no geometry' };
70
+ const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
71
+ for (let i = 0; i < p.length; i += 3) {
72
+ for (let d = 0; d < 3; d++) {
73
+ if (p[i + d] < mn[d]) mn[d] = p[i + d];
74
+ if (p[i + d] > mx[d]) mx[d] = p[i + d];
75
+ }
76
+ }
77
+ const size = [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]];
78
+ if (size[1] < MIN_H) return { whole: false, why: `only ${size[1].toFixed(1)} m tall`, size, mn };
79
+ if (Math.min(size[0], size[2]) < MIN_W) {
80
+ return { whole: false, why: `only ${Math.min(size[0], size[2]).toFixed(1)} m across — a panel`, size, mn };
81
+ }
82
+
83
+ // 2. every vertical band occupied — a slab lives in one band
84
+ const band = new Array(BANDS).fill(0);
85
+ // 3. and every side of the footprint occupied — this is the missing-walls test
86
+ const N = 8;
87
+ const edgeN = new Array(N).fill(0), edgeS = new Array(N).fill(0);
88
+ const edgeE = new Array(N).fill(0), edgeW = new Array(N).fill(0);
89
+ const bin = (v, lo, span, n) => Math.min(n - 1, Math.max(0, Math.floor(((v - lo) / span) * n)));
90
+ for (let i = 0; i < p.length; i += 3) {
91
+ const x = p[i], y = p[i + 1], z = p[i + 2];
92
+ band[bin(y, mn[1], size[1], BANDS)] = 1;
93
+ const fx = (x - mn[0]) / size[0], fz = (z - mn[2]) / size[2];
94
+ if (fz < 0.2) edgeS[bin(x, mn[0], size[0], N)] = 1;
95
+ if (fz > 0.8) edgeN[bin(x, mn[0], size[0], N)] = 1;
96
+ if (fx < 0.2) edgeW[bin(z, mn[2], size[2], N)] = 1;
97
+ if (fx > 0.8) edgeE[bin(z, mn[2], size[2], N)] = 1;
98
+ }
99
+ const gaps = band.filter((b) => !b).length;
100
+ if (gaps) return { whole: false, why: `${gaps}/${BANDS} vertical bands empty — a floor or a roof`, size, mn };
101
+ const fill = (e) => e.reduce((a, b) => a + b, 0) / N;
102
+ const sides = [fill(edgeN), fill(edgeE), fill(edgeS), fill(edgeW)];
103
+ const open = sides.filter((f) => f < SIDE_FILL).length;
104
+ if (open) {
105
+ return { whole: false, why: `${open} of 4 sides open (${sides.map((f) => (f * 100).toFixed(0) + '%').join('/')})`, size, mn };
106
+ }
107
+
108
+ // 4. IS IT OPEN AT THE TOP? Not a rejection — a measurement. Look at the top slice and ask
109
+ // whether anything covers the MIDDLE of the footprint: walls sit at the perimeter, so an
110
+ // open tube has nothing there and needs a lid drawing over it.
111
+ const G = 6;
112
+ const top = new Array(G * G).fill(0);
113
+ const lidFrom = mn[1] + size[1] * (1 - LID_BAND);
114
+ for (let i = 0; i < p.length; i += 3) {
115
+ if (p[i + 1] < lidFrom) continue;
116
+ const gx = bin(p[i], mn[0], size[0], G), gz = bin(p[i + 2], mn[2], size[2], G);
117
+ top[gz * G + gx] = 1;
118
+ }
119
+ let inner = 0, innerCells = 0;
120
+ for (let gz = 1; gz < G - 1; gz++) for (let gx = 1; gx < G - 1; gx++) {
121
+ innerCells++;
122
+ inner += top[gz * G + gx];
123
+ }
124
+ const lid = inner / innerCells;
125
+
126
+ // ROUND OR SQUARE? A lid has to match the thing it caps: a square slab on a cylinder
127
+ // overhangs at the corners. Measure how far the plan reaches into the corners of its own
128
+ // bounding box, normalised so the answer does not depend on size — a circle tops out at
129
+ // 1.0 because every point is on the inscribed ellipse, a rectangle reaches 1.41 at its
130
+ // corners. (A grid of cells cannot tell these apart at any resolution a corner cell is
131
+ // coarser than the gap; this is exact.)
132
+ const cx0 = mn[0] + size[0] / 2, cz0 = mn[2] + size[2] / 2;
133
+ let reach = 0;
134
+ for (let i = 0; i < p.length; i += 3) {
135
+ const u = (p[i] - cx0) / (size[0] / 2), v = (p[i + 2] - cz0) / (size[2] / 2);
136
+ const r = Math.hypot(u, v);
137
+ if (r > reach) reach = r;
138
+ }
139
+ const shape = reach < 1.15 ? 'round' : 'square';
140
+
141
+ return { whole: true, size, mn, sides, lid, shape, needsCap: lid < LID_FILL };
142
+ }
143
+
144
+ // PUT ITS OWN ROOF ON IT. This pack contains no finished building at all — every model is a
145
+ // shell, a floor or a roof, and the tests above reject all 75 of them. But it ships tower
146
+ // SHELLS (`OfficeOld_Large_01` is a 25 m body) alongside a matching `<Family>_Roof_01`, so the
147
+ // building is the shell with its own roof on top. Which roof belongs to which shell is
148
+ // Synty's naming, not a guess — and the result is then put through the same tests, so a
149
+ // wrong pairing is rejected rather than shipped.
150
+ const files = fs.readdirSync(glbDir).filter((f) => f.endsWith('.glb')).sort();
151
+ const geom = new Map();
152
+ for (const f of files) {
153
+ try { geom.set(f, positions(path.join(glbDir, f))); } catch { /* skip unreadable */ }
154
+ }
155
+ const familyOf = (f) => f.replace(/\.glb$/, '').replace(/^SM_Bld_/, '').replace(/_\d+$/, '');
156
+ const roofFor = (f) => {
157
+ const fam = familyOf(f);
158
+ for (const cand of [`SM_Bld_${fam}_Roof_01.glb`, `SM_Bld_${fam}_Roof.glb`]) {
159
+ if (geom.has(cand)) return cand;
160
+ }
161
+ return null;
162
+ };
163
+ const heightOf = (p) => {
164
+ let lo = Infinity, hi = -Infinity;
165
+ for (let i = 1; i < p.length; i += 3) { if (p[i] < lo) lo = p[i]; if (p[i] > hi) hi = p[i]; }
166
+ return { lo, hi };
167
+ };
168
+
169
+ fs.mkdirSync(outDir, { recursive: true });
170
+ const catalogue = {};
171
+ const rejected = [];
172
+ for (const f of files) {
173
+ const own = geom.get(f);
174
+ if (!own) { rejected.push([f, 'unreadable']); continue; }
175
+ // the shell alone, then the shell with its roof stacked on
176
+ const tries = [{ parts: [[f, 0]], pts: own }];
177
+ const roof = roofFor(f);
178
+ if (roof && roof !== f) {
179
+ const top = heightOf(own).hi;
180
+ const rp = geom.get(roof);
181
+ const lift = top - heightOf(rp).lo;
182
+ const merged = own.slice();
183
+ for (let i = 0; i < rp.length; i += 3) merged.push(rp[i], rp[i + 1] + lift, rp[i + 2]);
184
+ tries.push({ parts: [[f, 0], [roof, lift]], pts: merged });
185
+ }
186
+ let won = null, why = null;
187
+ for (const t of tries) {
188
+ const v = judge(t.pts);
189
+ if (v.whole) { won = { ...t, v }; break; }
190
+ why ??= v.why;
191
+ why = v.why; // report the best attempt's reason
192
+ }
193
+ if (!won) { rejected.push([f, why]); continue; }
194
+ const name = f.replace(/\.glb$/, '').replace(/^SM_Bld_/, '');
195
+ const s = won.v.size, mn = won.v.mn;
196
+ catalogue[name] = {
197
+ size: s.map((v) => +v.toFixed(2)),
198
+ centre: [0, 1, 2].map((d) => +(mn[d] + s[d] / 2).toFixed(2)),
199
+ base: +mn[1].toFixed(2),
200
+ parts: won.parts.length,
201
+ kind: /^([A-Za-z]+)/.exec(name)?.[1] ?? 'Building',
202
+ };
203
+ // CENTRE IT ON ITS OWN PLOT. A model is authored wherever its artist left it — some are
204
+ // centred on the origin, some start at it and run 22 m in +x. The planner works in
205
+ // footprint centres and spaces buildings by half-widths, so a model whose geometry sits off
206
+ // to one side is placed half a building away from where the planner thinks it is, and its
207
+ // neighbour is then built straight through it.
208
+ const offX = mn[0] + s[0] / 2, offZ = mn[2] + s[2] / 2;
209
+ fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify({
210
+ name,
211
+ parts: won.parts.map(([file, lift]) => ({
212
+ file,
213
+ pos: [+(-offX).toFixed(4), +(lift - mn[1]).toFixed(4), +(-offZ).toFixed(4)],
214
+ quat: [0, 0, 0, 1], scale: [1, 1, 1],
215
+ })),
216
+ // a flat lid over the hole, in the building's own local frame — the consumer draws it
217
+ ...(won.v.needsCap ? { cap: {
218
+ shape: won.v.shape,
219
+ y: +(s[1] - 0.05).toFixed(3),
220
+ sizeX: +(s[0] * 0.98).toFixed(3),
221
+ sizeZ: +(s[2] * 0.98).toFixed(3),
222
+ offX: 0, offZ: 0, // the parts are recentred above
223
+ } } : {}),
224
+ }));
225
+ }
226
+ fs.writeFileSync(path.join(outDir, 'catalogue.json'), JSON.stringify(catalogue));
227
+ fs.writeFileSync(path.join(outDir, 'index.json'), JSON.stringify(
228
+ Object.fromEntries(Object.entries(catalogue).map(([k, v]) => [k, { parts: 1, kind: v.kind }]))));
229
+
230
+ const capped = Object.keys(catalogue).length;
231
+ console.log(`${capped} whole buildings · ${rejected.length} rejected`);
232
+ for (const [name, why] of rejected.slice(0, 8)) console.log(` rejected ${name.replace('.glb', '').padEnd(38)} ${why}`);
233
+ if (rejected.length > 8) console.log(` ...and ${rejected.length - 8} more`);