@adatechnology/game-destruction-three 0.7.3 → 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
@@ -3,6 +3,7 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
5
  // src/renderer.ts
6
+ import { blastShove } from "@adatechnology/game-contracts";
6
7
  import { createVfx, effectForEvent, EVENT_EFFECTS_MAP } from "@adatechnology/game-vfx";
7
8
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
8
9
 
@@ -135,6 +136,7 @@ var FragmentSystem = class {
135
136
  const sc = new this.THREE.Vector3(Math.max(5e-3, sz[0]), Math.max(5e-3, sz[1]), Math.max(3e-3, sz[2]));
136
137
  mesh.scale.copy(sc);
137
138
  mesh.castShadow = true;
139
+ mesh.userData.destructionDebris = true;
138
140
  this.o.scene.add(mesh);
139
141
  const live = {
140
142
  mesh,
@@ -342,6 +344,7 @@ var GroundSpots = class {
342
344
  mesh.position.set(x, y, z);
343
345
  mesh.scale.setScalar(1e-3);
344
346
  mesh.renderOrder = 1;
347
+ mesh.userData.destructionDebris = true;
345
348
  this.scene.add(mesh);
346
349
  this.spots.push({ mesh, kind, home, x, z, radius: 1e-3, target: Math.min(MAX_RADIUS, radius) });
347
350
  }
@@ -770,7 +773,12 @@ var RebarCages = class {
770
773
  };
771
774
 
772
775
  // src/spec.ts
773
- import { guessMaterial, inferJoints } from "@adatechnology/game-destruction";
776
+ import { guessMaterial, inferJoints, MATERIALS, partMass } from "@adatechnology/game-destruction";
777
+ function containsOf(value) {
778
+ if (!value || typeof value !== "object") return void 0;
779
+ const v = value;
780
+ return typeof v.fluid === "string" && typeof v.liters === "number" && v.liters > 0 ? { fluid: v.fluid, liters: v.liters } : void 0;
781
+ }
774
782
  function partHintOf(value) {
775
783
  if (typeof value === "string") return value ? { id: value } : null;
776
784
  if (!value || typeof value !== "object") return null;
@@ -791,11 +799,17 @@ function isMesh(o) {
791
799
  function sizeOf(box) {
792
800
  return [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z];
793
801
  }
802
+ function specFrameInverse(THREE, root) {
803
+ root.updateMatrixWorld(true);
804
+ const position = new THREE.Vector3();
805
+ const quaternion = new THREE.Quaternion();
806
+ root.matrixWorld.decompose(position, quaternion, new THREE.Vector3());
807
+ return new THREE.Matrix4().compose(position, quaternion, new THREE.Vector3(1, 1, 1)).invert();
808
+ }
794
809
  function specFromObject3D(THREE, root, options = {}) {
795
810
  const fallback = options.fallback ?? "plastic";
796
811
  const minSize = options.minSize ?? 0.015;
797
- root.updateMatrixWorld(true);
798
- const inv = root.matrixWorld.clone().invert();
812
+ const inv = specFrameInverse(THREE, root);
799
813
  const entries = [];
800
814
  const byKey = /* @__PURE__ */ new Map();
801
815
  const visit = (o) => {
@@ -812,8 +826,10 @@ function specFromObject3D(THREE, root, options = {}) {
812
826
  const material = options.materialOf?.(o) ?? (typeof ud.destructionMaterial === "string" ? ud.destructionMaterial : void 0) ?? hint?.material ?? guessMaterial(`${o.name} ${matName} ${typeof ud.material === "string" ? ud.material : ""}`) ?? fallback;
813
827
  const explicit = hint?.id ?? null;
814
828
  const hollow = ud.hollow === true || /hollow/i.test(o.name);
829
+ const skin = ud.destructionSkin === true;
815
830
  const fill = options.fillOf?.(o, material);
816
831
  const tags = Array.isArray(ud.destructionTags) ? ud.destructionTags : void 0;
832
+ const contains = containsOf(ud.destructionContains);
817
833
  const existing = explicit ? byKey.get(explicit) : void 0;
818
834
  if (existing) {
819
835
  const before = sizeOf(existing.box);
@@ -823,9 +839,11 @@ function specFromObject3D(THREE, root, options = {}) {
823
839
  existing.meshes.push(o);
824
840
  if (ns[0] * ns[1] * ns[2] > bv) existing.material = material;
825
841
  existing.hollow = existing.hollow || hollow;
842
+ existing.skin = existing.skin && skin;
826
843
  if (fill !== void 0) existing.fill = fill;
844
+ if (contains) existing.contains = contains;
827
845
  } else {
828
- const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, tags, fixedId: !!explicit, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
846
+ const e = { key: explicit ?? `m${entries.length}`, meshes: [o], box, material, explicitId: explicit, fill, hollow, skin, tags, fixedId: !!explicit, ...contains ? { contains } : {}, name: o.name, parent: hint?.parent, jointKind: hint?.kind };
829
847
  entries.push(e);
830
848
  if (explicit) byKey.set(explicit, e);
831
849
  }
@@ -844,7 +862,7 @@ function specFromObject3D(THREE, root, options = {}) {
844
862
  const largest = (e) => Math.max(...sizeOf(e.box));
845
863
  const kept = [];
846
864
  const tiny = [];
847
- for (const e of entries) (!e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
865
+ for (const e of entries) (e.skin || !e.fixedId && largest(e) < minSize ? tiny : kept).push(e);
848
866
  for (const t of tiny) {
849
867
  let best = null;
850
868
  let bestV = 0;
@@ -898,11 +916,21 @@ function specFromObject3D(THREE, root, options = {}) {
898
916
  part.thickness = Math.max(minSide, 5e-4);
899
917
  }
900
918
  if (e.tags) part.tags = [...e.tags];
919
+ if (e.contains) part.contains = { ...e.contains };
901
920
  const ao = options.anchorBelow ?? -1;
902
921
  if (ao >= 0 && e.box.min.y <= ao) part.anchored = true;
903
922
  parts.push(part);
904
923
  meshesOf[id] = e.meshes;
905
924
  }
925
+ const knownMass = root.userData?.destructionMass;
926
+ if (typeof knownMass === "number" && knownMass > 0) {
927
+ const shares = parts.map((p) => {
928
+ const material = MATERIALS.find((m) => m.id === p.material);
929
+ return material ? partMass(p, material) : 0;
930
+ });
931
+ const total = shares.reduce((a, b) => a + b, 0);
932
+ if (total > 0) parts.forEach((p, i) => p.mass = knownMass * shares[i] / total);
933
+ }
906
934
  const spec = { parts };
907
935
  if (options.name !== void 0) spec.name = options.name;
908
936
  else if (root.name) spec.name = root.name;
@@ -954,6 +982,46 @@ function heatColor(temperatureC) {
954
982
  return last[1];
955
983
  }
956
984
 
985
+ // src/skin-dent.ts
986
+ function shellDisplacementAt(THREE, worldPoint, shells, out) {
987
+ const triangle = new THREE.Triangle();
988
+ const corners = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
989
+ const local = new THREE.Vector3();
990
+ const closest = new THREE.Vector3();
991
+ const weights = new THREE.Vector3();
992
+ const moved = new THREE.Vector3();
993
+ let nearest = Infinity;
994
+ out.set(0, 0, 0);
995
+ let nearestScale = 1;
996
+ for (const shell of shells) {
997
+ shell.mesh.updateWorldMatrix(true, false);
998
+ shell.mesh.worldToLocal(local.copy(worldPoint));
999
+ const index = shell.mesh.geometry.index;
1000
+ const triangles = index ? index.count / 3 : shell.before.length / 9;
1001
+ for (let t = 0; t < triangles; t++) {
1002
+ const ids = [0, 1, 2].map((corner) => index ? index.getX(t * 3 + corner) : t * 3 + corner);
1003
+ ids.forEach((id, corner) => corners[corner].set(shell.before[id * 3], shell.before[id * 3 + 1], shell.before[id * 3 + 2]));
1004
+ triangle.set(corners[0], corners[1], corners[2]);
1005
+ triangle.closestPointToPoint(local, closest);
1006
+ const distance = closest.distanceToSquared(local);
1007
+ if (distance >= nearest) continue;
1008
+ nearest = distance;
1009
+ nearestScale = shell.mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1010
+ triangle.getBarycoord(closest, weights);
1011
+ moved.set(0, 0, 0);
1012
+ ids.forEach((id, corner) => {
1013
+ const weight = corner === 0 ? weights.x : corner === 1 ? weights.y : weights.z;
1014
+ moved.x += weight * (shell.after[id * 3] - shell.before[id * 3]);
1015
+ moved.y += weight * (shell.after[id * 3 + 1] - shell.before[id * 3 + 1]);
1016
+ moved.z += weight * (shell.after[id * 3 + 2] - shell.before[id * 3 + 2]);
1017
+ });
1018
+ out.copy(moved).transformDirection(shell.mesh.matrixWorld);
1019
+ out.multiplyScalar(moved.length() * (shell.mesh.matrixWorld.getMaxScaleOnAxis() || 1));
1020
+ }
1021
+ }
1022
+ return Math.sqrt(nearest) * nearestScale;
1023
+ }
1024
+
957
1025
  // src/visual.ts
958
1026
  var POS_ATTR = "position";
959
1027
  function positionAttr(geo) {
@@ -986,7 +1054,8 @@ var DecalKit = class {
986
1054
  depthWrite: false,
987
1055
  polygonOffset: true,
988
1056
  polygonOffsetFactor: -4,
989
- polygonOffsetUnits: -4
1057
+ polygonOffsetUnits: -4,
1058
+ side: this.THREE.DoubleSide
990
1059
  }));
991
1060
  }
992
1061
  /** Opaque patch in the colour of what shows under a spalled coat. */
@@ -1018,6 +1087,12 @@ var DecalKit = class {
1018
1087
  }
1019
1088
  };
1020
1089
  var tmpV = (THREE) => new THREE.Vector3();
1090
+ var MIN_DENT_VERTICES = 10;
1091
+ var WIDEN_MARGIN = 1.1;
1092
+ var SKIN_FOLLOW = 0.85;
1093
+ var SKIN_SLACK = 5e-4;
1094
+ var SKIN_LAYER_SLACK = 4;
1095
+ var NEAREST_MARGIN = 1.5;
1021
1096
  var Visuals = class {
1022
1097
  constructor(THREE, lowQuality, decalCap) {
1023
1098
  __publicField(this, "THREE", THREE);
@@ -1186,14 +1261,14 @@ var Visuals = class {
1186
1261
  * Push the vertices of the mesh within `dent.radius` of `worldPoint` inwards along `worldNormal` by `depth * (1 - (d/r)^2)^2`.
1187
1262
  * Returns false when nothing was changed (too far, low quality on a heavy mesh, or no editable positions).
1188
1263
  */
1189
- applyDent(mv, worldPoint, worldNormal, dent) {
1264
+ applyDent(mv, worldPoint, worldNormal, dent, reachWorld = 0) {
1190
1265
  const mesh = mv.mesh;
1191
1266
  if (this.lowQuality && this.vertexCount(mv) > 2e4) return false;
1192
1267
  mesh.updateWorldMatrix(true, false);
1193
1268
  const s = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1194
1269
  const lp = mesh.worldToLocal(this.a.copy(worldPoint));
1195
1270
  const ln = this.b.copy(worldNormal).transformDirection(this.m4.copy(mesh.matrixWorld).invert()).normalize();
1196
- const r = dent.radius / s;
1271
+ const r = Math.max(dent.radius, reachWorld) / s;
1197
1272
  const depth = dent.depth / s;
1198
1273
  const geo = mesh.geometry;
1199
1274
  let bb;
@@ -1206,34 +1281,110 @@ var Visuals = class {
1206
1281
  if (!this.ownGeometry(mv)) return false;
1207
1282
  const work = mv.host ? mv.host.work : mv.work;
1208
1283
  const pos = positionAttr(mesh.geometry);
1209
- const r2 = r * r;
1210
- let touched = false;
1211
- const px = lp.x;
1212
- const py = lp.y;
1213
- const pz = lp.z;
1214
- const nx = ln.x;
1215
- const ny = ln.y;
1216
- const nz = ln.z;
1217
1284
  const from = mv.range ? mv.range[0] * 3 : 0;
1218
1285
  const to = mv.range ? mv.range[1] * 3 : work.length;
1286
+ const reach = r;
1287
+ const reach2 = reach * reach;
1219
1288
  for (let i = from; i < to; i += 3) {
1220
- const dx = work[i] - px;
1221
- const dy = work[i + 1] - py;
1222
- const dz = work[i + 2] - pz;
1289
+ const dx = work[i] - lp.x;
1290
+ const dy = work[i + 1] - lp.y;
1291
+ const dz = work[i + 2] - lp.z;
1223
1292
  const d2 = dx * dx + dy * dy + dz * dz;
1224
- if (d2 >= r2) continue;
1225
- const f = 1 - d2 / r2;
1293
+ if (d2 >= reach2) continue;
1294
+ const f = 1 - d2 / reach2;
1226
1295
  const k = depth * f * f;
1227
- work[i] -= nx * k;
1228
- work[i + 1] -= ny * k;
1229
- work[i + 2] -= nz * k;
1230
- touched = true;
1296
+ work[i] -= ln.x * k;
1297
+ work[i + 1] -= ln.y * k;
1298
+ work[i + 2] -= ln.z * k;
1231
1299
  }
1232
- if (!touched) return false;
1233
1300
  mv.touched = true;
1234
1301
  this.writePositions(mv, pos);
1235
1302
  return true;
1236
1303
  }
1304
+ /**
1305
+ * World radius a dent needs on this mesh to reach `MIN_DENT_VERTICES` vertices (the dent's own radius when the mesh is fine enough). A part with several meshes
1306
+ * (a shell and its label) must dent them all with the largest of these, so the skin and the shell follow the same smooth surface and never cross.
1307
+ */
1308
+ dentReach(mv, worldPoint, dent) {
1309
+ const mesh = mv.mesh;
1310
+ mesh.updateWorldMatrix(true, false);
1311
+ const s = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1312
+ const lp = mesh.worldToLocal(this.a.copy(worldPoint));
1313
+ const source = (mv.host ? mv.host.work : mv.work) ?? positionAttr(mesh.geometry)?.array;
1314
+ if (!source) return dent.radius;
1315
+ let bounds;
1316
+ if (mv.host) bounds = this.localBox(mv, this.lbox);
1317
+ else {
1318
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
1319
+ bounds = mesh.geometry.boundingBox;
1320
+ }
1321
+ if (bounds && !bounds.isEmpty() && bounds.distanceToPoint(lp) > dent.radius / s) return dent.radius;
1322
+ const from = mv.range ? mv.range[0] * 3 : 0;
1323
+ const to = mv.range ? mv.range[1] * 3 : source.length;
1324
+ const r2 = (dent.radius / s) ** 2;
1325
+ const distances = [];
1326
+ let inside = 0;
1327
+ for (let i = from; i < to; i += 3) {
1328
+ const dx = source[i] - lp.x;
1329
+ const dy = source[i + 1] - lp.y;
1330
+ const dz = source[i + 2] - lp.z;
1331
+ const d2 = dx * dx + dy * dy + dz * dz;
1332
+ if (d2 < r2) inside++;
1333
+ distances.push(d2);
1334
+ }
1335
+ if (inside >= MIN_DENT_VERTICES || distances.length < MIN_DENT_VERTICES) return dent.radius;
1336
+ distances.sort((x, y) => x - y);
1337
+ const nearest = Math.sqrt(distances[0]);
1338
+ return Math.max(dent.radius, Math.sqrt(distances[MIN_DENT_VERTICES - 1]) * s * WIDEN_MARGIN, nearest * s * NEAREST_MARGIN);
1339
+ }
1340
+ /**
1341
+ * Dent all the meshes of a part. The shells take the dent as `applyDent` gives it; a skin (label, coat: `userData.destructionSkin`) sits a hair above its shell
1342
+ * and moves with the shell's surface instead (see `shellDisplacementAt`), so the shell never shows through it whatever the two meshes' vertex grids are.
1343
+ */
1344
+ applyPartDent(meshes, worldPoint, worldNormal, dent, reachWorld) {
1345
+ const isSkin = (mv) => mv.mesh.userData.destructionSkin === true && !mv.host;
1346
+ const shells = meshes.filter((mv) => !isSkin(mv));
1347
+ const snapshots = [];
1348
+ for (const mv of shells) {
1349
+ const heavy = this.lowQuality && this.vertexCount(mv) > 2e4;
1350
+ const plain = !mv.host && !heavy && this.ownGeometry(mv) && mv.work;
1351
+ const before = plain ? new Float32Array(mv.work) : null;
1352
+ this.applyDent(mv, worldPoint, worldNormal, dent, reachWorld);
1353
+ if (before) snapshots.push({ mesh: mv.mesh, before, after: mv.work });
1354
+ }
1355
+ const displacement = this.t;
1356
+ const vertex = this.a;
1357
+ for (const mv of meshes) {
1358
+ if (!isSkin(mv)) continue;
1359
+ if (snapshots.length === 0) {
1360
+ this.applyDent(mv, worldPoint, worldNormal, dent, reachWorld);
1361
+ continue;
1362
+ }
1363
+ if (!this.ownGeometry(mv)) continue;
1364
+ const work = mv.work;
1365
+ const mesh = mv.mesh;
1366
+ mesh.updateWorldMatrix(true, false);
1367
+ const toLocal = this.m4.copy(mesh.matrixWorld).invert();
1368
+ const scale = mesh.matrixWorld.getMaxScaleOnAxis() || 1;
1369
+ let touched = false;
1370
+ for (let i = 0; i < work.length; i += 3) {
1371
+ vertex.set(work[i], work[i + 1], work[i + 2]).applyMatrix4(mesh.matrixWorld);
1372
+ if (vertex.distanceTo(worldPoint) > reachWorld) continue;
1373
+ const height = shellDisplacementAt(this.THREE, vertex, snapshots, displacement);
1374
+ const length = displacement.length();
1375
+ if (length === 0) continue;
1376
+ const falloff = 1 - (vertex.distanceTo(worldPoint) / reachWorld) ** 2;
1377
+ const smooth = dent.depth * falloff * falloff;
1378
+ const pull = Math.max(0, Math.min(length, smooth) * SKIN_FOLLOW - SKIN_SLACK - height * SKIN_LAYER_SLACK);
1379
+ displacement.transformDirection(toLocal).multiplyScalar(pull / scale);
1380
+ work[i] += displacement.x;
1381
+ work[i + 1] += displacement.y;
1382
+ work[i + 2] += displacement.z;
1383
+ touched = true;
1384
+ }
1385
+ if (touched) this.writePositions(mv, positionAttr(mesh.geometry));
1386
+ }
1387
+ }
1237
1388
  /** Copy `work` (dents) into the geometry, adding the melt sag when the mesh is melting. */
1238
1389
  writePositions(mv, pos) {
1239
1390
  if (mv.host) {
@@ -1280,6 +1431,7 @@ var Visuals = class {
1280
1431
  return { s, pos, quat };
1281
1432
  }
1282
1433
  addDecal(mv, decal) {
1434
+ decal.obj.userData.destructionDebris = true;
1283
1435
  mv.mesh.add(decal.obj);
1284
1436
  mv.decals.push(decal);
1285
1437
  this.decalCount++;
@@ -1296,6 +1448,27 @@ var Visuals = class {
1296
1448
  }
1297
1449
  addHole(mv, worldPoint, worldNormal, diameter) {
1298
1450
  const { s, pos, quat } = this.decalPose(mv.mesh, worldPoint, worldNormal, 25e-4);
1451
+ const work = mv.host ? mv.host.work : mv.work;
1452
+ if (work && mv.touched) {
1453
+ const ln = worldNormal.clone().transformDirection(this.m4.copy(mv.mesh.matrixWorld).invert()).normalize();
1454
+ let minDistSq = Infinity;
1455
+ let nearestDisp = 0;
1456
+ const from = mv.range ? mv.range[0] * 3 : 0;
1457
+ const to = mv.range ? mv.range[1] * 3 : work.length;
1458
+ for (let i = from; i < to; i += 3) {
1459
+ const dx = work[i] - pos.x;
1460
+ const dy = work[i + 1] - pos.y;
1461
+ const dz = work[i + 2] - pos.z;
1462
+ const d2 = dx * dx + dy * dy + dz * dz;
1463
+ if (d2 < minDistSq) {
1464
+ minDistSq = d2;
1465
+ nearestDisp = dx * ln.x + dy * ln.y + dz * ln.z;
1466
+ }
1467
+ }
1468
+ if (minDistSq < (0.06 / s) ** 2 && nearestDisp < 0) {
1469
+ pos.addScaledVector(ln, nearestDisp);
1470
+ }
1471
+ }
1299
1472
  const disc = new this.THREE.Mesh(this.kit.discGeometry(), this.kit.discMaterial());
1300
1473
  disc.position.copy(pos);
1301
1474
  disc.quaternion.copy(quat);
@@ -1767,7 +1940,13 @@ var FLUID_COLORS = {
1767
1940
  gas: 15921906,
1768
1941
  sap: 12098362,
1769
1942
  acid: 10211900,
1770
- blood: 9046538
1943
+ blood: 9046538,
1944
+ soda: 3808786,
1945
+ coffee: 3875860,
1946
+ beer: 14259516,
1947
+ milk: 16052714,
1948
+ wine: 7016479,
1949
+ juice: 15238923
1771
1950
  };
1772
1951
  var SMOKE_TONES = { white: 15132390, grey: 9079434, black: 2763306 };
1773
1952
  var DEFAULT_SPEED = {
@@ -1864,6 +2043,8 @@ var Renderer = class {
1864
2043
  __publicField(this, "tmpQ");
1865
2044
  __publicField(this, "v1");
1866
2045
  __publicField(this, "v2");
2046
+ // The last warm-up keeps its materials until the next one: a material that does not use lights keeps the program of the lights it was first drawn with, so each warm-up needs materials of its own.
2047
+ __publicField(this, "warmProbes");
1867
2048
  this.world = o.world;
1868
2049
  this.scene = o.scene;
1869
2050
  this.quality = o.quality ?? "medium";
@@ -1961,6 +2142,24 @@ var Renderer = class {
1961
2142
  if (!bound.parts.has(pid)) bound.parts.set(pid, this.makePartVis(objectId, pid, this.world.part(objectId, pid)?.material ?? "", meshes, hosts));
1962
2143
  }
1963
2144
  this.bounds.set(objectId, bound);
2145
+ for (const pv of bound.parts.values()) this.tagPart(pv, objectId);
2146
+ }
2147
+ /**
2148
+ * Label the meshes of a part so a host can go from a raycast hit to the world: `userData.destructionPart` (the part id) and
2149
+ * `userData.destructionObject` (the id of the world object that part belongs to now; a loose piece has an id of its own). Parts drawn by a range
2150
+ * of a merged mesh share that mesh with others and are left alone until they get a mesh of their own. Decals are marked `destructionDebris`.
2151
+ */
2152
+ tagPart(pv, objectId) {
2153
+ for (const mv of pv.meshes) {
2154
+ if (mv.merged && !mv.loose) continue;
2155
+ mv.mesh.traverse((c) => {
2156
+ if (!c.isMesh) return;
2157
+ const ud = c.userData;
2158
+ if (ud.destructionDebris) return;
2159
+ if (partHintOf(ud.destructionPart)?.id !== pv.id) ud.destructionPart = pv.id;
2160
+ ud.destructionObject = objectId;
2161
+ });
2162
+ }
1964
2163
  }
1965
2164
  makePartVis(home, id, material, meshes, hosts) {
1966
2165
  return { id, home, material, meshes: meshes.map((m) => this.visuals.makeMeshVis(m.mesh, home, m.range, hosts)), charApplied: 0, glowApplied: 0, meltApplied: 0, gone: "none" };
@@ -1974,13 +2173,13 @@ var Renderer = class {
1974
2173
  specParts = void 0;
1975
2174
  }
1976
2175
  if (!specParts) return;
1977
- root.updateMatrixWorld(true);
2176
+ const frame = specFrameInverse(this.THREE, root);
1978
2177
  const e = new this.THREE.Euler();
1979
2178
  const q = new this.THREE.Quaternion();
1980
2179
  for (const m of meshes) {
1981
2180
  this.box.setFromObject(m);
1982
2181
  if (this.box.isEmpty()) continue;
1983
- const c = root.worldToLocal(this.box.getCenter(this.v1));
2182
+ const c = this.box.getCenter(this.v1).applyMatrix4(frame);
1984
2183
  let best = null;
1985
2184
  let bestVol = Infinity;
1986
2185
  for (const p of specParts) {
@@ -2017,10 +2216,9 @@ var Renderer = class {
2017
2216
  // ----------------------------------------------------------------------------------------------------------------------------------
2018
2217
  // Pieces
2019
2218
  // ----------------------------------------------------------------------------------------------------------------------------------
2020
- /** Half extents and centre of the box around some meshes, in the local space of `root`. */
2219
+ /** Half extents and centre of the box around some meshes, in the metric frame of `root` (its pose removed, its scale kept). */
2021
2220
  measure(root, meshes) {
2022
- root.updateMatrixWorld(true);
2023
- const inv = root.matrixWorld.clone().invert();
2221
+ const inv = specFrameInverse(this.THREE, root);
2024
2222
  const total = new this.THREE.Box3();
2025
2223
  const m4 = new this.THREE.Matrix4();
2026
2224
  const lb = new this.THREE.Box3();
@@ -2095,6 +2293,7 @@ var Renderer = class {
2095
2293
  dt = clamp(dt, 0, 0.1);
2096
2294
  this.time += dt;
2097
2295
  this.drainQueue();
2296
+ for (const b of this.bounds.values()) if (b.soilNode && this.world.state(b.id)?.soil?.status === "yielding") this.onSettle(b.id);
2098
2297
  this.updateActive(dt);
2099
2298
  this.updateLeaks(dt);
2100
2299
  this.pieceSys.update(dt);
@@ -2113,6 +2312,27 @@ var Renderer = class {
2113
2312
  }
2114
2313
  }
2115
2314
  }
2315
+ /**
2316
+ * The ground under an object settled or tilted: its parts are drawn under a node with the soil's offset (`ObjectState.soil.offset`), so the host's
2317
+ * root keeps the pose the host gave and the world keeps getting it from `syncTransform`.
2318
+ */
2319
+ onSettle(objectId) {
2320
+ const b = this.bounds.get(objectId);
2321
+ const soil = this.world.state(objectId)?.soil;
2322
+ if (!b || !soil) return;
2323
+ let node = b.soilNode;
2324
+ if (!node) {
2325
+ node = new this.THREE.Group();
2326
+ node.name = "destruction-soil";
2327
+ for (const c of [...b.root.children]) node.add(c);
2328
+ b.root.add(node);
2329
+ b.soilNode = node;
2330
+ }
2331
+ const { position: p, rotation: q } = soil.offset;
2332
+ node.position.set(p[0], p[1], p[2]);
2333
+ node.quaternion.set(q[0], q[1], q[2], q[3]);
2334
+ node.updateMatrixWorld(true);
2335
+ }
2116
2336
  syncTransforms() {
2117
2337
  for (const b of this.bounds.values()) this.syncOne(b);
2118
2338
  }
@@ -2167,6 +2387,9 @@ var Renderer = class {
2167
2387
  case "detach":
2168
2388
  this.onDetach(ev);
2169
2389
  break;
2390
+ case "settle":
2391
+ this.onSettle(ev.object);
2392
+ break;
2170
2393
  case "collapse":
2171
2394
  this.splitLoose(ev.object);
2172
2395
  this.playEvent({ kind: "collapse" }, this.rootPoint(ev.object));
@@ -2252,13 +2475,39 @@ var Renderer = class {
2252
2475
  }
2253
2476
  case "leak": {
2254
2477
  const key = `${ev.object}|${ev.part}`;
2478
+ const b = this.bounds.get(ev.object);
2479
+ let localPoint;
2480
+ let localDirection;
2481
+ if (b) {
2482
+ b.root.updateWorldMatrix(true, false);
2483
+ const inv = new this.THREE.Matrix4().copy(b.root.matrixWorld).invert();
2484
+ const lp = this.v1.set(ev.point[0], ev.point[1], ev.point[2]).applyMatrix4(inv);
2485
+ const ld = this.v2.set(ev.direction[0], ev.direction[1], ev.direction[2]).transformDirection(inv).normalize();
2486
+ localPoint = [lp.x, lp.y, lp.z];
2487
+ localDirection = [ld.x, ld.y, ld.z];
2488
+ }
2255
2489
  const same = this.leaks.find((l) => l.object === ev.object && l.part === ev.part && Math.hypot(l.point[0] - ev.point[0], l.point[1] - ev.point[1], l.point[2] - ev.point[2]) < 0.03);
2256
2490
  if (same) {
2257
2491
  same.rate = ev.rate;
2258
2492
  same.direction = ev.direction;
2493
+ if (localPoint) same.localPoint = localPoint;
2494
+ if (localDirection) same.localDirection = localDirection;
2259
2495
  } else {
2260
2496
  if (this.leaks.length >= 24) this.leaks.shift();
2261
- this.leaks.push({ object: ev.object, part: ev.part, fluid: ev.fluid, point: ev.point, direction: ev.direction, rate: ev.rate, pressurized: ev.pressurized, acc: 0 });
2497
+ this.leaks.push({
2498
+ object: ev.object,
2499
+ part: ev.part,
2500
+ fluid: ev.fluid,
2501
+ point: ev.point,
2502
+ direction: ev.direction,
2503
+ rate: ev.rate,
2504
+ pressurized: ev.pressurized,
2505
+ acc: 0,
2506
+ opened: this.world.part(ev.object, ev.part)?.holes.some((h) => h.through) ?? false,
2507
+ localPoint,
2508
+ localDirection,
2509
+ life: 0
2510
+ });
2262
2511
  }
2263
2512
  void key;
2264
2513
  break;
@@ -2335,10 +2584,9 @@ var Renderer = class {
2335
2584
  const def = this.world.materials.get(pv.material);
2336
2585
  const wp = new this.THREE.Vector3(ev.worldPoint[0], ev.worldPoint[1], ev.worldPoint[2]);
2337
2586
  const wn = this.worldDir(b, ev.dent.normal);
2338
- for (const mv of pv.meshes) {
2339
- if (!this.visuals.shown(mv)) continue;
2340
- this.visuals.applyDent(mv, wp, wn, ev.dent);
2341
- }
2587
+ const shown = pv.meshes.filter((mv) => this.visuals.shown(mv));
2588
+ const reach = shown.reduce((widest, mv) => Math.max(widest, this.visuals.dentReach(mv, wp, ev.dent)), ev.dent.radius);
2589
+ this.visuals.applyPartDent(shown, wp, wn, ev.dent, reach);
2342
2590
  if (def?.tags?.includes("metal")) this.vfx.burst("sparks", ev.worldPoint, [wn.x, wn.y, wn.z], 4 + Math.min(8, ev.dent.depth * 150), 3.2);
2343
2591
  this.sound(impactKey(def), ev.worldPoint, clamp(0.3 + ev.dent.depth * 12, 0.3, 1));
2344
2592
  }
@@ -2534,6 +2782,7 @@ var Renderer = class {
2534
2782
  }
2535
2783
  }
2536
2784
  this.bounds.set(newId, nb);
2785
+ for (const pv of nb.parts.values()) this.tagPart(pv, newId);
2537
2786
  const meshes = this.meshesOf(nb);
2538
2787
  if (meshes.length === 0) return;
2539
2788
  const { half, center } = this.measure(group, meshes);
@@ -2561,24 +2810,40 @@ var Renderer = class {
2561
2810
  const report = options.report;
2562
2811
  const confinement = report?.find((r) => r.blast !== void 0)?.blast?.confinement ?? 1;
2563
2812
  const energy = ev.energy * confinement;
2564
- for (const pc of this.pieceSys.list) {
2813
+ const reachOf = (objectId) => report?.find((r) => r.object === objectId)?.blast?.shelter ?? 1;
2814
+ const shove = (pc) => {
2565
2815
  this.pieceSys.centerOf(pc, c);
2566
- const dx = c.x - p[0];
2567
- const dy = c.y - p[1];
2568
- const dz = c.z - p[2];
2569
- const d = Math.hypot(dx, dy, dz);
2570
- if (d > ev.radius) continue;
2571
- const fall = (1 - d / ev.radius) ** 2;
2572
- const reach = report?.find((r) => r.object === pc.objectId)?.blast?.shelter ?? 1;
2573
- const speed = Math.min(30, Math.sqrt(2 * energy * reach * 0.02 / pc.mass)) * fall;
2574
- const inv = d > 1e-4 ? 1 / d : 0;
2575
- const dir = d > 1e-4 ? [dx * inv, dy * inv + 0.3, dz * inv] : [0, 1, 0];
2816
+ const hit = blastShove({ point: p, energy, radius: ev.radius, reach: reachOf(pc.objectId) }, { position: [c.x, c.y, c.z], mass: pc.mass }, this.o.blastShove);
2817
+ if (!hit) return;
2576
2818
  const spread = pc.halfExtents[0];
2577
- this.pieceSys.impulse(pc, [dir[0] * speed * pc.mass, dir[1] * speed * pc.mass, dir[2] * speed * pc.mass], [c.x + (this.rng() - 0.5) * spread, c.y + spread * 0.5, c.z + (this.rng() - 0.5) * spread]);
2578
- }
2819
+ const momentum = hit.speed * pc.mass;
2820
+ this.pieceSys.impulse(pc, [hit.direction[0] * momentum, hit.direction[1] * momentum, hit.direction[2] * momentum], [c.x + (this.rng() - 0.5) * spread, c.y + spread * 0.5, c.z + (this.rng() - 0.5) * spread]);
2821
+ };
2822
+ for (const pc of [...this.pieceSys.list]) shove(pc);
2823
+ if (this.o.shoveable) this.loosen(p, ev.radius, energy, reachOf);
2579
2824
  this.fragments.blast(p, ev.radius, Math.min(20, Math.sqrt(energy) * 0.08));
2580
2825
  this.sound("explode", p, clamp(0.5 + ev.radius / 10, 0.5, 1));
2581
2826
  }
2827
+ /** 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. */
2828
+ loosen(point, radius, energy, reachOf) {
2829
+ const shoveable = this.o.shoveable;
2830
+ if (!shoveable) return;
2831
+ for (const b of [...this.bounds.values()]) {
2832
+ if (b.piece || this.pieceSys.get(b.id) || !shoveable(b.id) || !this.world.has(b.id)) continue;
2833
+ const { half, center } = this.measure(b.root, this.meshesOf(b));
2834
+ const world = center.clone().applyQuaternion(b.root.quaternion).add(b.root.position);
2835
+ const mass = this.massOf(b.id);
2836
+ const hit = blastShove({ point, energy, radius, reach: reachOf(b.id) }, { position: [world.x, world.y, world.z], mass }, this.o.blastShove);
2837
+ if (!hit) continue;
2838
+ const piece = this.makePiece(b.id, { mass });
2839
+ const body = this.pieceSys.get(b.id);
2840
+ if (!body) continue;
2841
+ this.o.onLoosened?.(b.id, piece);
2842
+ const spread = half[0];
2843
+ const momentum = hit.speed * mass;
2844
+ this.pieceSys.impulse(body, [hit.direction[0] * momentum, hit.direction[1] * momentum, hit.direction[2] * momentum], [world.x + (this.rng() - 0.5) * spread, world.y + spread * 0.5, world.z + (this.rng() - 0.5) * spread]);
2845
+ }
2846
+ }
2582
2847
  playExplosion(ev, options) {
2583
2848
  const named = options.effect ?? ev.effect;
2584
2849
  const direction = options.direction;
@@ -2678,21 +2943,44 @@ var Renderer = class {
2678
2943
  updateLeaks(dt) {
2679
2944
  for (let i = this.leaks.length - 1; i >= 0; i--) {
2680
2945
  const l = this.leaks[i];
2946
+ const b = this.bounds.get(l.object);
2681
2947
  const ps = this.world.part(l.object, l.part);
2682
- if (!ps || ps.liters === void 0 || ps.liters <= 0) {
2948
+ if (!b || !ps || ps.liters === void 0 || ps.liters <= 1e-4 || l.opened && !ps.holes.some((h) => h.through)) {
2683
2949
  this.leaks.splice(i, 1);
2684
2950
  continue;
2685
2951
  }
2952
+ l.life = (l.life ?? 0) + dt;
2953
+ if (l.pressurized && l.life > 0.8) l.pressurized = false;
2954
+ if (l.lastLiters !== void 0 && Math.abs(ps.liters - l.lastLiters) < 1e-5) {
2955
+ l.idleSeconds = (l.idleSeconds ?? 0) + dt;
2956
+ if (l.idleSeconds > 0.4) {
2957
+ this.leaks.splice(i, 1);
2958
+ continue;
2959
+ }
2960
+ } else {
2961
+ l.idleSeconds = 0;
2962
+ l.lastLiters = ps.liters;
2963
+ }
2964
+ let pt = l.point;
2965
+ let dir = l.direction;
2966
+ if (l.localPoint && l.localDirection) {
2967
+ b.root.updateWorldMatrix(true, false);
2968
+ const wp = this.v1.set(l.localPoint[0], l.localPoint[1], l.localPoint[2]).applyMatrix4(b.root.matrixWorld);
2969
+ const wd = this.v2.set(l.localDirection[0], l.localDirection[1], l.localDirection[2]).transformDirection(b.root.matrixWorld).normalize();
2970
+ pt = [wp.x, wp.y, wp.z];
2971
+ dir = [wd.x, wd.y, wd.z];
2972
+ l.point = pt;
2973
+ l.direction = dir;
2974
+ }
2686
2975
  l.acc += clamp(20 + l.rate * 400, 15, 120) * dt;
2687
2976
  const color = FLUID_COLORS[l.fluid] ?? 11184810;
2688
- const d = l.direction;
2689
- const dl = Math.hypot(d[0], d[1], d[2]) || 1;
2977
+ const dl = Math.hypot(dir[0], dir[1], dir[2]) || 1;
2690
2978
  const sp = l.pressurized ? 2.5 + this.rng() * 1.5 : 0.3 + this.rng() * 0.3;
2691
2979
  while (l.acc >= 1) {
2692
2980
  l.acc -= 1;
2693
- const vel = [d[0] / dl * sp + (this.rng() - 0.5) * 0.25, d[1] / dl * sp + (this.rng() - 0.5) * 0.25, d[2] / dl * sp + (this.rng() - 0.5) * 0.25];
2694
- if (l.fluid === "gas") this.vfx.spawn("steam", l.point, vel, { color, size: 0.5, alpha: 0.5 });
2695
- else this.vfx.spawn("drips", l.point, vel, { color, tag: this.rng() < 0.03 ? this.tagOf(l.object) : 0, owner: this.owner });
2981
+ const vel = [dir[0] / dl * sp + (this.rng() - 0.5) * 0.25, dir[1] / dl * sp + (this.rng() - 0.5) * 0.25, dir[2] / dl * sp + (this.rng() - 0.5) * 0.25];
2982
+ if (l.fluid === "gas") this.vfx.spawn("steam", pt, vel, { color, size: 0.5, alpha: 0.5 });
2983
+ else this.vfx.spawn("drips", pt, vel, { color, tag: this.rng() < 0.03 ? this.tagOf(l.object) : 0, owner: this.owner });
2696
2984
  }
2697
2985
  }
2698
2986
  }
@@ -2730,6 +3018,7 @@ var Renderer = class {
2730
3018
  for (const [pid, pv] of [...b.parts]) {
2731
3019
  if (pv.home !== home) continue;
2732
3020
  this.visuals.restorePart(pv);
3021
+ this.tagPart(pv, home);
2733
3022
  if (b !== homeBound && homeBound) {
2734
3023
  b.parts.delete(pid);
2735
3024
  homeBound.parts.set(pid, pv);
@@ -2771,10 +3060,31 @@ var Renderer = class {
2771
3060
  const { puffs, sparks, glows, chunks } = this.vfx.stats();
2772
3061
  return puffs + sparks + glows + chunks;
2773
3062
  }
3063
+ warmUpProbes() {
3064
+ this.releaseWarmProbes();
3065
+ const { kit } = this.visuals;
3066
+ const T = this.THREE;
3067
+ const geometry = new T.BufferGeometry();
3068
+ geometry.setAttribute("position", new T.BufferAttribute(new Float32Array(6), 3));
3069
+ const materials = [kit.discMaterial().clone(), kit.patchMaterial(8421504).clone(), kit.lineMaterial(0).clone()];
3070
+ const group = new T.Group();
3071
+ group.add(new T.Mesh(kit.discGeometry(), materials[0]), new T.Mesh(kit.discGeometry(), materials[1]), new T.LineSegments(geometry, materials[2]));
3072
+ for (const probe of group.children) probe.frustumCulled = false;
3073
+ this.warmProbes = { group, geometry, materials };
3074
+ return { group, dispose: () => {
3075
+ } };
3076
+ }
3077
+ releaseWarmProbes() {
3078
+ if (!this.warmProbes) return;
3079
+ this.warmProbes.geometry.dispose();
3080
+ for (const material of this.warmProbes.materials) material.dispose();
3081
+ this.warmProbes = void 0;
3082
+ }
2774
3083
  rebarCount() {
2775
3084
  return this.cages.count;
2776
3085
  }
2777
3086
  dispose() {
3087
+ this.releaseWarmProbes();
2778
3088
  if (this.disposed) return;
2779
3089
  this.unsubscribe();
2780
3090
  this.clear();