@adatechnology/game-floorplan-three 0.5.0 → 0.7.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
@@ -6030,7 +6030,7 @@ function installLightsRoomLight(K, shared) {
6030
6030
 
6031
6031
  // src/destruction/pieces.ts
6032
6032
  import { assemblyPart, getAssembly } from "@adatechnology/game-destruction";
6033
- import { DENSITY, toDestructibleSpec } from "@adatechnology/game-floorplan-structure";
6033
+ import { DENSITY, MEMBER_CAGE, toDestructibleSpec } from "@adatechnology/game-floorplan-structure";
6034
6034
 
6035
6035
  // src/destruction/panels.ts
6036
6036
  import { resolveSlabPanels, slabPanelView } from "@adatechnology/game-floorplan-structure";
@@ -6210,7 +6210,11 @@ function installDestructionPieces(K, shared) {
6210
6210
  if (!pc || pc.unit) return sp;
6211
6211
  if (pc.role !== "wall" && pc.role !== "partition") {
6212
6212
  const def = getAssembly(pc.assembly || (pc.material === "concrete" && ["slab", "beam", "column", "stair"].includes(pc.role) ? "reinforced_concrete" : ""));
6213
- return def && def.reinforcement && !sp.reinforcement ? { ...sp, reinforcement: { ...def.reinforcement } } : sp;
6213
+ const out = def && def.reinforcement && !sp.reinforcement ? { ...sp, reinforcement: { ...def.reinforcement } } : sp;
6214
+ if (out.reinforcement && !out.units && pc.material === "concrete" && (pc.role === "beam" || pc.role === "column") && sp.shape.kind === "box") {
6215
+ return { ...out, units: { size: [MEMBER_CAGE.unit, MEMBER_CAGE.unit, Math.min(...sp.shape.size)], bond: "stack", mortar: "concrete" } };
6216
+ }
6217
+ return out;
6214
6218
  }
6215
6219
  if (!pc.assembly) return sp;
6216
6220
  const [u0, v0, u1, v1] = pc.aabb, alongU = u1 - u0 >= v1 - v0;
@@ -6247,6 +6251,19 @@ function restoreRange(mesh, v0, saved) {
6247
6251
  pos.array.set(saved, v0 * 3);
6248
6252
  pos.needsUpdate = true;
6249
6253
  }
6254
+ function noise2(x, y, cell) {
6255
+ const h = (i2, j2) => {
6256
+ const s = Math.sin(i2 * 127.1 + j2 * 311.7) * 43758.5453;
6257
+ return s - Math.floor(s);
6258
+ };
6259
+ const fx = x / cell, fy = y / cell, i = Math.floor(fx), j = Math.floor(fy), u = fx - i, v = fy - j;
6260
+ const su = u * u * (3 - 2 * u), sv = v * v * (3 - 2 * v);
6261
+ return (h(i, j) * (1 - su) + h(i + 1, j) * su) * (1 - sv) + (h(i, j + 1) * (1 - su) + h(i + 1, j + 1) * su) * sv;
6262
+ }
6263
+ var MASK_TEXEL = 0.02;
6264
+ var SPALL_MIN = 0.025;
6265
+ var SPALL_RAGGED = 0.045;
6266
+ var COVER = 0.015;
6250
6267
  function installDestructionBreach(K, _shared) {
6251
6268
  const { THREE, fragmentMaterial, invalidate, invalidateShadow } = K;
6252
6269
  function createBreachView(opts) {
@@ -6256,94 +6273,198 @@ function installDestructionBreach(K, _shared) {
6256
6273
  const unitMaterial = new THREE.MeshLambertMaterial({ color: 16777215 });
6257
6274
  const unitGeometry = new THREE.BoxGeometry(1, 1, 1);
6258
6275
  const tmp = { m: new THREE.Matrix4(), q: new THREE.Quaternion(), p: new THREE.Vector3(), s: new THREE.Vector3(), c: new THREE.Color() };
6259
- const wallColour = (items) => {
6260
- for (const it of items) {
6261
- if (!it.mesh) continue;
6262
- const c = it.mesh.geometry.attributes.color;
6263
- if (!c) continue;
6264
- const a = c.array;
6265
- tmp.c.setRGB(a[it.v0 * 3], a[it.v0 * 3 + 1], a[it.v0 * 3 + 2]);
6266
- return tmp.c.getHex();
6267
- }
6268
- return null;
6269
- };
6270
6276
  const local = (p) => {
6271
6277
  house.updateMatrixWorld(true);
6272
6278
  const v = house.worldToLocal(tmp.p.set(p[0], p[1], p[2]));
6273
6279
  return [v.x, v.y, v.z];
6274
6280
  };
6275
- function courses(part, units, opening, chips, paint) {
6281
+ function cells(part, units) {
6276
6282
  const [u0, v0, u1, v1] = part.aabb;
6277
6283
  const alongU = u1 - u0 >= v1 - v0;
6278
6284
  const len = alongU ? u1 - u0 : v1 - v0;
6279
- const thick = alongU ? v1 - v0 : u1 - u0;
6280
6285
  const [ul, uh] = units.size;
6281
6286
  const rows = Math.max(1, Math.round((part.y1 - part.y0) / uh));
6282
6287
  const rowH = (part.y1 - part.y0) / rows;
6283
6288
  const per = Math.max(1, Math.round(len / ul));
6284
6289
  const w = len / per;
6285
- const edge = fragmentMaterial(part.material).color.getHex();
6286
- const list = [];
6290
+ const out = [];
6287
6291
  for (let r = 0; r < rows; r++) {
6288
6292
  const shift = units.bond === "running" && r & 1 ? w / 2 : 0;
6289
- const cells = per + (shift ? 1 : 0);
6290
- for (let i = 0; i < cells; i++) {
6291
- let a = i * w - shift, b = a + w;
6292
- a = Math.max(0, a);
6293
- b = Math.min(len, b);
6294
- if (b - a < 1e-3) continue;
6295
- const mid = (a + b) / 2;
6296
- const x = alongU ? u0 + mid : (u0 + u1) / 2;
6297
- const z = alongU ? (v0 + v1) / 2 : v0 + mid;
6298
- const y = part.y0 + (r + 0.5) * rowH;
6299
- const d = opening ? Math.hypot(x - opening.center[0], y - opening.center[1], z - opening.center[2]) : Infinity;
6300
- if (opening && d < opening.radius) continue;
6301
- const gone = (c) => Math.abs(c[1] - y) <= rowH / 2 && (alongU ? c[0] >= u0 + a && c[0] <= u0 + b : c[2] >= v0 + a && c[2] <= v0 + b);
6302
- if (chips.some(gone)) continue;
6303
- const near = opening && d < opening.radius + 0.25 || chips.some((c) => Math.hypot(c[0] - x, c[1] - y, c[2] - z) < 0.15);
6304
- list.push({ x, y, z, sx: alongU ? b - a : thick, sy: rowH, sz: alongU ? thick : b - a, colour: near || paint === null ? edge : paint });
6293
+ for (let i = 0; i < per + (shift ? 1 : 0); i++) {
6294
+ const a = Math.max(0, i * w - shift), b = Math.min(len, i * w - shift + w);
6295
+ if (b - a > 1e-3) out.push({ a, b, y0: part.y0 + r * rowH, y1: part.y0 + (r + 1) * rowH, r, i });
6305
6296
  }
6306
6297
  }
6307
- return list;
6298
+ return out;
6299
+ }
6300
+ const specOf = (id) => opts.spec()?.parts?.find((q) => q.id === id);
6301
+ const same = (a, b) => a.every((x, k) => Math.abs(x - b[k]) < 1e-3);
6302
+ function hostOf(partId) {
6303
+ if (structure.meshesOf(partId).some((it) => it.mesh)) return partId;
6304
+ const part = structure.byId.get(partId);
6305
+ if (!part) return null;
6306
+ for (const q of structure.parts) {
6307
+ if (q.id !== partId && same(q.aabb, part.aabb) && Math.abs(q.y1 - part.y0) < 0.01 && structure.meshesOf(q.id).some((it) => it.mesh)) return q.id;
6308
+ }
6309
+ return null;
6310
+ }
6311
+ const riders = (hostId) => structure.parts.filter((q) => q.id !== hostId && specOf(q.id)?.units && hostOf(q.id) === hostId && !structure.meshesOf(q.id).some((it) => it.mesh)).map((q) => q.id);
6312
+ function shell(it, mask) {
6313
+ const g0 = it.mesh.geometry, g = new THREE.BufferGeometry();
6314
+ const src = it.mesh.userData._p0 || g0.attributes.position.array;
6315
+ for (const [name, attr] of Object.entries(g0.attributes)) {
6316
+ const k = attr.itemSize;
6317
+ g.setAttribute(name, new THREE.BufferAttribute((name === "position" ? src : attr.array).slice(it.v0 * k, it.v1 * k), k));
6318
+ }
6319
+ if (g0.index) {
6320
+ const ix = g0.index.array.slice(it.i0, it.i1);
6321
+ for (let k = 0; k < ix.length; k++) ix[k] -= it.v0;
6322
+ g.setIndex(new THREE.BufferAttribute(ix, 1));
6323
+ }
6324
+ g.computeBoundingBox();
6325
+ g.computeBoundingSphere();
6326
+ const base = it.mesh.material;
6327
+ const material = base.clone();
6328
+ const toHouse = { value: new THREE.Matrix4() };
6329
+ material.onBeforeCompile = (sh) => {
6330
+ base.onBeforeCompile?.(sh);
6331
+ sh.uniforms.uToHouse = toHouse;
6332
+ sh.uniforms.uMask = { value: mask.texture };
6333
+ sh.uniforms.uMaskRect = { value: mask.rect };
6334
+ sh.uniforms.uMaskAlongX = { value: mask.alongU ? 1 : 0 };
6335
+ sh.vertexShader = sh.vertexShader.replace("void main() {", "uniform mat4 uToHouse;\nvarying vec3 vHousePos;\nvoid main() {").replace("#include <begin_vertex>", "#include <begin_vertex>\nvHousePos = (uToHouse * vec4(transformed, 1.0)).xyz;");
6336
+ sh.fragmentShader = sh.fragmentShader.replace("void main() {", "uniform sampler2D uMask;\nuniform vec4 uMaskRect;\nuniform float uMaskAlongX;\nvarying vec3 vHousePos;\nvoid main() {\n vec2 mUv = vec2(((uMaskAlongX > 0.5 ? vHousePos.x : vHousePos.z) - uMaskRect.x) / uMaskRect.z, (vHousePos.y - uMaskRect.y) / uMaskRect.w);\n if (mUv.x >= 0.0 && mUv.x <= 1.0 && mUv.y >= 0.0 && mUv.y <= 1.0 && texture2D(uMask, mUv).r > 0.5) discard;");
6337
+ };
6338
+ material.customProgramCacheKey = () => "breachMask" + (base.customProgramCacheKey?.() ?? "");
6339
+ const mesh = new THREE.Mesh(g, material);
6340
+ mesh.name = "breach-shell";
6341
+ mesh.userData.breachMask = mask;
6342
+ mesh.position.copy(it.mesh.position);
6343
+ mesh.quaternion.copy(it.mesh.quaternion);
6344
+ mesh.scale.copy(it.mesh.scale);
6345
+ mesh.castShadow = it.mesh.castShadow;
6346
+ mesh.receiveShadow = it.mesh.receiveShadow;
6347
+ mesh.renderOrder = it.mesh.renderOrder;
6348
+ mesh.onBeforeRender = () => {
6349
+ house.updateMatrixWorld();
6350
+ toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6351
+ };
6352
+ it.mesh.parent.add(mesh);
6353
+ mesh.updateMatrixWorld(true);
6354
+ toHouse.value.copy(house.matrixWorld).invert().multiply(mesh.matrixWorld);
6355
+ return mesh;
6356
+ }
6357
+ function maskOf(hostId, parts) {
6358
+ const host = structure.byId.get(hostId);
6359
+ const [u0, v0, u1, v1] = host.aabb;
6360
+ const alongU = u1 - u0 >= v1 - v0;
6361
+ const y0 = Math.min(...parts.map((id) => structure.byId.get(id).y0)), y1 = Math.max(...parts.map((id) => structure.byId.get(id).y1));
6362
+ const len = alongU ? u1 - u0 : v1 - v0;
6363
+ const w = Math.max(2, Math.min(2048, Math.ceil(len / MASK_TEXEL))), h = Math.max(2, Math.min(1024, Math.ceil((y1 - y0) / MASK_TEXEL)));
6364
+ const data = new Uint8Array(w * h);
6365
+ const texture = new THREE.DataTexture(data, w, h, THREE.RedFormat);
6366
+ texture.magFilter = THREE.LinearFilter;
6367
+ texture.minFilter = THREE.LinearFilter;
6368
+ texture.needsUpdate = true;
6369
+ 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
+ }
6371
+ const boxDistance = (s, y, a, b, c, d) => Math.hypot(Math.max(a - s, 0, s - b), Math.max(c - y, 0, y - d));
6372
+ function paint(view) {
6373
+ const { mask } = view;
6374
+ const along = (p) => (mask.alongU ? p[0] : p[2]) - mask.start;
6375
+ const holes = [];
6376
+ const discs = [];
6377
+ const shown = [];
6378
+ 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);
6380
+ const all = cells(part, units2);
6381
+ const thick = mask.alongU ? part.aabb[3] - part.aabb[1] : part.aabb[2] - part.aabb[0];
6382
+ const mid = mask.alongU ? (part.aabb[1] + part.aabb[3]) / 2 : (part.aabb[0] + part.aabb[2]) / 2;
6383
+ 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;
6385
+ const kept = [];
6386
+ for (const c of all) (gone(c) ? holes : kept).push(c);
6387
+ for (const c of kept) shown.push({ c, part, units: units2, thick, mid });
6388
+ }
6389
+ const reach = SPALL_MIN + SPALL_RAGGED;
6390
+ 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));
6394
+ for (let j = j0; j <= j1; j++) {
6395
+ const y = mask.y0 + (j + 0.5) / mask.h * mask.height;
6396
+ 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;
6400
+ }
6401
+ }
6402
+ };
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));
6405
+ 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);
6407
+ const list = [];
6408
+ const tone = (hex, k) => tmp.c.setHex(hex).multiplyScalar(k).getHex();
6409
+ for (const { c, part, units: units2, thick, mid } of shown) {
6410
+ if (!near(c)) continue;
6411
+ 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 });
6413
+ const base = fragmentMaterial(part.material).color.getHex();
6414
+ const n = noise2(centre * 3.1, y * 2.7, 0.07);
6415
+ if (units2.mortar === "concrete") {
6416
+ at(len, h, thick - 2 * COVER - 0.03 * n, tone(base, 0.86 + 0.2 * n));
6417
+ } else {
6418
+ at(len, h, thick - 2 * COVER - 0.012, 10327948);
6419
+ at(len - 0.012, h - 0.012, thick - 2 * COVER, tone(base, 0.8 + 0.35 * n));
6420
+ }
6421
+ }
6422
+ if (view.units) {
6423
+ view.units.removeFromParent();
6424
+ view.units.dispose();
6425
+ }
6426
+ const units = new THREE.InstancedMesh(unitGeometry, unitMaterial, Math.max(1, list.length));
6427
+ units.name = "breach:" + view.host;
6428
+ list.forEach((u, i) => {
6429
+ tmp.m.compose(tmp.p.set(u.x, u.y, u.z), tmp.q.identity(), tmp.s.set(u.sx, u.sy, u.sz));
6430
+ units.setMatrixAt(i, tmp.m);
6431
+ units.setColorAt(i, tmp.c.setHex(u.colour));
6432
+ });
6433
+ units.count = list.length;
6434
+ units.instanceMatrix.needsUpdate = true;
6435
+ if (units.instanceColor) units.instanceColor.needsUpdate = true;
6436
+ units.castShadow = true;
6437
+ units.receiveShadow = true;
6438
+ units.visible = lod === 0;
6439
+ house.add(units);
6440
+ view.units = units;
6441
+ view.gone = holes.length;
6308
6442
  }
