@adatechnology/game-destruction-three 0.15.3 → 0.16.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/dist/index.js CHANGED
@@ -122,13 +122,154 @@ function mixHex(a, b, t) {
122
122
  return r << 16 | g << 8 | bl;
123
123
  }
124
124
 
125
+ // src/fragment-shapes.ts
126
+ var FRAGMENT_SHAPE_VARIANTS = 6;
127
+ var SLIVER_STATIONS = 6;
128
+ var SLIVER_FRAY = 0.3;
129
+ var SLIVER_SECTION = [[0.5, 0.5], [0, 0.5], [-0.5, 0.5], [-0.5, -0.5], [0, -0.5], [0.5, -0.5]];
130
+ var sliverWidth = (t) => t < 0.25 ? 0.6 + 1.6 * t : t > 0.5 ? Math.max(0, (1 - t) / 0.5) ** 0.85 : 1;
131
+ var pushTriangle = (out, a, b, c) => {
132
+ out.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
133
+ };
134
+ var faceted = (THREE, positions) => {
135
+ const geometry = new THREE.BufferGeometry();
136
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
137
+ geometry.computeVertexNormals();
138
+ return geometry;
139
+ };
140
+ function buildSliverGeometry(THREE, seed) {
141
+ const rnd = createRng(7e3 + seed);
142
+ const rings = [];
143
+ const bendY = (rnd() - 0.5) * 0.3;
144
+ const bendZ = (rnd() - 0.5) * 0.3;
145
+ for (let i = 0; i < SLIVER_STATIONS - 1; i++) {
146
+ const t = i / (SLIVER_STATIONS - 1);
147
+ const x = -0.5 + t + (i > 0 ? (rnd() - 0.5) * 0.08 : 0);
148
+ const width = sliverWidth(t);
149
+ const lift = Math.sin(t * Math.PI);
150
+ rings.push(SLIVER_SECTION.map(([y, z]) => {
151
+ const fray = 1 - SLIVER_FRAY + SLIVER_FRAY * 1.4 * rnd();
152
+ return [x, Math.max(-0.5, Math.min(0.5, y * width * fray + bendY * lift)), Math.max(-0.5, Math.min(0.5, z * width * fray + bendZ * lift))];
153
+ }));
154
+ }
155
+ const tip = [0.5, (rnd() - 0.5) * 0.5, (rnd() - 0.5) * 0.3];
156
+ const torn = [-0.5 + rnd() * 0.12, (rnd() - 0.5) * 0.2, (rnd() - 0.5) * 0.2];
157
+ const sides = SLIVER_SECTION.length;
158
+ const positions = [];
159
+ for (let k = 0; k < sides; k++) {
160
+ const next = (k + 1) % sides;
161
+ pushTriangle(positions, torn, rings[0][next], rings[0][k]);
162
+ for (let i = 0; i < rings.length - 1; i++) {
163
+ const a = rings[i][k], b = rings[i][next], c = rings[i + 1][next], d = rings[i + 1][k];
164
+ pushTriangle(positions, a, b, c);
165
+ pushTriangle(positions, a, c, d);
166
+ }
167
+ const last = rings[rings.length - 1];
168
+ pushTriangle(positions, last[k], last[next], tip);
169
+ }
170
+ return faceted(THREE, positions);
171
+ }
172
+ function buildLumpGeometry(THREE, seed) {
173
+ const rnd = createRng(8e3 + seed);
174
+ const base = new THREE.IcosahedronGeometry(0.5, 0);
175
+ const source = base.getAttribute("position");
176
+ const pushOf = /* @__PURE__ */ new Map();
177
+ const positions = [];
178
+ for (let i = 0; i < source.count; i++) {
179
+ const x = source.getX(i), y = source.getY(i), z = source.getZ(i);
180
+ const key = `${x.toFixed(3)},${y.toFixed(3)},${z.toFixed(3)}`;
181
+ let push = pushOf.get(key);
182
+ if (push === void 0) {
183
+ push = 0.6 + 0.55 * rnd();
184
+ pushOf.set(key, push);
185
+ }
186
+ positions.push(x * push, y * push, z * push);
187
+ }
188
+ base.dispose();
189
+ const extent = [0, 0, 0];
190
+ for (let i = 0; i < positions.length; i++) extent[i % 3] = Math.max(extent[i % 3], Math.abs(positions[i]));
191
+ for (let i = 0; i < positions.length; i++) positions[i] *= 0.5 / Math.max(extent[i % 3], 1e-3);
192
+ return faceted(THREE, positions);
193
+ }
194
+ var VENEER_EDGE = 11;
195
+ function buildVeneerGeometry(THREE, seed) {
196
+ const rnd = createRng(9e3 + seed);
197
+ const shape = new THREE.Shape();
198
+ const outline = [];
199
+ const edge = (y, inward) => y - inward * (rnd() * 0.16 + (rnd() < 0.15 ? 0.12 : 0));
200
+ for (let i = 0; i <= VENEER_EDGE; i++) outline.push([-0.5 + i / VENEER_EDGE, edge(0.5, 1)]);
201
+ outline.push([0.5 - rnd() * 0.12, 0.2 * rnd()], [0.5, -0.1 + 0.2 * rnd()], [0.5 - rnd() * 0.12, -0.25 * rnd()]);
202
+ for (let i = VENEER_EDGE; i >= 0; i--) outline.push([-0.5 + i / VENEER_EDGE, edge(-0.5, -1)]);
203
+ outline.push([-0.5 + rnd() * 0.12, -0.2 * rnd()], [-0.5, 0.1 * rnd()], [-0.5 + rnd() * 0.12, 0.25 * rnd()]);
204
+ shape.moveTo(outline[0][0], outline[0][1]);
205
+ for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i][0], outline[i][1]);
206
+ shape.closePath();
207
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: 1, bevelEnabled: false });
208
+ geometry.translate(0, 0, -0.5);
209
+ return geometry;
210
+ }
211
+ var TORN_END = 0.24;
212
+ var TORN_STRANDS = [4, 7];
213
+ var pushQuad = (out, quad) => {
214
+ pushTriangle(out, quad[0], quad[1], quad[2]);
215
+ pushTriangle(out, quad[0], quad[2], quad[3]);
216
+ };
217
+ var unevenCuts = (count, rnd) => {
218
+ const shares = Array.from({ length: count }, () => 0.35 + 1.65 * rnd());
219
+ const total = shares.reduce((sum, share) => sum + share, 0);
220
+ const cuts = [-0.5];
221
+ for (const share of shares) cuts.push(cuts[cuts.length - 1] + share / total);
222
+ cuts[cuts.length - 1] = 0.5;
223
+ return cuts;
224
+ };
225
+ function buildBrokenBoardGeometry(THREE, seed) {
226
+ const rnd = createRng(1e4 + seed);
227
+ const end = 0.5 - TORN_END;
228
+ const positions = [];
229
+ const lo = -0.5, hi = 0.5;
230
+ pushQuad(positions, [[lo, lo, hi], [end, lo, hi], [end, hi, hi], [lo, hi, hi]]);
231
+ pushQuad(positions, [[lo, hi, lo], [end, hi, lo], [end, lo, lo], [lo, lo, lo]]);
232
+ pushQuad(positions, [[lo, hi, hi], [end, hi, hi], [end, hi, lo], [lo, hi, lo]]);
233
+ pushQuad(positions, [[lo, lo, lo], [end, lo, lo], [end, lo, hi], [lo, lo, hi]]);
234
+ pushQuad(positions, [[lo, lo, lo], [lo, lo, hi], [lo, hi, hi], [lo, hi, lo]]);
235
+ const count = TORN_STRANDS[0] + Math.floor(rnd() * (TORN_STRANDS[1] - TORN_STRANDS[0] + 1));
236
+ const cuts = unevenCuts(count, rnd);
237
+ for (let k = 0; k < count; k++) {
238
+ const y0 = cuts[k], y1 = cuts[k + 1];
239
+ const length = TORN_END * (0.15 + 0.85 * rnd());
240
+ const midX = end + length * (0.35 + 0.3 * rnd());
241
+ const narrow = 0.55 + 0.35 * rnd();
242
+ const shiftY = (rnd() - 0.5) * (y1 - y0) * 0.6;
243
+ const shiftZ = (rnd() - 0.5) * 0.5;
244
+ const cy = (y0 + y1) / 2 + shiftY, hy = (y1 - y0) / 2 * narrow, hz = 0.5 * (0.4 + 0.5 * rnd());
245
+ const base = [[end, y0, lo], [end, y1, lo], [end, y1, hi], [end, y0, hi]];
246
+ const clampZ = (z) => Math.max(lo, Math.min(hi, z));
247
+ const mid = [[midX, cy - hy, clampZ(shiftZ - hz)], [midX, cy + hy, clampZ(shiftZ - hz)], [midX, cy + hy, clampZ(shiftZ + hz)], [midX, cy - hy, clampZ(shiftZ + hz)]];
248
+ const tip = [end + length, y0 + (y1 - y0) * rnd(), clampZ(shiftZ + (rnd() - 0.5) * 0.4)];
249
+ for (let i = 0; i < 4; i++) {
250
+ const next = (i + 1) % 4;
251
+ pushQuad(positions, [base[i], base[next], mid[next], mid[i]]);
252
+ pushTriangle(positions, mid[i], mid[next], tip);
253
+ }
254
+ }
255
+ return faceted(THREE, positions);
256
+ }
257
+
125
258
  // src/fragments.ts
