@adatechnology/game-floorplan-three 0.7.0 → 0.8.1

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
@@ -6236,6 +6236,10 @@ function installDestructionPieces(K, shared) {
6236
6236
  function collapseRange(mesh, v0, v1) {
6237
6237
  const pos = mesh.geometry.attributes.position;
6238
6238
  const a = pos.array;
6239
+ const u = mesh.userData;
6240
+ if (!u._p0) u._p0 = a.slice();
6241
+ const list = u.collapsedRanges ?? (u.collapsedRanges = []);
6242
+ if (!list.some((r) => r[0] === v0 && r[1] === v1)) list.push([v0, v1]);
6239
6243
  const saved = a.slice(v0 * 3, v1 * 3);
6240
6244
  const x = a[v0 * 3], y = a[v0 * 3 + 1], z = a[v0 * 3 + 2];
6241
6245
  for (let k = v0; k < v1; k++) {
@@ -6250,6 +6254,8 @@ function restoreRange(mesh, v0, saved) {
6250
6254
  const pos = mesh.geometry.attributes.position;
6251
6255
  pos.array.set(saved, v0 * 3);
6252
6256
  pos.needsUpdate = true;
6257
+ const list = mesh.userData.collapsedRanges;
6258
+ if (list) mesh.userData.collapsedRanges = list.filter((r) => r[0] !== v0);
6253
6259
  }
6254
6260
  function noise2(x, y, cell) {
6255
6261
  const h = (i2, j2) => {
@@ -6264,13 +6270,22 @@ var MASK_TEXEL = 0.02;
6264
6270
  var SPALL_MIN = 0.025;
6265
6271
  var SPALL_RAGGED = 0.045;
6266
6272
  var COVER = 0.015;
6273
+ var MAX_FLAKES = 64;
6274
+ function clipToBox(unit, box) {
6275
+ const lo = [Math.max(unit.x - unit.sx / 2, box[0]), Math.max(unit.y - unit.sy / 2, box[1]), Math.max(unit.z - unit.sz / 2, box[2])];
6276
+ const hi = [Math.min(unit.x + unit.sx / 2, box[3]), Math.min(unit.y + unit.sy / 2, box[4]), Math.min(unit.z + unit.sz / 2, box[5])];
6277
+ if (hi[0] - lo[0] <= 1e-4 || hi[1] - lo[1] <= 1e-4 || hi[2] - lo[2] <= 1e-4) return null;
6278
+ return { x: (lo[0] + hi[0]) / 2, y: (lo[1] + hi[1]) / 2, z: (lo[2] + hi[2]) / 2, sx: hi[0] - lo[0], sy: hi[1] - lo[1], sz: hi[2] - lo[2], colour: unit.colour };
6279
+ }
6267
6280
  function installDestructionBreach(K, _shared) {
6268
6281
  const { THREE, fragmentMaterial, invalidate, invalidateShadow } = K;
6269
6282
  function createBreachView(opts) {
6270
6283
  const { house, structure } = opts;
6271
6284
  const views = /* @__PURE__ */ new Map();
6285
+ const dirty = /* @__PURE__ */ new Set();
6286
+ const paintBudgetMs = opts.paintBudgetMs ?? Infinity;
6287
+ const dropped = [];
6272
6288
  let lod = 0;
6273
- const unitMaterial = new THREE.MeshLambertMaterial({ color: 16777215 });
6274
6289
  const unitGeometry = new THREE.BoxGeometry(1, 1, 1);
6275
6290
  const tmp = { m: new THREE.Matrix4(), q: new THREE.Quaternion(), p: new THREE.Vector3(), s: new THREE.Vector3(), c: new THREE.Color() };
6276
6291
  const local = (p) => {
@@ -6339,6 +6354,11 @@ function installDestructionBreach(K, _shared) {
6339
6354
  const mesh = new THREE.Mesh(g, material);
6340
6355
  mesh.name = "breach-shell";
6341
6356
  mesh.userData.breachMask = mask;
6357
+ mesh.userData.cutAt = (p) => {
6358
+ const v = local([p.x, p.y, p.z]);
6359
+ const i = Math.floor(((mask.alongU ? v[0] : v[2]) - mask.start) / mask.len * mask.w), j = Math.floor((v[1] - mask.y0) / mask.height * mask.h);
6360
+ return i >= 0 && i < mask.w && j >= 0 && j < mask.h && mask.data[j * mask.w + i] > 127;
6361
+ };
6342
6362
  mesh.position.copy(it.mesh.position);
6343
6363
  mesh.quaternion.copy(it.mesh.quaternion);
6344
6364
  mesh.scale.copy(it.mesh.scale);
@@ -6354,6 +6374,29 @@ function installDestructionBreach(K, _shared) {
6354
6374
  toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6355
6375
  return mesh;
6356
6376
  }
6377
+ function unitsMaterial(core) {
6378
+ const material = new THREE.MeshLambertMaterial({ color: 16777215, side: THREE.DoubleSide });
6379
+ material.onBeforeCompile = (sh) => {
6380
+ sh.uniforms.uCore = { value: core.texture };
6381
+ sh.uniforms.uCoreRect = { value: core.rect };
6382
+ sh.uniforms.uCoreAlongX = { value: core.alongU ? 1 : 0 };
6383
+ sh.vertexShader = sh.vertexShader.replace("void main() {", "varying vec3 vHousePos;\nvoid main() {").replace("#include <begin_vertex>", "#include <begin_vertex>\n#ifdef USE_INSTANCING\nvHousePos = (instanceMatrix * vec4(transformed, 1.0)).xyz;\n#else\nvHousePos = transformed;\n#endif");
6384
+ sh.fragmentShader = sh.fragmentShader.replace("void main() {", "uniform sampler2D uCore;\nuniform vec4 uCoreRect;\nuniform float uCoreAlongX;\nvarying vec3 vHousePos;\nvoid main() {\n vec2 cUv = vec2(((uCoreAlongX > 0.5 ? vHousePos.x : vHousePos.z) - uCoreRect.x) / uCoreRect.z, (vHousePos.y - uCoreRect.y) / uCoreRect.w);\n if (cUv.x >= 0.0 && cUv.x <= 1.0 && cUv.y >= 0.0 && cUv.y <= 1.0 && texture2D(uCore, cUv).r > 0.5) discard;").replace("#include <color_fragment>", "#include <color_fragment>\nif (!gl_FrontFacing) diffuseColor.rgb *= 0.55;");
6385
+ };
6386
+ material.customProgramCacheKey = () => "breachCore";
6387
+ material.userData.breachCore = core;
6388
+ return material;
6389
+ }
6390
+ function drawnBounds(items) {
6391
+ house.updateMatrixWorld(true);
6392
+ const box = new THREE.Box3(), toHouse = new THREE.Matrix4().copy(house.matrixWorld).invert();
6393
+ for (const it of items) {
6394
+ const a = it.mesh.userData._p0 || it.mesh.geometry.attributes.position.array;
6395
+ tmp.m.multiplyMatrices(toHouse, it.mesh.matrixWorld);
6396
+ for (let k = it.v0; k < it.v1; k++) box.expandByPoint(tmp.p.set(a[k * 3], a[k * 3 + 1], a[k * 3 + 2]).applyMatrix4(tmp.m));
6397
+ }
6398
+ return [box.min.x, box.min.y, box.min.z, box.max.x, box.max.y, box.max.z];
6399
+ }
6357
6400
  function maskOf(hostId, parts) {
6358
6401
  const host = structure.byId.get(hostId);
6359
6402
  const [u0, v0, u1, v1] = host.aabb;
@@ -6369,51 +6412,86 @@ function installDestructionBreach(K, _shared) {
6369
6412
  return { data, texture, w, h, alongU, start: alongU ? u0 : v0, len, y0, height: y1 - y0, rect: new THREE.Vector4(alongU ? u0 : v0, y0, len, y1 - y0) };
6370
6413
  }
6371
6414
  const boxDistance = (s, y, a, b, c, d) => Math.hypot(Math.max(a - s, 0, s - b), Math.max(c - y, 0, y - d));
6415
+ const ellipseDistance = (s, y, cs, cy, rs, ry) => (Math.hypot((s - cs) / Math.max(rs, 1e-4), (y - cy) / Math.max(ry, 1e-4)) - 1) * Math.min(rs, ry);
6416
+ function covered(c, boxes) {
6417
+ const n = 8;
6418
+ let hit = 0;
6419
+ for (let j = 0; j < n; j++) {
6420
+ const y = c.y0 + (j + 0.5) / n * (c.y1 - c.y0);
6421
+ for (let i = 0; i < n; i++) {
6422
+ const s = c.a + (i + 0.5) / n * (c.b - c.a);
6423
+ if (boxes.some((k) => s >= k.s0 && s <= k.s1 && y >= k.y0 && y <= k.y1)) hit++;
6424
+ }
6425
+ }
6426
+ return hit / (n * n);
6427
+ }
6372
6428
  function paint(view) {
6373
- const { mask } = view;
6429
+ const { mask, core } = view;
6374
6430
  const along = (p) => (mask.alongU ? p[0] : p[2]) - mask.start;
6375
6431
  const holes = [];
6432
+ const pits = [];
6376
6433
  const discs = [];
6434
+ const flakes = [];
6377
6435
  const shown = [];
6378
6436
  for (const id of view.parts) {
6379
- const part = structure.byId.get(id), units2 = specOf(id).units, chips = view.chips.get(id), opening = view.openings.get(id);
6437
+ const part = structure.byId.get(id), units2 = specOf(id).units, chips = view.chips.get(id), opening = view.openings.get(id), spalls = view.spalls.get(id);
6380
6438
  const all = cells(part, units2);
6381
6439
  const thick = mask.alongU ? part.aabb[3] - part.aabb[1] : part.aabb[2] - part.aabb[0];
6382
6440
  const mid = mask.alongU ? (part.aabb[1] + part.aabb[3]) / 2 : (part.aabb[0] + part.aabb[2]) / 2;
6383
6441
  if (opening) discs.push({ s: along(opening.center), y: opening.center[1], r: opening.radius });
6384
- const gone = (c) => chips.some((p) => p[1] >= c.y0 && p[1] <= c.y1 && along(p) >= c.a && along(p) <= c.b) || opening && Math.hypot((c.a + c.b) / 2 - along(opening.center), (c.y0 + c.y1) / 2 - opening.center[1]) < opening.radius;
6442
+ for (const k of spalls) flakes.push({ s: along(k.center), y: k.center[1], r: k.radius });
6443
+ const boxes = chips.map((k) => ({ s0: along(k.center) - k.along, s1: along(k.center) + k.along, y0: k.center[1] - k.up, y1: k.center[1] + k.up }));
6444
+ const gone = (c) => covered(c, boxes.filter((k) => k.s1 > c.a && k.s0 < c.b && k.y1 > c.y0 && k.y0 < c.y1)) >= 0.9 || opening && Math.hypot((c.a + c.b) / 2 - along(opening.center), (c.y0 + c.y1) / 2 - opening.center[1]) < opening.radius;
6385
6445
  const kept = [];
6386
6446
  for (const c of all) (gone(c) ? holes : kept).push(c);
6387
6447
  for (const c of kept) shown.push({ c, part, units: units2, thick, mid });
6448
+ for (const k of boxes) {
6449
+ if (holes.some((h) => k.s0 >= h.a - 1e-4 && k.s1 <= h.b + 1e-4 && k.y0 >= h.y0 - 1e-4 && k.y1 <= h.y1 + 1e-4)) continue;
6450
+ pits.push({ s: (k.s0 + k.s1) / 2, y: (k.y0 + k.y1) / 2, rs: (k.s1 - k.s0) / 2, ry: (k.y1 - k.y0) / 2 });
6451
+ }
6388
6452
  }
6389
6453
  const reach = SPALL_MIN + SPALL_RAGGED;
6454
+ const ragged = (s, y) => 0.6 * noise2(s, y, 0.03) + 0.4 * noise2(s + 7.3, y - 3.1, 0.011);
6390
6455
  mask.data.fill(0);
6391
- const stamp = (s0, s1, y0, y1, inside) => {
6392
- const i0 = Math.max(0, Math.floor((s0 - reach) / mask.len * mask.w)), i1 = Math.min(mask.w - 1, Math.ceil((s1 + reach) / mask.len * mask.w));
6393
- const j0 = Math.max(0, Math.floor((y0 - reach - mask.y0) / mask.height * mask.h)), j1 = Math.min(mask.h - 1, Math.ceil((y1 + reach - mask.y0) / mask.height * mask.h));
6456
+ core.data.fill(0);
6457
+ const stamp = (m, s0, s1, y0, y1, inside) => {
6458
+ const i0 = Math.max(0, Math.floor((s0 - reach) / m.len * m.w)), i1 = Math.min(m.w - 1, Math.ceil((s1 + reach) / m.len * m.w));
6459
+ const j0 = Math.max(0, Math.floor((y0 - reach - m.y0) / m.height * m.h)), j1 = Math.min(m.h - 1, Math.ceil((y1 + reach - m.y0) / m.height * m.h));
6394
6460
  for (let j = j0; j <= j1; j++) {
6395
- const y = mask.y0 + (j + 0.5) / mask.h * mask.height;
6461
+ const y = m.y0 + (j + 0.5) / m.h * m.height;
6396
6462
  for (let i = i0; i <= i1; i++) {
6397
- const s = (i + 0.5) / mask.w * mask.len;
6398
- const edge = SPALL_MIN + SPALL_RAGGED * (0.6 * noise2(s, y, 0.03) + 0.4 * noise2(s + 7.3, y - 3.1, 0.011));
6399
- if (inside(s, y) <= edge) mask.data[j * mask.w + i] = 255;
6463
+ const s = (i + 0.5) / m.w * m.len;
6464
+ if (inside(s, y)) m.data[j * m.w + i] = 255;
6400
6465
  }
6401
6466
  }
6402
6467
  };
6403
- for (const c of holes) stamp(c.a, c.b, c.y0, c.y1, (s, y) => boxDistance(s, y, c.a, c.b, c.y0, c.y1));
6404
- for (const d of discs) stamp(d.s - d.r, d.s + d.r, d.y - d.r, d.y + d.r, (s, y) => Math.max(0, Math.hypot(s - d.s, y - d.y) - d.r));
6468
+ const plaster = (d, s, y) => d <= SPALL_MIN + SPALL_RAGGED * ragged(s, y);
6469
+ for (const c of holes) stamp(mask, c.a, c.b, c.y0, c.y1, (s, y) => plaster(boxDistance(s, y, c.a, c.b, c.y0, c.y1), s, y));
6470
+ for (const d of discs) stamp(mask, d.s - d.r, d.s + d.r, d.y - d.r, d.y + d.r, (s, y) => plaster(Math.max(0, Math.hypot(s - d.s, y - d.y) - d.r), s, y));
6471
+ for (const f of flakes) {
6472
+ const rough = (s, y) => Math.hypot(s - f.s, y - f.y) - f.r * (0.65 + 0.7 * ragged(s * 2.3, y * 2.3));
6473
+ stamp(mask, f.s - 2 * f.r, f.s + 2 * f.r, f.y - 2 * f.r, f.y + 2 * f.r, (s, y) => rough(s, y) <= 0);
6474
+ }
6475
+ for (const p of pits) {
6476
+ stamp(mask, p.s - p.rs, p.s + p.rs, p.y - p.ry, p.y + p.ry, (s, y) => plaster(Math.max(0, ellipseDistance(s, y, p.s, p.y, p.rs, p.ry)), s, y));
6477
+ stamp(core, p.s - p.rs, p.s + p.rs, p.y - p.ry, p.y + p.ry, (s, y) => ellipseDistance(s, y, p.s, p.y, p.rs, p.ry) <= -0.35 * Math.min(p.rs, p.ry) * noise2(s * 1.7, y * 1.3, 0.012));
6478
+ }
6405
6479
  mask.texture.needsUpdate = true;
6406
- const near = (c) => holes.some((h) => boxDistance((c.a + c.b) / 2, (c.y0 + c.y1) / 2, h.a - (c.b - c.a) / 2, h.b + (c.b - c.a) / 2, h.y0 - (c.y1 - c.y0) / 2, h.y1 + (c.y1 - c.y0) / 2) <= reach) || discs.some((d) => boxDistance(d.s, d.y, c.a, c.b, c.y0, c.y1) <= d.r + reach);
6480
+ core.texture.needsUpdate = true;
6481
+ const near = (c) => holes.some((h) => boxDistance((c.a + c.b) / 2, (c.y0 + c.y1) / 2, h.a - (c.b - c.a) / 2, h.b + (c.b - c.a) / 2, h.y0 - (c.y1 - c.y0) / 2, h.y1 + (c.y1 - c.y0) / 2) <= reach) || discs.some((d) => boxDistance(d.s, d.y, c.a, c.b, c.y0, c.y1) <= d.r + reach) || pits.some((p) => boxDistance(p.s, p.y, c.a, c.b, c.y0, c.y1) <= Math.max(p.rs, p.ry) + reach) || flakes.some((f) => boxDistance(f.s, f.y, c.a, c.b, c.y0, c.y1) <= 2 * f.r);
6407
6482
  const list = [];
6408
6483
  const tone = (hex, k) => tmp.c.setHex(hex).multiplyScalar(k).getHex();
6409
6484
  for (const { c, part, units: units2, thick, mid } of shown) {
6410
6485
  if (!near(c)) continue;
6411
6486
  const centre = mask.start + (c.a + c.b) / 2, y = (c.y0 + c.y1) / 2, len = c.b - c.a, h = c.y1 - c.y0;
6412
- const at = (s, dy, d, colour) => list.push({ x: mask.alongU ? centre : mid, y, z: mask.alongU ? mid : centre, sx: mask.alongU ? s : d, sy: dy, sz: mask.alongU ? d : s, colour });
6487
+ const at = (s, dy, d, colour) => {
6488
+ const unit = clipToBox({ x: mask.alongU ? centre : mid, y, z: mask.alongU ? mid : centre, sx: mask.alongU ? s : d, sy: dy, sz: mask.alongU ? d : s, colour }, view.bounds);
6489
+ if (unit) list.push(unit);
6490
+ };
6413
6491
  const base = fragmentMaterial(part.material).color.getHex();
6414
6492
  const n = noise2(centre * 3.1, y * 2.7, 0.07);
6415
6493
  if (units2.mortar === "concrete") {
6416
- at(len, h, thick - 2 * COVER - 0.03 * n, tone(base, 0.86 + 0.2 * n));
6494
+ at(len, h, thick - 2 * COVER, tone(base, 0.9 + 0.04 * n));
6417
6495
  } else {
6418
6496
  at(len, h, thick - 2 * COVER - 0.012, 10327948);
6419
6497
  at(len - 0.012, h - 0.012, thick - 2 * COVER, tone(base, 0.8 + 0.35 * n));
@@ -6423,7 +6501,7 @@ function installDestructionBreach(K, _shared) {
6423
6501
  view.units.removeFromParent();
6424
6502
  view.units.dispose();
6425
6503
  }
6426
- const units = new THREE.InstancedMesh(unitGeometry, unitMaterial, Math.max(1, list.length));
6504
+ const units = new THREE.InstancedMesh(unitGeometry, view.unitMaterial, Math.max(1, list.length));
6427
6505
  units.name = "breach:" + view.host;
6428
6506
  list.forEach((u, i) => {
6429
6507
  tmp.m.compose(tmp.p.set(u.x, u.y, u.z), tmp.q.identity(), tmp.s.set(u.sx, u.sy, u.sz));
@@ -6439,6 +6517,19 @@ function installDestructionBreach(K, _shared) {
6439
6517
  house.add(units);
6440
6518
  view.units = units;
6441
6519
  view.gone = holes.length;
6520
+ view.pits = pits.length;
6521
+ view.flakes = flakes.length;
6522
+ }
6523
+ function chipBox(chip, alongU) {
6524
+ house.updateMatrixWorld(true);
6525
+ const q = tmp.q.set(chip.rotation[0], chip.rotation[1], chip.rotation[2], chip.rotation[3]).premultiply(house.getWorldQuaternion(new THREE.Quaternion()).invert());
6526
+ let a = 0, u = 0;
6527
+ for (let k = 0; k < 3; k++) {
6528
+ const axis = tmp.p.set(k === 0 ? 1 : 0, k === 1 ? 1 : 0, k === 2 ? 1 : 0).applyQuaternion(q);
6529
+ a += Math.abs(alongU ? axis.x : axis.z) * chip.size[k] / 2;
6530
+ u += Math.abs(axis.y) * chip.size[k] / 2;
6531
+ }
6532
+ return { center: local(chip.center), along: a, up: u };
6442
6533
  }
6443
6534
  function draw2(partId, change) {
6444
6535
  if (lod !== 0) return false;
@@ -6450,23 +6541,50 @@ function installDestructionBreach(K, _shared) {
6450
6541
  if (!items.length) return false;
6451
6542
  const parts = [hostId, ...riders(hostId)].filter((id) => structure.byId.get(id) && specOf(id)?.units);
6452
6543
  if (!parts.includes(partId)) return false;
6453
- const mask = maskOf(hostId, parts);
6454
- view = { host: hostId, parts, items, mask, shells: [], saved: null, units: null, gone: 0, chips: new Map(parts.map((id) => [id, []])), openings: /* @__PURE__ */ new Map() };
6544
+ const mask = maskOf(hostId, parts), core = maskOf(hostId, parts);
6545
+ view = {
6546
+ host: hostId,
6547
+ parts,
6548
+ items,
6549
+ mask,
6550
+ core,
6551
+ unitMaterial: unitsMaterial(core),
6552
+ shells: [],
6553
+ saved: null,
6554
+ units: null,
6555
+ gone: 0,
6556
+ pits: 0,
6557
+ bounds: drawnBounds(items),
6558
+ chips: new Map(parts.map((id) => [id, []])),
6559
+ spalls: new Map(parts.map((id) => [id, []])),
6560
+ openings: /* @__PURE__ */ new Map(),
6561
+ flakes: 0
6562
+ };
6455
6563
  }
6456
6564
  if (!view.chips.has(partId)) return false;
6457
6565
  if (change.opening) {
6458
6566
  if (view.openings.has(partId)) return false;
6459
6567
  view.openings.set(partId, { center: local(change.opening.center), radius: change.opening.radius });
6460
6568
  }
6461
- if (change.chip) view.chips.get(partId).push(local(change.chip));
6569
+ if (change.chip) view.chips.get(partId).push(chipBox(change.chip, view.mask.alongU));
6570
+ if (change.spall) {
6571
+ const list = view.spalls.get(partId);
6572
+ list.push({ center: local(change.spall.center), radius: change.spall.radius });
6573
+ if (list.length > MAX_FLAKES) list.shift();
6574
+ }
6462
6575
  if (!view.saved) {
6463
6576
  view.shells = view.items.map((it) => shell(it, view.mask));
6577
+ for (const m of view.shells) m.userData.behind = () => view.units;
6464
6578
  view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6465
6579
  }
6466
- paint(view);
6467
6580
  views.set(hostId, view);
6468
- invalidate();
6469
- invalidateShadow();
6581
+ if (opts.deferPaint && change.spall) dirty.add(view);
6582
+ else {
6583
+ dirty.delete(view);
6584
+ paint(view);
6585
+ invalidate();
6586
+ invalidateShadow();
6587
+ }
6470
6588
  return true;
6471
6589
  }
6472
6590
  function plain(view, shown) {
@@ -6475,24 +6593,70 @@ function installDestructionBreach(K, _shared) {
6475
6593
  else s.data = collapseRange(s.it.mesh, s.v0, s.it.v1);
6476
6594
  }
6477
6595
  }
6596
+ function flush(budgetMs = paintBudgetMs) {
6597
+ if (dirty.size === 0) return 0;
6598
+ const deadline = performance.now() + budgetMs;
6599
+ for (const view of dirty) {
6600
+ dirty.delete(view);
6601
+ paint(view);
6602
+ if (performance.now() >= deadline) break;
6603
+ }
6604
+ invalidate();
6605
+ invalidateShadow();
6606
+ return dirty.size;
6607
+ }
6478
6608
  function dispose(view) {
6479
- view.units.removeFromParent();
6480
- view.units.dispose();
6609
+ dirty.delete(view);
6610
+ if (view.units) {
6611
+ view.units.removeFromParent();
6612
+ view.units.dispose();
6613
+ }
6614
+ view.unitMaterial.dispose();
6481
6615
  for (const m of view.shells) {
6482
6616
  m.removeFromParent();
6483
6617
  m.geometry.dispose();
6484
6618
  m.material.dispose();
6485
6619
  }
6486
6620
  view.mask.texture.dispose();
6621
+ view.core.texture.dispose();
6487
6622
  }
6488
6623
  return {
6489
6624
  /** Handles a `stage` event of the house's object. Returns true when the wall was cut. */
6490
6625
  stage(event) {
6491
6626
  return event.stage === 2 && !!event.opening ? draw2(event.part, { opening: event.opening }) : false;
6492
6627
  },
6493
- /** Handles a `chip` event: the unit that came out leaves a hole in the wall where it was. Returns true when the wall was cut. */
6628
+ /**
6629
+ * Handles a `spall` of a wall of units: the coat comes off in a ragged patch and the units show under it, drawn by the house (the destruction
6630
+ * renderer leaves its flat patch out on the meshes `claim` marked). Returns true when the wall was cut.
6631
+ */
6632
+ spall(event) {
6633
+ const r = Math.sqrt(Math.max(0, event.area) / Math.PI);
6634
+ return r > 0 ? draw2(event.part, { spall: { center: event.point, radius: r } }) : false;
6635
+ },
6636
+ /**
6637
+ * Marks the meshes that draw a wall of units as drawing its spalls itself (`userData.drawnSpalls`, the part ids): the destruction renderer then
6638
+ * throws the bits and leaves its patch out. Call it once the spec is known.
6639
+ */
6640
+ claim() {
6641
+ var _a;
6642
+ for (const q of opts.spec()?.parts ?? []) {
6643
+ if (!q.units) continue;
6644
+ const host = hostOf(q.id);
6645
+ if (!host) continue;
6646
+ for (const it of structure.meshesOf(host)) {
6647
+ if (!it.mesh) continue;
6648
+ const list = (_a = it.mesh.userData).drawnSpalls ?? (_a.drawnSpalls = []);
6649
+ if (!list.includes(q.id)) list.push(q.id);
6650
+ }
6651
+ }
6652
+ },
6653
+ /**
6654
+ * Handles a `chip` event: what came out (a unit, or a crater a shot broke through one) leaves a hole in the wall where it was; a `torn` chip (what a blow
6655
+ * tore off a face) leaves the marks the renderer drew. Returns true when the wall was cut.
6656
+ */
6494
6657
  chip(event) {
6495
- return draw2(event.part, { chip: event.center });
6658
+ if (event.torn) return false;
6659
+ return draw2(event.part, { chip: { center: event.center, size: event.size ?? [0, 0, 0], rotation: event.rotation ?? [0, 0, 0, 1] } });
6496
6660
  },
6497
6661
  /** The part broke or fell: its damage goes (the plain range stays collapsed, the renderer hides the part). */
6498
6662
  drop(partId) {
@@ -6500,9 +6664,11 @@ function installDestructionBreach(K, _shared) {
6500
6664
  if (!v) return;
6501
6665
  dispose(v);
6502
6666
  views.delete(partId);
6667
+ if (v.saved) dropped.push(v);
6503
6668
  },
6504
6669
  /** LOD 0 draws the cut wall; LOD 1 and 2 give the wall its box back. */
6505
6670
  setLod(level) {
6671
+ flush(Infinity);
6506
6672
  const was = lod;
6507
6673
  lod = level;
6508
6674
  if (was === 0 === (level === 0)) return;
@@ -6521,15 +6687,20 @@ function installDestructionBreach(K, _shared) {
6521
6687
  if (lod === 0) plain(v, true);
6522
6688
  }
6523
6689
  views.clear();
6690
+ for (const v of dropped) plain(v, true);
6691
+ dropped.length = 0;
6524
6692
  },
6693
+ flush,
6525
6694
  /** The wall has damage drawn. */
6526
6695
  has(partId) {
6527
6696
  return views.has(partId);
6528
6697
  },
6529
- /** Walls with damage drawn, units out of them, and units laid bare at the edge of the cuts. */
6530
- statistics: () => ({
6698
+ /** Walls with damage drawn, units out of them, craters shots broke through units that stay, and units laid bare at the edge of the cuts. */
6699
+ statistics: () => (flush(Infinity), {
6531
6700
  walls: views.size,
6532
6701
  gone: [...views.values()].reduce((n, v) => n + v.gone, 0),
6702
+ craters: [...views.values()].reduce((n, v) => n + v.pits, 0),
6703
+ flakes: [...views.values()].reduce((n, v) => n + v.flakes, 0),
6533
6704
  units: [...views.values()].reduce((n, v) => n + v.units.count, 0)
6534
6705
  })
6535
6706
  };
@@ -6538,6 +6709,7 @@ function installDestructionBreach(K, _shared) {
6538
6709
  }
6539
6710
 
6540
6711
  // src/destruction/world.ts
6712
+ var FOLLOW_FLUSH_FALLBACK_MS = 100;
6541
6713
  function installDestructionWorld(K, shared) {
6542
6714
  const { THREE, cfg, invalidateShadow, invalidate, rngString, FRAGMENT_GEO, fragmentMaterial, createBreachView } = K;
6543
6715
  function createHouseDestruction(root, house, structure, parts, colliders, water) {
@@ -6546,7 +6718,7 @@ function installDestructionWorld(K, shared) {
6546
6718
  const R = cfg.destructionRenderer || null, listeners = /* @__PURE__ */ new Set();
6547
6719
  D.renderer = R;
6548
6720
  D.spec = null;
6549
- const breach = createBreachView({ house, structure, spec: () => D.spec });
6721
+ const breach = createBreachView({ house, structure, spec: () => D.spec, deferPaint: true, ...cfg.breachPaintBudgetMs !== void 0 ? { paintBudgetMs: cfg.breachPaintBudgetMs } : {} });
6550
6722
  D.breach = breach;
6551
6723
  D.onEvent = (fn) => {
6552
6724
  listeners.add(fn);
@@ -6731,6 +6903,9 @@ function installDestructionWorld(K, shared) {
6731
6903
  case "chip":
6732
6904
  breach.chip(e);
6733
6905
  break;
6906
+ case "spall":
6907
+ breach.spall(e);
6908
+ break;
6734
6909
  case "collapse":
6735
6910
  for (const id of e.parts) {
6736
6911
  breach.drop(id);
@@ -6772,7 +6947,8 @@ function installDestructionWorld(K, shared) {
6772
6947
  D.specOptions = specOptions || null;
6773
6948
  D.spec = structure.spec(specOptions);
6774
6949
  D.id = D.world.add(D.spec, { id: "house3d:" + ++shared.destructionSeq, position: [p.x, p.y, p.z], rotation: [q.x, q.y, q.z, q.w] });
6775
- if (R) R.bind(D.id, house, { parts: rangesOf() });
6950
+ breach.claim();
6951
+ if (R) R.bind(D.id, house, { parts: rangesOf(), onFrame: () => breach.flush() });
6776
6952
  off = D.world.on("*", handleEvent);
6777
6953
  D.active = true;
6778
6954
  return D;
@@ -6787,6 +6963,8 @@ function installDestructionWorld(K, shared) {
6787
6963
  };
6788
6964
  D.follow = (world, id, spec) => {
6789
6965
  D.spec = spec;
6966
+ breach.claim();
6967
+ let fallback = null;
6790
6968
  const offFollow = world.on("*", (e) => {
6791
6969
  if (e.object !== id) return;
6792
6970
  switch (e.type) {
@@ -6796,6 +6974,9 @@ function installDestructionWorld(K, shared) {
6796
6974
  case "chip":
6797
6975
  breach.chip(e);
6798
6976
  break;
6977
+ case "spall":
6978
+ breach.spall(e);
6979
+ break;
6799
6980
  case "fracture":
6800
6981
  breach.drop(e.part);
6801
6982
  break;
@@ -6805,10 +6986,16 @@ function installDestructionWorld(K, shared) {
6805
6986
  default:
6806
6987
  break;
6807
6988
  }
6989
+ if (!fallback && typeof setTimeout === "function") fallback = setTimeout(() => {
6990
+ fallback = null;
6991
+ breach.flush(Infinity);
6992
+ }, FOLLOW_FLUSH_FALLBACK_MS);
6808
6993
  });
6809
6994
  return { restore: () => breach.restore(), dispose: () => {
6810
6995
  offFollow();
6811
6996
  breach.restore();
6997
+ }, update: () => {
6998
+ breach.flush();
6812
6999
  } };
6813
7000
  };
6814
7001
  D.piece = (id) => D.active ? D.world.part(D.id, id) : null;
@@ -6841,6 +7028,7 @@ function installDestructionWorld(K, shared) {
6841
7028
  return r;
6842
7029
  };
6843
7030
  D.animate = (dt) => {
7031
+ breach.flush();
6844
7032
  if (R) return;
6845
7033
  let moved = false;
6846
7034
  for (const [id, list] of bodies) {
@@ -8307,6 +8495,7 @@ function createFloorplanTestModule(options = {}) {
8307
8495
  houseRenderer = create(THREE, {
8308
8496
  locale,
8309
8497
  lod: { interior: 10, hide: 40 },
8498
+ breachPaintBudgetMs: 4,
8310
8499
  ...options.house,
8311
8500
  quality: settings.quality,
8312
8501
  ...options.world ? { destruction: options.world } : {},
@@ -8462,9 +8651,39 @@ function createFloorplanTestModule(options = {}) {
8462
8651
  host?.log(text.steps.no_door);
8463
8652
  return;
8464
8653
  }
8465
- p.house.interact(eyeOf(p));
8654
+ const fo = target.target;
8655
+ const reach = fo && host?.reachFor && fo.type === "swing" && fo.gs?.[0] && fo.handles?.[0] ? doorReach(p, fo) : null;
8656
+ if (reach && host?.reachFor) {
8657
+ host.reachFor({ ...reach, onGrip: () => void p.house.toggleDoor(fo) });
8658
+ } else p.house.interact(eyeOf(p));
8466
8659
  host?.log(fill(text.steps.door, { text: String(target.text) }));
8467
8660
  };
8661
+ const doorReach = (p, fo) => {
8662
+ const g = fo.gs[0];
8663
+ const pivot = fo.handles[0].pivot;
8664
+ const at = (angle) => {
8665
+ const c = Math.cos(angle);
8666
+ const s = Math.sin(angle);
8667
+ const v = new THREE.Vector3(pivot[0] * c + pivot[2] * s, pivot[1], -pivot[0] * s + pivot[2] * c).add(g.position);
8668
+ return g.parent ? g.parent.localToWorld(v) : v;
8669
+ };
8670
+ const camera = host?.camera;
8671
+ const eye = new THREE.Vector3(camera.position.x, 0, camera.position.z);
8672
+ const flat = (v) => Math.hypot(v.x - eye.x, v.z - eye.z);
8673
+ const closed = at(0);
8674
+ const open = at(fo.rot);
8675
+ const opening = !fo.open;
8676
+ return {
8677
+ handle: () => {
8678
+ g.updateMatrixWorld(true);
8679
+ return g.localToWorld(new THREE.Vector3(pivot[0], pivot[1], pivot[2])).toArray();
8680
+ },
8681
+ hinge: () => g.getWorldPosition(new THREE.Vector3()).toArray(),
8682
+ seconds: fo.locked || fo.blockedFlag ? 0.6 : 0.55,
8683
+ pull: opening ? flat(open) <= flat(closed) : flat(closed) <= flat(open),
8684
+ locked: !!(fo.locked || fo.blockedFlag)
8685
+ };
8686
+ };
8468
8687
  const strike = () => {
8469
8688
  const world = options.world;
8470
8689
  if (!world || !host) return;