6309
6443
  function draw2(partId, change) {
6310
6444
  if (lod !== 0) return false;
6311
- const part = structure.byId.get(partId);
6312
- const spec = opts.spec()?.parts?.find((q) => q.id === partId);
6313
- if (!part || !spec || !spec.units) return false;
6314
- let view = views.get(partId);
6445
+ const hostId = hostOf(partId);
6446
+ if (!hostId || !specOf(partId)?.units) return false;
6447
+ let view = views.get(hostId);
6315
6448
  if (!view) {
6316
- const items = structure.meshesOf(partId).filter((it) => it.mesh);
6449
+ const items = structure.meshesOf(hostId).filter((it) => it.mesh);
6317
6450
  if (!items.length) return false;
6318
- view = { mesh: null, saved: null, items, paint: wallColour(items), units: 0, opening: null, chips: [] };
6451
+ const parts = [hostId, ...riders(hostId)].filter((id) => structure.byId.get(id) && specOf(id)?.units);
6452
+ 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() };
6319
6455
  }
6456
+ if (!view.chips.has(partId)) return false;
6320
6457
  if (change.opening) {
6321
- if (view.opening) return false;
6322
- view.opening = { center: local(change.opening.center), radius: change.opening.radius };
6458
+ if (view.openings.has(partId)) return false;
6459
+ view.openings.set(partId, { center: local(change.opening.center), radius: change.opening.radius });
6323
6460
  }