259
+ var BUILDERS = {
260
+ sliver: buildSliverGeometry,
261
+ lump: buildLumpGeometry,
262
+ veneer: buildVeneerGeometry,
263
+ board: buildBrokenBoardGeometry
264
+ };
126
265
  var SHEET_MAX = 4e-3;
127
266
  var SHEET_AIR = 2.5;
128
267
  var SHEET_SWAY = 3;
129
268
  var BIG_LENGTH = 0.2;
130
269
  var BIG_WIDTH = 0.03;
131
270
  var FRAGMENT_SEED = 1374496523;
271
+ var SUPPORT_CHECKS = 32;
272
+ var SUPPORT_GAP = 0.02;
132
273
  var SHARD_SHAPES = 6;
133
274
  var isBig = (size) => {
134
275
  const [, middle, longest] = [...size].sort((a, b) => a - b);
@@ -143,6 +284,7 @@ var FragmentSystem = class {
143
284
  /** The mesh box of a fragment before its scale. */
144
285
  __publicField(this, "unitBox", { min: { x: -0.5, y: -0.5, z: -0.5 }, max: { x: 0.5, y: 0.5, z: 0.5 } });
145
286
  __publicField(this, "shardGeos", []);
287
+ __publicField(this, "shapeGeos", /* @__PURE__ */ new Map());
146
288
  __publicField(this, "materials", /* @__PURE__ */ new Map());
147
289
  __publicField(this, "q");
148
290
  __publicField(this, "dq");
@@ -152,6 +294,7 @@ var FragmentSystem = class {
152
294
  __publicField(this, "rng", createRng(FRAGMENT_SEED));
153
295
  /** Count of `update` calls: what makes the fragments of one burst tell apart from older ones. */
154
296
  __publicField(this, "tick", 0);
297
+ __publicField(this, "supportTurn", 0);
155
298
  __publicField(this, "markGeometry", null);
156
299
  __publicField(this, "markMaterial", null);
157
300
  this.box = new THREE.BoxGeometry(1, 1, 1);
@@ -200,21 +343,54 @@ var FragmentSystem = class {
200
343
  }
201
344
  return g;
202
345
  }
346
+ sharedGeometry(shape, seed) {
347
+ const idx = seed % FRAGMENT_SHAPE_VARIANTS;
348
+ let variants = this.shapeGeos.get(shape);
349
+ if (!variants) {
350
+ variants = [];
351
+ this.shapeGeos.set(shape, variants);
352
+ }
353
+ let g = variants[idx];
354
+ if (!g) {
355
+ g = BUILDERS[shape](this.THREE, idx);
356
+ variants[idx] = g;
357
+ }
358
+ return g;
359
+ }
360
+ /**
361
+ * The geometry a fragment is drawn with: a splinter is a pointed, frayed sliver; MDF breaks in lumps, plywood peels in ragged veneer, the stub of a broken
362
+ * board ends in torn strands at +x; the rest are boxes.
363
+ */
364
+ geometryOf(f) {
365
+ const seed = hashString(f.id);
366
+ if (f.shape === "shard") return this.shardGeometry(seed);
367
+ if (f.shape === "splinter") return this.sharedGeometry("sliver", seed);
368
+ const form = this.o.formOf?.(f.material);
369
+ if (f.shape === "chunk" && form === "granules") return this.sharedGeometry("lump", seed);
370
+ if (f.shape === "scrap" && form === "plies") return this.sharedGeometry("veneer", seed);
371
+ if (f.shape === "slab" && (form === "slivers" || form === "plies")) return this.sharedGeometry("board", seed);
372
+ return this.box;
373
+ }
203
374
  markParts() {
204
375
  this.markGeometry ?? (this.markGeometry = new this.THREE.CircleGeometry(1, 10));
205
376
  this.markMaterial ?? (this.markMaterial = new this.THREE.MeshBasicMaterial({ color: 723465, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 }));
206
377
  return { geometry: this.markGeometry, material: this.markMaterial };
207
378
  }
208
379
  /**
209
- * Meshes, never put in a scene by this class, that draw every shared piece of a fragment once: the box, the six shards, the bullet-hole disc, and the opaque and
380
+ * Meshes, never put in a scene by this class, that draw every shared piece of a fragment once: the box, the six shards, the wood splinters, MDF lumps and plywood veneer, the bullet-hole disc, and the opaque and
210
381
  * glass materials. A host draws them at load so the first blast does not upload these buffers in the middle of a frame.
211
382
  */
212
383
  warmMeshes() {
213
384
  const opaque = this.materialFor("warm", 8421504, false);
214
385
  const glass2 = this.materialFor("warm", 8421504, true);
215
386
  const geometries = [this.box, ...Array.from({ length: SHARD_SHAPES }, (_, index) => this.shardGeometry(index))];
387
+ const wood = ["sliver", "lump", "veneer", "board"].flatMap((shape) => Array.from({ length: FRAGMENT_SHAPE_VARIANTS }, (_, index) => this.sharedGeometry(shape, index)));
216
388
  const { geometry, material } = this.markParts();
217
- return [...geometries.flatMap((shared) => [new this.THREE.Mesh(shared, opaque), new this.THREE.Mesh(shared, glass2)]), new this.THREE.Mesh(geometry, material)];
389
+ return [
390
+ ...geometries.flatMap((shared) => [new this.THREE.Mesh(shared, opaque), new this.THREE.Mesh(shared, glass2)]),
391
+ ...wood.map((shared) => new this.THREE.Mesh(shared, opaque)),
392
+ new this.THREE.Mesh(geometry, material)
393
+ ];
218
394
  }
219
395
  /**
220
396
  * Bullet holes that stay on a fragment (`Fragment.marks`): dark discs that are children of its mesh, so they fall, spin and settle with it. The mesh is
@@ -239,7 +415,11 @@ var FragmentSystem = class {
239
415
  * bigger than the budget (every live fragment was made in this same frame) does not make the rest at all, so a blast never builds a thousand meshes only to fade them
240
416
  * out. Returns null when the fragment was not made.
241
417
  */
242
- spawn(f, material, home, maxAllowed) {
418
+ /**
419
+ * Throws `f` into the scene, within `maxAllowed` live fragments (the oldest small ones make room first). A `lasting` fragment (a steel bar) is debris that
420
+ * stays: it has a body the player and the pieces run into, however thin it is, and it never fades.
421
+ */
422
+ spawn(f, material, home, maxAllowed, lasting = false) {
243
423
  if (this.list.length >= maxAllowed) {
244
424
  let alive = 0;
245
425
  let oldest;
@@ -256,7 +436,7 @@ var FragmentSystem = class {
256
436
  oldest.dying = true;
257
437
  }
258
438
  }
259
- const geo = f.shape === "shard" ? this.shardGeometry(hashString(f.id)) : this.box;
439
+ const geo = this.geometryOf(f);
260
440
  const mesh = new this.THREE.Mesh(geo, material);
261
441
  mesh.position.set(f.center[0], f.center[1], f.center[2]);
262
442
  mesh.quaternion.set(f.rotation[0], f.rotation[1], f.rotation[2], f.rotation[3]).normalize();
@@ -284,9 +464,9 @@ var FragmentSystem = class {
284
464
  air: f.shape === "scrap" && sz[2] <= SHEET_MAX ? SHEET_AIR : 0,
285
465
  body: null,
286
466
  born: this.tick,
287
- life: Math.max(0, typeof this.o.life === "function" ? this.o.life(f, home) : this.o.life)
467
+ life: lasting ? 0 : Math.max(0, typeof this.o.life === "function" ? this.o.life(f, home) : this.o.life)
288
468
  };
289
- if (f.shape !== "shard" && isBig(f.size)) {
469
+ if (lasting || f.shape !== "shard" && isBig(f.size)) {
290
470
  live.body = {
291
471
  mass: f.mass,
292
472
  push: (x, z) => {
@@ -346,6 +526,7 @@ var FragmentSystem = class {
346
526
  }
347
527
  update(dt) {
348
528
  this.tick++;
529
+ this.wakeUnsupported();
349
530
  const g = this.o.gravity;
350
531
  for (let i = this.list.length - 1; i >= 0; i--) {
351
532
  const f = this.list[i];
@@ -370,6 +551,18 @@ var FragmentSystem = class {
370
551
  this.step(f, dt, g);
371
552
  }
372
553
  }
554
+ /** A few fragments asleep each update (`SUPPORT_CHECKS`, in turn) look at the ground under them again: one left over air falls. */
555
+ wakeUnsupported() {
556
+ const n = this.list.length;
557
+ for (let k = 0; k < Math.min(SUPPORT_CHECKS, n); k++) {
558
+ this.supportTurn = (this.supportTurn + 1) % n;
559
+ const f = this.list[this.supportTurn];
560
+ if (!f.asleep || f.dying) continue;
561
+ const e = this.m.makeRotationFromQuaternion(f.mesh.quaternion).elements;
562
+ const ext = Math.abs(e[1]) * f.half.x + Math.abs(e[5]) * f.half.y + Math.abs(e[9]) * f.half.z;
563
+ if (f.mesh.position.y - ext > this.o.groundAt(f.mesh.position.x, f.mesh.position.z) + SUPPORT_GAP) this.rouse(f);
564
+ }
565
+ }
373
566
  step(f, dt, g) {
374
567
  const mesh = f.mesh;
375
568
  f.vel.y -= g * dt;
@@ -477,6 +670,8 @@ var FragmentSystem = class {
477
670
  this.markMaterial?.dispose();
478
671
  for (const g of this.shardGeos) g?.dispose();
479
672
  this.shardGeos = [];
673
+ for (const variants of this.shapeGeos.values()) for (const g of variants) g?.dispose();
674
+ this.shapeGeos.clear();
480
675
  for (const m of this.materials.values()) m.dispose();
481
676
  this.materials.clear();
482
677
  }
@@ -586,6 +781,8 @@ var WARM_SLOTS = 8 + 12 * 7;
586
781
  var SOLVER_PASSES = 10;
587
782
  var SIDE_BOUNCE = 0.1;
588
783
  var EDGE_POINTS = [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75];
784
+ var SUPPORT_CHECKS2 = 16;
785
+ var SUPPORT_GAP2 = 0.02;
589
786
  var boxTopAt = (s, x, z) => {
590
787
  const e = s.e;
591
788
  const dx = x - s.c[0], dz = z - s.c[2];
@@ -618,6 +815,7 @@ var PieceSystem = class {
618
815
  __publicField(this, "m");
619
816
  __publicField(this, "ax");
620
817
  __publicField(this, "solids", []);
818
+ __publicField(this, "supportTurn", 0);
621
819
  this.p = new THREE.Vector3();
622
820
  this.q = new THREE.Quaternion();
623
821
  this.dq = new THREE.Quaternion();
@@ -777,6 +975,7 @@ var PieceSystem = class {
777
975
  const h = body.halfExtents;
778
976
  return { body, c: [c.x, c.y, c.z], e: [...new this.THREE.Matrix4().makeRotationFromQuaternion(body.root.quaternion).elements], half: h, r: Math.hypot(h[0], h[1], h[2]) };
779
977
  });
978
+ this.wakeUnsupported();
780
979
  for (const b of this.list) {
781
980
  if (b.held) {
782
981
  b.velocity = [0, 0, 0];
@@ -791,6 +990,33 @@ var PieceSystem = class {
791
990
  }
792
991
  this.separate(dt);
793
992
  }
993
+ /** A few sleeping pieces each update (`SUPPORT_CHECKS`, in turn) look under them again: one whose table broke, or whose shelf fell over, falls. */
994
+ wakeUnsupported() {
995
+ const n = this.list.length;
996
+ for (let k = 0; k < Math.min(SUPPORT_CHECKS2, n); k++) {
997
+ this.supportTurn = (this.supportTurn + 1) % n;
998
+ const b = this.list[this.supportTurn];
999
+ if (!b.sleeping || b.held || this.supported(b, false) || this.supported(b, true)) continue;
1000
+ b.sleeping = false;
1001
+ b.still = 0;
1002
+ b.calmAt = null;
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Whether a point of `b` (its corners, or with `edges` the points along its edges too) has the ground, a piece or a fixed top under it. Any point, not only the lowest:
1007
+ * a book propped on a fallen case rests by a corner high on it while its lowest one hangs beside it.
1008
+ */
1009
+ supported(b, edges) {
1010
+ const c = this.centerOf(b, this.p);
1011
+ const e = this.m.makeRotationFromQuaternion(b.root.quaternion).elements;
1012
+ for (const [sx, sy, sz] of this.samples(b, edges)) {
1013
+ const y = c.y + e[1] * sx + e[5] * sy + e[9] * sz;
1014
+ const x = c.x + e[0] * sx + e[4] * sy + e[8] * sz;
1015
+ const z = c.z + e[2] * sx + e[6] * sy + e[10] * sz;
1016
+ if (this.supportAt(b, x, y, z, STEP) >= y - SUPPORT_GAP2) return true;
1017
+ }
1018
+ return false;
1019
+ }
794
1020
  /** Where a corner of `self` at `(x, y, z)` lands: the ground, the top of another piece or of something fixed under it. */
795
1021
  supportAt(self, x, y, z, step) {
796
1022
  let top = this.o.groundAt(x, z);
@@ -1164,6 +1390,7 @@ var RebarCages = class {
1164
1390
  constructor(THREE, scene2, radius = 4e-3) {
1165
1391
  __publicField(this, "THREE", THREE);
1166
1392
  __publicField(this, "scene", scene2);
1393
+ __publicField(this, "radius", radius);
1167
1394
  __publicField(this, "cages", /* @__PURE__ */ new Map());
1168
1395
  __publicField(this, "geometry");
1169
1396
  __publicField(this, "material");
@@ -1192,28 +1419,62 @@ var RebarCages = class {
1192
1419
  meshes() {
1193
1420
  return [...this.cages.values()];
1194
1421
  }
1195
- /** Draw `bars` (world space) as the cage of `key`; an empty list removes it. */
1196
- set(key, bars) {
1422
+ /**
1423
+ * Draw `bars` (world space) as the cage of `key`; an empty list removes it. The cage is a child of `holder` (the drawing of the part's object, the scene
1424
+ * by default), so it goes where the part goes: a beam that comes down takes its bare bars with it.
1425
+ */
1426
+ set(key, bars, holder) {
1197
1427
  this.remove(key);
1198
1428
  if (bars.length === 0) return;
1429
+ const parent = holder ?? this.scene;
1430
+ parent.updateWorldMatrix(true, false);
1431
+ const toParent = new this.THREE.Matrix4().copy(parent.matrixWorld).invert();
1199
1432
  const mesh = new this.THREE.InstancedMesh(this.geometry, this.material, bars.length);
1200
1433
  bars.forEach(([a, b], i) => {
1201
1434
  this.dir.set(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
1202
1435
  const length = this.dir.length();
1203
1436
  this.mid.set((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2);
1204
1437
  this.q.setFromUnitVectors(this.up, length > 1e-9 ? this.dir.divideScalar(length) : this.up);
1205
- this.m.compose(this.mid, this.q, this.scale.set(1, Math.max(length, 1e-4), 1));
1438
+ this.m.compose(this.mid, this.q, this.scale.set(1, Math.max(length, 1e-4), 1)).premultiply(toParent);
1206
1439
  mesh.setMatrixAt(i, this.m);
1207
1440
  });
1208
1441
  mesh.instanceMatrix.needsUpdate = true;
1209
1442
  mesh.castShadow = true;
1210
- this.scene.add(mesh);
1443
+ mesh.name = "rebar-cage";
1444
+ parent.add(mesh);
1211
1445
  this.cages.set(key, mesh);
1212
1446
  }
1447
+ /** The cage of `from` now belongs to `to` (a part that left its object for one of its own) and hangs from `holder`, where it is in the world now. */
1448
+ move(from, to, holder) {
1449
+ const mesh = this.cages.get(from);
1450
+ if (!mesh) return;
1451
+ this.cages.delete(from);
1452
+ this.remove(to);
1453
+ holder.attach(mesh);
1454
+ this.cages.set(to, mesh);
1455
+ }
1456
+ /** Takes the cage of `key` away and gives its bars, where they are in the world now: centre, turn (the bar along its y) and length. */
1457
+ take(key) {
1458
+ const mesh = this.cages.get(key);
1459
+ if (!mesh) return [];
1460
+ mesh.updateWorldMatrix(true, false);
1461
+ const bars = [];
1462
+ for (let i = 0; i < mesh.count; i++) {
1463
+ mesh.getMatrixAt(i, this.m);
1464
+ this.m.premultiply(mesh.matrixWorld).decompose(this.mid, this.q, this.scale);
1465
+ bars.push({ center: [this.mid.x, this.mid.y, this.mid.z], rotation: [this.q.x, this.q.y, this.q.z, this.q.w], length: this.scale.y });
1466
+ }
1467
+ this.remove(key);
1468
+ return bars;
1469
+ }
1470
+ /** Radius of the bars drawn, metres. */
1471
+ get barRadius() {
1472
+ return this.radius;
1473
+ }
1213
1474
  remove(key) {
1214
1475
  const mesh = this.cages.get(key);
1215
1476
  if (!mesh) return;
1216
- this.scene.remove(mesh);
1477
+ mesh.removeFromParent();
1217
1478
  mesh.dispose();
1218
1479
  this.cages.delete(key);
1219
1480
  }
@@ -1556,6 +1817,23 @@ var DecalKit = class {
1556
1817
  }
1557
1818
  };
1558
1819
  var tmpV = (THREE) => new THREE.Vector3();
1820
+ function plainPositions(mesh, v0, v1) {
1821
+ const { collapsedRanges, plainPositions: plain } = mesh.userData;
1822
+ if (!plain || !collapsedRanges?.some(([a, b]) => a < v1 && b > v0)) return null;
1823
+ return plain;
1824
+ }
1825
+ function showCollapsedRanges(mesh) {
1826
+ const data = mesh.userData;
1827
+ const plain = data.plainPositions;
1828
+ const position = mesh.geometry?.getAttribute(POS_ATTR);
1829
+ if (!plain || !data.collapsedRanges?.length || !position) return;
1830
+ const array = position.array;
1831
+ for (const [v0, v1] of data.collapsedRanges) array.set(plain.subarray(v0 * 3, v1 * 3), v0 * 3);
1832
+ data.collapsedRanges = [];
1833
+ position.needsUpdate = true;
1834
+ mesh.geometry.computeBoundingBox();
1835
+ mesh.geometry.computeBoundingSphere();
1836
+ }
1559
1837
  var MIN_DENT_VERTICES = 10;
1560
1838
  var WIDEN_MARGIN = 1.1;
1561
1839
  var SKIN_FOLLOW = 0.85;
@@ -2321,7 +2599,7 @@ var Visuals = class {
2321
2599
  const a = src.getAttribute(name);
2322
2600
  if (a.isInterleavedBufferAttribute) continue;
2323
2601
  const size = a.itemSize;
2324
- const from = (name === POS_ATTR ? host.work : null) ?? a.array;
2602
+ const from = (name === POS_ATTR ? plainPositions(host.mesh, v0, v1) ?? host.work : null) ?? a.array;
2325
2603
  const slice = from.slice(v0 * size, v1 * size);
2326
2604
  const Ctor = a.array.constructor;
2327
2605
  geo.setAttribute(name, new this.THREE.BufferAttribute(new Ctor(slice), size, a.normalized));
@@ -2479,11 +2757,29 @@ var Visuals = class {
2479
2757
  // src/renderer.ts
2480
2758
  var rendererCount = 0;
2481
2759
  var COLLAPSE_CLOUD_KG = 1e3;
2760
+ var REBAR_COLOR = 7628380;
2761
+ var STEEL_DENSITY = 7850;
2482
2762
  var CHIP_DECAL_EDGE = 0.05;
2483
2763
  var WOOD_RIM = 1.5;
2484
2764
  var IMPACT_TAGS = ["metal", "glass", "wood"];
2485
2765
  var GLOW_FROM_C = 430;
2486
2766
  var QUEUE_CAP = 4e3;
2767
+ var STRUCTURAL_EVENTS = /* @__PURE__ */ new Set([
2768
+ "fracture",
2769
+ "detach",
2770
+ "collapse",
2771
+ "settle",
2772
+ "destroyed",
2773
+ "expose",
2774
+ "stage",
2775
+ "hole",
2776
+ "ignite",
2777
+ "primed",
2778
+ "extinguish",
2779
+ "burnout",
2780
+ "leak",
2781
+ "explode"
2782
+ ]);
2487
2783
  var MIN_FRAGMENTS_PER_SLICE = 8;
2488
2784
  var STATIC_REFRESH = 0.5;
2489
2785
  var STATIC_REFRESH_SLICE_MS = 1;
@@ -2592,6 +2888,8 @@ var Renderer = class {
2592
2888
  __publicField(this, "pieceBoxes", /* @__PURE__ */ new WeakMap());
2593
2889
  __publicField(this, "staticAt", 0);
2594
2890
  __publicField(this, "queue", []);
2891
+ /** The cover of each body around a part of the world that went off, read as the event is emitted: before the blast tore or moved anything. */
2892
+ __publicField(this, "coverWhenWentOff", /* @__PURE__ */ new WeakMap());
2595
2893
  /** Presentation work an event left for later frames (fragments still to spawn, loose groups still to split), run in order within the frame budget. */
2596
2894
  __publicField(this, "slices", []);
2597
2895
  /** When the work of this frame must stop; `Infinity` outside a drain, where a call finishes what it started. */
@@ -2671,7 +2969,8 @@ var Renderer = class {
2671
2969
  groundAt: this.groundAt,
2672
2970
  gravity: this.gravity,
2673
2971
  max: this.maxFragments,
2674
- life: o.fragmentLife ?? 12
2972
+ life: o.fragmentLife ?? 12,
2973
+ formOf: (id) => this.world.materials.get(id)?.splitForm
2675
2974
  });
2676
2975
  this.cages = new RebarCages(THREE, o.scene);
2677
2976
  this.pieceSys = new PieceSystem(THREE, {
@@ -2684,7 +2983,8 @@ var Renderer = class {
2684
2983
  onSlam: (p, hit, speed) => this.slam(p, hit, speed)
2685
2984
  });
2686
2985
  this.unsubscribe = this.world.on("*", (ev) => {
2687
- if (this.queue.length < QUEUE_CAP) this.queue.push(ev);
2986
+ if (ev.type === "explode") this.coverWhenWentOff.set(ev, this.coverAround(ev));
2987
+ if (this.queue.length < QUEUE_CAP || STRUCTURAL_EVENTS.has(ev.type)) this.queue.push(ev);
2688
2988
  });
2689
2989
  }
2690
2990
  // ----------------------------------------------------------------------------------------------------------------------------------
@@ -3207,7 +3507,8 @@ var Renderer = class {
3207
3507
  this.onChip(ev);
3208
3508
  break;
3209
3509
  case "expose":
3210
- this.cages.set(`${ev.object}|${ev.part}`, ev.bars);
3510
+ if (ev.bars.length > 0) this.cages.set(`${ev.object}|${ev.part}`, ev.bars, this.bounds.get(ev.object)?.root);
3511
+ else this.dropCage(ev.object, ev.part);
3211
3512
  if (ev.bars.length > 0) {
3212
3513
  const [a, b] = ev.bars[0];
3213
3514
  this.playEvent({ kind: "expose" }, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]);
@@ -3505,7 +3806,7 @@ var Renderer = class {
3505
3806
  const rotation = new T.Quaternion(ev.rotation[0], ev.rotation[1], ev.rotation[2], ev.rotation[3]);
3506
3807
  for (const part of this.bounds.get(ev.object)?.parts.values() ?? []) for (const mv of part.meshes) this.visuals.clearDecals(mv, center, half, rotation);
3507
3808
  this.vfx.peekWorkshop()?.decals.clearInside(ev.center, [half.x, half.y, half.z], ev.rotation);
3508
- this.vfx.burst("dust", ev.center, [0, 1, 0], 8, 0.8, { color });
3809
+ this.vfx.burst("dust", ev.center, [0, 1, 0], ev.units ? Math.min(40, 8 + ev.units.length) : 8, ev.units ? 1.6 : 0.8, { color });
3509
3810
  this.impactSound(pv?.material ?? "", ev.center, 0.7, ev.blow);
3510
3811
  }
3511
3812
  onFracture(ev) {
@@ -3524,6 +3825,7 @@ var Renderer = class {
3524
3825
  this.visuals.hide(pv, "broken");
3525
3826
  }
3526
3827
  this.spawnFragments(ev, 0, { def, color, opacity, transparent });
3828
+ this.loosenIfFootless(ev.object);
3527
3829
  const up = [0, 1, 0];
3528
3830
  const sizeK = clamp(ev.fragments.length / 10, 0.5, 2);
3529
3831
  switch (ev.style) {
@@ -3569,6 +3871,33 @@ var Renderer = class {
3569
3871
  this.sound("detach", this.rootPoint(ev.object), 0.5);
3570
3872
  this.splitLoose(ev.object);
3571
3873
  }
3874
+ /**
3875
+ * The cage of a part gave way (`expose` with no bars): nothing holds its bars any more, so each comes down where it is, as steel debris that stays on the
3876
+ * ground with a body (`FragmentSystem.spawn` with `lasting`), not as bars left standing in the air.
3877
+ */
3878
+ dropCage(objectId, partId) {
3879
+ const bars = this.cages.take(`${objectId}|${partId}`);
3880
+ if (bars.length === 0) return;
3881
+ const radius = this.cages.barRadius;
3882
+ const density = this.world.materials.get("steel")?.density ?? STEEL_DENSITY;
3883
+ const material = this.fragments.materialFor("steel", REBAR_COLOR, false);
3884
+ const rnd = createRng(hashString(`${objectId}|${partId}`));
3885
+ bars.forEach((bar, i) => {
3886
+ const fragment = {
3887
+ id: `${partId}#bar${i}`,
3888
+ part: partId,
3889
+ material: "steel",
3890
+ shape: "chunk",
3891
+ center: bar.center,
3892
+ size: [2 * radius, bar.length, 2 * radius],
3893
+ rotation: bar.rotation,
3894
+ velocity: [(rnd() - 0.5) * 0.4, 0, (rnd() - 0.5) * 0.4],
3895
+ angularVelocity: [(rnd() - 0.5) * 1.5, (rnd() - 0.5) * 0.5, (rnd() - 0.5) * 1.5],
3896
+ mass: density * Math.PI * radius * radius * bar.length
3897
+ };
3898
+ this.fragments.spawn(fragment, material, objectId, this.maxFragments, true);
3899
+ });
3900
+ }
3572
3901
  /** Turn every loose group of the object (a detach, a collapse) into a piece of its own. */
3573
3902
  splitLoose(objectId) {
3574
3903
  if (!this.autoSplit) return;
@@ -3623,10 +3952,12 @@ var Renderer = class {
3623
3952
  if (!pv) continue;
3624
3953
  from.parts.delete(p.id);
3625
3954
  nb.parts.set(p.id, pv);
3955
+ this.cages.move(`${from.id}|${p.id}`, `${newId}|${p.id}`, group);
3626
3956
  for (const mv of pv.meshes) {
3627
3957
  if (mv.host && !mv.loose) this.visuals.extract(mv, group);
3628
3958
  else {
3629
3959
  group.attach(mv.mesh);
3960
+ showCollapsedRanges(mv.mesh);
3630
3961
  mv.moved = true;
3631
3962
  }
3632
3963
  }
@@ -3653,6 +3984,45 @@ var Renderer = class {
3653
3984
  if (kitOriginOf(from.root)?.kind === "furniture") this.tipIfTopHeavy(nb);
3654
3985
  this.o.onPiece?.(piece);
3655
3986
  }
3987
+ /**
3988
+ * Furniture that lost what it stood on falls: with every leg of a table broken the top is loose, and with the legs of one side gone what is left topples. Feet are the
3989
+ * meshes at the bottom of the object as it was whole; while the ones left still span the same floor (a cupboard with a broken door, a table missing one leg) it stands.
3990
+ */
3991
+ loosenIfFootless(objectId) {
3992
+ const b = this.bounds.get(objectId);
3993
+ if (!b || !this.autoSplit) return;
3994
+ const left = this.meshesOf(b).filter((mv) => this.visuals.shown(mv));
3995
+ if (left.length === 0) return;
3996
+ const body = b.piece ?? this.pieceSys.get(objectId);
3997
+ if (body) {
3998
+ const { half, center } = this.measure(b.root, left);
3999
+ body.halfExtents = half;
4000
+ this.pieceSys.setCenter(body, center);
4001
+ body.mass = Math.max(0.05, this.massOf(objectId));
4002
+ body.sleeping = false;
4003
+ return;
4004
+ }
4005
+ if (kitOriginOf(b.root)?.kind !== "furniture") return;
4006
+ const boxOf = (mv) => this.visuals.worldBox(mv, new this.THREE.Box3());
4007
+ const whole = [...b.parts.values()].flatMap((pv) => pv.meshes).map(boxOf).filter((one) => !one.isEmpty());
4008
+ const floor = Math.min(...whole.map((one) => one.min.y));
4009
+ const feetOf = (boxes) => boxes.filter((one) => one.min.y < floor + FEET_BAND).reduce((feet, one) => feet.union(one), new this.THREE.Box3());
4010
+ const was = feetOf(whole);
4011
+ const now = feetOf(left.map(boxOf).filter((one) => !one.isEmpty()));
4012
+ const same = !now.isEmpty() && now.min.x <= was.min.x + FEET_BAND && now.max.x >= was.max.x - FEET_BAND && now.min.z <= was.min.z + FEET_BAND && now.max.z >= was.max.z - FEET_BAND;
4013
+ if (same) return;
4014
+ if (!now.isEmpty()) {
4015
+ const box = left.map(boxOf).reduce((all, one) => all.union(one), new this.THREE.Box3());
4016
+ const centreX = (box.min.x + box.max.x) / 2;
4017
+ const centreZ = (box.min.z + box.max.z) / 2;
4018
+ const height = box.max.y - box.min.y;
4019
+ const narrow = height >= TIP_MIN_HEIGHT && Math.min(now.max.x - now.min.x, now.max.z - now.min.z) < TIP_NARROW * height;
4020
+ if (!narrow && centreX > now.min.x && centreX < now.max.x && centreZ > now.min.z && centreZ < now.max.z) return;
4021
+ }
4022
+ const piece = this.makePiece(objectId);
4023
+ this.tipIfTopHeavy(b);
4024
+ this.o.onLoosened?.(objectId, piece, "apart");
4025
+ }
3656
4026
  /**
3657
4027
  * A piece of furniture standing on feet too close for its height (a board on two legs in a line, a lone leg) does not stay up: the box of a piece hides that its weight is up
3658
4028
  * high and its feet are thin, so it gets the start of the fall, across the line of its feet.
@@ -3684,7 +4054,8 @@ var Renderer = class {
3684
4054
  const report = options.report;
3685
4055
  const confinement = report?.find((r) => r.blast !== void 0)?.blast?.confinement ?? 1;
3686
4056
  const energy = ev.energy * confinement;
3687
- const reachOf = (objectId, at) => report?.find((r) => r.object === objectId)?.blast?.shelter ?? this.world.blastReach?.(p, at, { ignore: ev.object ? [ev.object, objectId] : [objectId] }) ?? 1;
4057
+ const wentOff = this.coverWhenWentOff.get(ev);
4058
+ const reachOf = (objectId, at) => report?.find((r) => r.object === objectId)?.blast?.shelter ?? wentOff?.get(objectId) ?? this.world.blastReach?.(p, at, { ignore: ev.object ? [ev.object, objectId] : [objectId] }) ?? 1;
3688
4059
  const shove = (pc) => {
3689
4060
  this.pieceSys.centerOf(pc, c);
3690
4061
  if (c.distanceTo(this.v2.set(p[0], p[1], p[2])) > ev.radius) return;
@@ -3704,6 +4075,29 @@ var Renderer = class {
3704
4075
  ...report ? { confinement } : {}
3705
4076
  });
3706
4077
  }
4078
+ /** The cover between a part of the world that goes off and the centre of each piece and each shoveable body within its radius, as the world stands at that moment. */
4079
+ coverAround(ev) {
4080
+ const cover = /* @__PURE__ */ new Map();
4081
+ const reach = this.world.blastReach;
4082
+ if (!reach) return cover;
4083
+ const point = new this.THREE.Vector3(ev.point[0], ev.point[1], ev.point[2]);
4084
+ const centre = new this.THREE.Vector3();
4085
+ const read = (objectId) => {
4086
+ if (centre.distanceTo(point) <= ev.radius) cover.set(objectId, reach.call(this.world, ev.point, [centre.x, centre.y, centre.z], { ignore: [ev.object, objectId] }));
4087
+ };
4088
+ for (const pc of this.pieceSys.list) {
4089
+ this.pieceSys.centerOf(pc, centre);
4090
+ read(pc.objectId);
4091
+ }
4092
+ const shoveable = this.o.shoveable;
4093
+ if (!shoveable) return cover;
4094
+ for (const b of this.bounds.values()) {
4095
+ if (b.piece || cover.has(b.id) || !shoveable(b.id) || !this.world.has(b.id)) continue;
4096
+ centre.copy(this.measure(b.root, this.meshesOf(b)).center).applyQuaternion(b.root.quaternion).add(b.root.position);
4097
+ read(b.id);
4098
+ }
4099
+ return cover;
4100
+ }
3707
4101
  /** The bound objects without a piece that the host lets a blast move (`shoveable`): the ones it reaches with enough speed become pieces and get the shove. */
3708
4102
  loosen(point, radius, energy, reachOf) {
3709
4103
  const shoveable = this.o.shoveable;