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.
- package/CHANGELOG.md +80 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +16 -3
- package/src/vehicle/car.js +4 -1
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +28 -1
- package/src/world/roadLink.js +88 -15
- package/src/world/roadTerrain.js +36 -8
- package/src/world/roadWorld.js +5 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
|
@@ -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`);
|