6324
- if (change.chip) view.chips.push(local(change.chip));
6325
- const list = courses(part, spec.units, view.opening, view.chips, view.paint);
6326
- if (view.mesh) {
6327
- view.mesh.removeFromParent();
6328
- view.mesh.dispose();
6461
+ if (change.chip) view.chips.get(partId).push(local(change.chip));
6462
+ if (!view.saved) {
6463
+ view.shells = view.items.map((it) => shell(it, view.mask));
6464
+ view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6329
6465
  }
6330
- const mesh = new THREE.InstancedMesh(unitGeometry, unitMaterial, Math.max(1, list.length));
6331
- mesh.name = "breach:" + partId;
6332
- list.forEach((u, i) => {
6333
- tmp.m.compose(tmp.p.set(u.x, u.y, u.z), tmp.q.identity(), tmp.s.set(u.sx, u.sy, u.sz));
6334
- mesh.setMatrixAt(i, tmp.m);
6335
- mesh.setColorAt(i, tmp.c.setHex(u.colour));
6336
- });
6337
- mesh.count = list.length;
6338
- mesh.instanceMatrix.needsUpdate = true;
6339
- if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
6340
- mesh.castShadow = true;
6341
- mesh.receiveShadow = true;
6342
- house.add(mesh);
6343
- if (!view.saved) view.saved = view.items.map((it) => ({ it, v0: it.v0, data: collapseRange(it.mesh, it.v0, it.v1) }));
6344
- view.mesh = mesh;
6345
- view.units = list.length;
6346
- views.set(partId, view);
6466
+ paint(view);
6467
+ views.set(hostId, view);
6347
6468
  invalidate();
6348
6469
  invalidateShadow();
6349
6470
  return true;
@@ -6354,30 +6475,40 @@ function installDestructionBreach(K, _shared) {
6354
6475
  else s.data = collapseRange(s.it.mesh, s.v0, s.it.v1);
6355
6476
  }
6356
6477
  }
6478
+ function dispose(view) {
6479
+ view.units.removeFromParent();
6480
+ view.units.dispose();
6481
+ for (const m of view.shells) {
6482
+ m.removeFromParent();
6483
+ m.geometry.dispose();
6484
+ m.material.dispose();
6485
+ }
6486
+ view.mask.texture.dispose();
6487
+ }
6357
6488
  return {
6358
- /** Handles a `stage` event of the house's object. Returns true when the wall was redrawn as courses. */
6489
+ /** Handles a `stage` event of the house's object. Returns true when the wall was cut. */
6359
6490
  stage(event) {
6360
6491
  return event.stage === 2 && !!event.opening ? draw2(event.part, { opening: event.opening }) : false;
6361
6492
  },
6362
- /** Handles a `chip` event: the unit that came out leaves a gap in the courses. Returns true when the wall was redrawn. */
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. */
6363
6494
  chip(event) {
6364
6495
  return draw2(event.part, { chip: event.center });
6365
6496
  },
6366
- /** The part broke or fell: the courses go (the plain range stays collapsed, the renderer hides the part). */
6497
+ /** The part broke or fell: its damage goes (the plain range stays collapsed, the renderer hides the part). */
6367
6498
  drop(partId) {
6368
6499
  const v = views.get(partId);
6369
6500
  if (!v) return;
6370
- v.mesh.removeFromParent();
6371
- v.mesh.dispose();
6501
+ dispose(v);
6372
6502
  views.delete(partId);
6373
6503
  },
6374
- /** LOD 0 draws the courses; LOD 1 and 2 give the wall its box back. */
6504
+ /** LOD 0 draws the cut wall; LOD 1 and 2 give the wall its box back. */
6375
6505
  setLod(level) {
6376
6506
  const was = lod;
6377
6507
  lod = level;
6378
6508
  if (was === 0 === (level === 0)) return;
6379
6509
  for (const v of views.values()) {
6380
- v.mesh.visible = level === 0;
6510
+ v.units.visible = level === 0;
6511
+ for (const m of v.shells) m.visible = level === 0;
6381
6512
  plain(v, level !== 0);
6382
6513
  }
6383
6514
  invalidate();
@@ -6386,17 +6517,21 @@ function installDestructionBreach(K, _shared) {
6386
6517
  /** Everything back to what it was (after `world.reset`). */
6387
6518
  restore() {
6388
6519
  for (const v of views.values()) {
6389
- v.mesh.removeFromParent();
6390
- v.mesh.dispose();
6520
+ dispose(v);
6391
6521
  if (lod === 0) plain(v, true);
6392
6522
  }
6393
6523
  views.clear();
6394
6524
  },
6395
- /** The wall's opening, if it has courses. */
6525
+ /** The wall has damage drawn. */
6396
6526
  has(partId) {
6397
6527
  return views.has(partId);
6398
6528
  },
6399
- statistics: () => ({ walls: views.size, units: [...views.values()].reduce((n, v) => n + v.units, 0) })
6529
+ /** Walls with damage drawn, units out of them, and units laid bare at the edge of the cuts. */
6530
+ statistics: () => ({
6531
+ walls: views.size,
6532
+ gone: [...views.values()].reduce((n, v) => n + v.gone, 0),
6533
+ units: [...views.values()].reduce((n, v) => n + v.units.count, 0)
6534
+ })
6400
6535
  };
6401
6536
  }
6402
6537
  Object.assign(K, { createBreachView });
@@ -8515,6 +8650,7 @@ function createFloorplanTestModule(options = {}) {
8515
8650
  return {
8516
8651
  id: FLOORPLAN_AREA_ID,
8517
8652
  name: text.name,
8653
+ ground: { surface: "lawn" },
8518
8654
  group: text.group,
8519
8655
  get houses() {
8520
8656
  return placed;
@@ -8622,6 +8758,9 @@ import { inferJoints } from "@adatechnology/game-destruction";
8622
8758
  import { markKitOrigin as markKitOrigin2 } from "@adatechnology/game-contracts";
8623
8759
  import { residentialItems } from "@adatechnology/game-floorplan-residential";
8624
8760
  var WOOD_THIN = 0.035;
8761
+ var BOARD_TOP_THICK = 0.07;
8762
+ var BOARD_TOP_WIDTH = 0.5;
8763
+ var BOARD_WIDTH = 0.16;
8625
8764
  var SHELF_SPAN = 1.5;
8626
8765
  var SHELVING = /shelf|bookcase|rack/;
8627
8766
  function carcass(parts, drawn) {
@@ -8636,6 +8775,39 @@ function carcass(parts, drawn) {
8636
8775
  } else if (y <= x && y <= z) part.structure = { role: "beam", maxSpan: SHELF_SPAN * Math.max(x, z) };
8637
8776
  }
8638
8777
  }
8778
+ function splitTopsIntoBoards(drawn, materialOf, carcassed) {
8779
+ const tops = /* @__PURE__ */ new Map();
8780
+ if (carcassed) return tops;
8781
+ for (let i = drawn.length - 1; i >= 0; i--) {
8782
+ const p = drawn[i];
8783
+ if (p.key !== "wood" || materialOf.wood !== "wood_hard" || p.shape.kind !== "box" || p.rotation) continue;
8784
+ const [sx, sy, sz] = p.shape.size;
8785
+ const across = sx < sz ? 0 : 2;
8786
+ const width = Math.min(sx, sz);
8787
+ if (sy < WOOD_THIN || sy > BOARD_TOP_THICK || sy >= width || width < BOARD_TOP_WIDTH) continue;
8788
+ const count = Math.round(width / BOARD_WIDTH);
8789
+ const boards = [];
8790
+ for (let b = 0; b < count; b++) {
8791
+ const size = [sx, sy, sz];
8792
+ size[across] = width / count;
8793
+ const position = [...p.position];
8794
+ position[across] += (b + 0.5) * size[across] - width / 2;
8795
+ const min = [...p.min];
8796
+ const max = [...p.max];
8797
+ min[across] = position[across] - size[across] / 2;
8798
+ max[across] = position[across] + size[across] / 2;
8799
+ boards.push({ ...p, id: `${p.id}_b${b}`, shape: { kind: "box", size }, position, min, max, color: shadeBoard(p.color, b) });
8800
+ }
8801
+ drawn.splice(i, 1, ...boards);
8802
+ tops.set(p.id, boards);
8803
+ }
8804
+ return tops;
8805
+ }
8806
+ function shadeBoard(color, board) {
8807
+ const k = [1, 0.94, 1.05, 0.97, 1.03][board % 5];
8808
+ const channel = (shift) => Math.min(255, Math.round((color >> shift & 255) * k)) << shift;
8809
+ return channel(16) | channel(8) | channel(0);
8810
+ }
8639
8811
  var FLOOR_TOUCH = 5e-3;
8640
8812
  var LOOSE = /* @__PURE__ */ new Set(["paper"]);
8641
8813
  var BASE_MATERIAL = {
@@ -8713,10 +8885,12 @@ function recordSpec(THREE, name, root, run, opts) {
8713
8885
  };
8714
8886
  run(A);
8715
8887
  if (!drawn.length) throw new Error(`game-floorplan-three: the builder of "${name}" drew no box or cylinder`);
8888
+ const boardsOf = splitTopsIntoBoards(drawn, materialOf, Boolean(opts.carcass));
8716
8889
  const parts = drawn.map((p) => {
8717
8890
  const thin = Math.min(...p.shape.kind === "box" ? p.shape.size : p.shape.kind === "cylinder" ? [p.shape.radius * 2, p.shape.height] : [p.shape.radius * 2]);
8718
8891
  let material = materialOf[p.key] ?? "plastic";
8719
- if (p.key === "wood" && material === "wood_hard" && thin < WOOD_THIN) material = "wood_soft";
8892
+ if (p.key === "wood" && material === "wood_hard" && opts.carcass && !opts.materials?.wood) material = "mdf";
8893
+ else if (p.key === "wood" && material === "wood_hard" && thin < WOOD_THIN) material = "wood_soft";
8720
8894
  const part = { id: p.id, material, shape: p.shape, position: p.position };
8721
8895
  if (p.rotation) part.rotation = p.rotation;
8722
8896
  if (!LOOSE.has(material) && p.min[1] <= FLOOR_TOUCH) part.anchored = true;
@@ -8751,6 +8925,13 @@ function recordSpec(THREE, name, root, run, opts) {
8751
8925
  if (area < 0.6 * footprint || p.max[1] < 0.2) continue;
8752
8926
  if (!top || p.max[1] > top.y + 1e-6) top = { part: p.id, y: p.max[1] };
8753
8927
  }
8928
+ for (const boards of boardsOf.values()) {
8929
+ if (top && boards[0].max[1] <= top.y - 1e-6) continue;
8930
+ const mid = boards[boards.length >> 1];
8931
+ const min = [Math.min(...boards.map((b) => b.min[0])), Math.min(...boards.map((b) => b.min[2]))];
8932
+ const max = [Math.max(...boards.map((b) => b.max[0])), Math.max(...boards.map((b) => b.max[2]))];
8933
+ if ((max[0] - min[0]) * (max[1] - min[1]) >= 0.6 * footprint && mid.max[1] >= 0.2) top = { part: mid.id, y: mid.max[1], parts: boards.map((b) => b.id) };
8934
+ }
8754
8935
  if (opts.carcass) carcass(parts, byId);
8755
8936
  const spec = { name, parts, joints, tags: ["furniture"] };
8756
8937
  const colors = new Map(drawn.map((p) => [p.id, p.color]));