@driftengine/physics 4.5.0 → 4.6.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/README.md CHANGED
@@ -5,7 +5,7 @@ kinematic sweep that resolves a moving body against a static world one axis at a
5
5
 
6
6
  **It imports no other engine package.** Together with `@driftengine/entities` that makes a
7
7
  deterministic simulation you can run with no renderer in its module graph — in Node, in a worker,
8
- or on a server, which is what an authoritative host needs. **45.2 KB gzipped** standalone, measured
8
+ or on a server, which is what an authoritative host needs. **45.4 KB gzipped** standalone, measured
9
9
  by `scripts/size-gate.test.mjs` against `scripts/fixtures/size/physics-only.ts`.
10
10
 
11
11
  **`@driftengine/core` re-exports every name here**, so reaching for collision through the core
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export type { Manifold, ShapePose } from './manifold.ts';
25
25
  export { MAX_CONTACTS, collideShapes, createManifold } from './manifold.ts';
26
26
  export type { Parallelism, WorldOptions } from './world.ts';
27
27
  export { PhysicsWorld } from './world.ts';
28
+ export { StaticRegions } from './staticRegions.ts';
28
29
  export { ContactConstraints, LINEAR_SLOP, MAX_BIAS_VELOCITY, RESTITUTION_THRESHOLD, applyRestitution, isDynamicBody, prepareContact, solveContacts, warmStart, } from './solver.ts';
29
30
  export { IslandSet } from './island.ts';
30
31
  export type { FrictionModel } from './solver.ts';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { DynamicTree } from './tree.js';
6
6
  export { MAX_BODIES, PairSet, layersInteract, pairA, pairB, pairKey } from './pairs.js';
7
7
  export { MAX_CONTACTS, collideShapes, createManifold } from './manifold.js';
8
8
  export { PhysicsWorld } from './world.js';
9
+ export { StaticRegions } from './staticRegions.js';
9
10
  export { ContactConstraints, LINEAR_SLOP, MAX_BIAS_VELOCITY, RESTITUTION_THRESHOLD, applyRestitution, isDynamicBody, prepareContact, solveContacts, warmStart, } from './solver.js';
10
11
  export { IslandSet } from './island.js';
11
12
  export { DisjointExecutor, SerialExecutor, ShuffledExecutor } from './executor.js';
package/dist/manifold.js CHANGED
@@ -400,6 +400,21 @@ function closestOnPolytope(shape, world, planes, faces, px, py, pz) {
400
400
  bestPlane = f;
401
401
  }
402
402
  }
403
+ /*
404
+ * **One face is a surface, not a solid: behind it is inside only where the point projects onto
405
+ * it.** A mesh's triangle arrives here as a polytope of one face, and "behind every face" is then
406
+ * the whole half-space behind the triangle's plane — so a capsule standing on a kerb, its axis a
407
+ * few centimetres past the kerb's front face, was "inside" that face's triangles half a metre above
408
+ * them, pushed back along the face normal and stopped dead on top of the kerb. Where the point
409
+ * projects outside the triangle the closest point is on its boundary, which the outside path finds
410
+ * and the mesh's one-sided rule then sorts. Behind the triangle and over it is still inside, which
411
+ * is what holds a sphere that has dipped a little through a floor.
412
+ */
413
+ if (inside &&
414
+ faces === 1 &&
415
+ !projectsOntoFace(shape, world, planes, px, py, pz, bestPlane, bestPlaneDist)) {
416
+ inside = false;
417
+ }
403
418
  if (inside) {
404
419
  const nx = planes[bestPlane * 4] ?? 0;
405
420
  const ny = planes[bestPlane * 4 + 1] ?? 0;
@@ -439,6 +454,32 @@ function closestOnPolytope(shape, world, planes, faces, px, py, pz) {
439
454
  CLOSEST[6] = Math.sqrt(bestD2);
440
455
  }
441
456
  const TRI = new Float64Array(3);
457
+ /**
458
+ * Whether `p`, `distance` from face `face`'s plane along its normal, projects inside that face: the
459
+ * closest point on the face to its projection is the projection itself.
460
+ */
461
+ function projectsOntoFace(shape, world, planes, px, py, pz, face, distance) {
462
+ const n = faceVertices(shape, face, loop);
463
+ /* The world plane the distance was measured against, so a rotated mesh projects correctly. */
464
+ const nx = planes[face * 4] ?? 0;
465
+ const ny = planes[face * 4 + 1] ?? 0;
466
+ const nz = planes[face * 4 + 2] ?? 0;
467
+ const qx = px - nx * distance;
468
+ const qy = py - ny * distance;
469
+ const qz = pz - nz * distance;
470
+ const a = (loop[0] ?? 0) * 3;
471
+ for (let i = 1; i + 1 < n; i++) {
472
+ const b = (loop[i] ?? 0) * 3;
473
+ const c = (loop[i + 1] ?? 0) * 3;
474
+ closestOnTriangle(qx, qy, qz, world[a] ?? 0, world[a + 1] ?? 0, world[a + 2] ?? 0, world[b] ?? 0, world[b + 1] ?? 0, world[b + 2] ?? 0, world[c] ?? 0, world[c + 1] ?? 0, world[c + 2] ?? 0);
475
+ const dx = qx - TRI[0];
476
+ const dy = qy - TRI[1];
477
+ const dz = qz - TRI[2];
478
+ if (dx * dx + dy * dy + dz * dz < 1e-12)
479
+ return true;
480
+ }
481
+ return false;
482
+ }
442
483
  /** Closest point on a triangle to `p`, by the standard barycentric region test. */
443
484
  function closestOnTriangle(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
444
485
  const abx = bx - ax;
@@ -1,6 +1,7 @@
1
1
  import { fieldCandidates, fieldConvexEdges, fieldCorners, fieldPlane } from './heightfieldShape.js';
2
2
  import { collideShapes, createManifold } from './manifold.js';
3
3
  import { shapeBounds } from './shape.js';
4
+ import { faceCount } from './faces.js';
4
5
  /**
5
6
  * Contacts between a convex shape and a static triangle mesh.
6
7
  *
@@ -93,6 +94,8 @@ export function collideMesh(shapeA, poseA, shapeB, poseB, margin, out) {
93
94
  /* Two meshes never touch: both are static, so nothing would move if they did. */
94
95
  if (isMesh(other))
95
96
  return 0;
97
+ /* A sphere or capsule's contacts lie on the triangle, not on it; see `filterInteriorEdge`. */
98
+ const roundRadius = faceCount(other) === 0 ? other.radius : -1;
96
99
  /*
97
100
  * The moving shape's bounds, carried into the mesh's own frame — because the tree is in that
98
101
  * frame and rebuilding it per frame is exactly what static means it never has to do. The box is
@@ -126,8 +129,8 @@ export function collideMesh(shapeA, poseA, shapeB, poseB, margin, out) {
126
129
  /* One-sided: a normal pointing into the back of the triangle is a body already through it. */
127
130
  if (away <= 0)
128
131
  continue;
129
- if (away < FACE_DOT)
130
- filterInteriorEdge(mesh, triangle, meshPose, sign);
132
+ if (away < FACE_DOT && !filterInteriorEdge(mesh, triangle, meshPose, sign, roundRadius))
133
+ continue;
131
134
  const target = out[count];
132
135
  if (target === undefined)
133
136
  break;
@@ -211,8 +214,18 @@ function worldCorner(mesh, triangle, corner, pose, out, at) {
211
214
  * The separations are recomputed from the face plane rather than scaled, because the direction has
212
215
  * changed and a scaled depth would hold a body off the floor by the cosine of an angle nobody
213
216
  * chose.
217
+ *
218
+ * **A sphere or capsule is measured from its own centre, not from the contact point.** Its manifold's
219
+ * points lie on the triangle, so their distance from the face plane is zero whatever the real gap —
220
+ * and a capsule standing on a kerb, just past the top edge of the kerb's front face, read as touching
221
+ * that face with the face's normal, which faces backwards along its walk: a wall it had already
222
+ * stepped over, stopping it dead on top. So the centre that produced each contact is rebuilt from
223
+ * the edge normal before that normal is replaced, and the separation is its plane distance less the
224
+ * radius. **A centre behind the face plane drops the contact**, returning false: the one-sided rule,
225
+ * applied to the geometry after the swap as it is to the geometry before it. A polytope's points lie
226
+ * on the body, so their plane distance is already the separation, as before.
214
227
  */
215
- function filterInteriorEdge(mesh, triangle, meshPose, sign) {
228
+ function filterInteriorEdge(mesh, triangle, meshPose, sign, roundRadius) {
216
229
  let deepest = 0;
217
230
  for (let i = 1; i < scratch.count; i++) {
218
231
  if ((scratch.separations[i] ?? 0) < (scratch.separations[deepest] ?? 0))
@@ -234,12 +247,32 @@ function filterInteriorEdge(mesh, triangle, meshPose, sign) {
234
247
  }
235
248
  /* A convex edge is a real one: the ridge is there and the body should feel it. */
236
249
  if ((convexEdgesOf(mesh, triangle) >> nearest) & 1)
237
- return;
250
+ return true;
251
+ worldPlaneAnchor(mesh, triangle, meshPose, ANCHOR);
252
+ const plane = FACE[0] * ANCHOR[0] + FACE[1] * ANCHOR[1] + FACE[2] * ANCHOR[2];
253
+ if (roundRadius >= 0) {
254
+ /* Mesh-to-body along the old normal, out to the centre: the point plus that much of the gap. */
255
+ const ux = sign * scratch.nx;
256
+ const uy = sign * scratch.ny;
257
+ const uz = sign * scratch.nz;
258
+ for (let i = 0; i < scratch.count; i++) {
259
+ const reach = (scratch.separations[i] ?? 0) + roundRadius;
260
+ const cx = (scratch.points[i * 3] ?? 0) + ux * reach;
261
+ const cy = (scratch.points[i * 3 + 1] ?? 0) + uy * reach;
262
+ const cz = (scratch.points[i * 3 + 2] ?? 0) + uz * reach;
263
+ const height = cx * FACE[0] + cy * FACE[1] + cz * FACE[2] - plane;
264
+ if (height < 0)
265
+ return false;
266
+ scratch.separations[i] = height - roundRadius;
267
+ }
268
+ scratch.nx = sign * FACE[0];
269
+ scratch.ny = sign * FACE[1];
270
+ scratch.nz = sign * FACE[2];
271
+ return true;
272
+ }
238
273
  scratch.nx = sign * FACE[0];
239
274
  scratch.ny = sign * FACE[1];
240
275
  scratch.nz = sign * FACE[2];
241
- worldPlaneAnchor(mesh, triangle, meshPose, ANCHOR);
242
- const plane = FACE[0] * ANCHOR[0] + FACE[1] * ANCHOR[1] + FACE[2] * ANCHOR[2];
243
276
  for (let i = 0; i < scratch.count; i++) {
244
277
  scratch.separations[i] =
245
278
  (scratch.points[i * 3] ?? 0) * FACE[0] +
@@ -247,6 +280,7 @@ function filterInteriorEdge(mesh, triangle, meshPose, sign) {
247
280
  (scratch.points[i * 3 + 2] ?? 0) * FACE[2] -
248
281
  plane;
249
282
  }
283
+ return true;
250
284
  }
251
285
  const EDGE = new Float64Array(6);
252
286
  const ANCHOR = new Float64Array(3);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A world's static scenery by region: one body a region, added when the region streams in and
3
+ * removed when it streams out, with each region's body index kept right as the world moves bodies.
4
+ *
5
+ * **One body a region, and a triangle mesh is what makes that cheap.** A city region holds hundreds
6
+ * of boxes and a few odd shapes; as bodies they would be tens of thousands for the city, every one
7
+ * an index to follow. As one static triangle mesh each — `meshShape`, which carries its own tree —
8
+ * the whole city is a few hundred bodies and a region comes and goes as one add and one remove.
9
+ *
10
+ * **Why the indices need following.** `PhysicsWorld.removeBody` fills the hole with its last body,
11
+ * so removing one region's body moves another's — possibly another region's, possibly anything
12
+ * else in the world. It returns the index that moved, and this uses it. **What it gives up** is any
13
+ * other caller's body index in the same world: a removal moves whatever was last, and only the
14
+ * caller that asked for it hears where it went. A world that also holds bodies somebody else tracks
15
+ * by index needs those removed through one place that follows them all.
16
+ */
17
+ import type { BodyDesc } from './bodies.ts';
18
+ import type { PhysicsWorld } from './world.ts';
19
+ export declare class StaticRegions {
20
+ private readonly world;
21
+ private readonly bodyOfRegion;
22
+ /** The region each body index belongs to, or −1. Grows with the world. */
23
+ private regionOfBody;
24
+ constructor(world: PhysicsWorld);
25
+ /** How many regions are in the world. */
26
+ get size(): number;
27
+ has(region: number): boolean;
28
+ /** The body index a region stands as now, or −1. It changes as other regions are removed. */
29
+ bodyOf(region: number): number;
30
+ /** Add a region as one body. A region already in the world is refused rather than doubled. */
31
+ add(region: number, desc: BodyDesc): number;
32
+ /** Take a region out of the world. A region that is not there is nothing to remove. */
33
+ remove(region: number): void;
34
+ }
@@ -0,0 +1,54 @@
1
+ export class StaticRegions {
2
+ world;
3
+ bodyOfRegion = new Map();
4
+ /** The region each body index belongs to, or −1. Grows with the world. */
5
+ regionOfBody = new Int32Array(64).fill(-1);
6
+ constructor(world) {
7
+ this.world = world;
8
+ }
9
+ /** How many regions are in the world. */
10
+ get size() {
11
+ return this.bodyOfRegion.size;
12
+ }
13
+ has(region) {
14
+ return this.bodyOfRegion.has(region);
15
+ }
16
+ /** The body index a region stands as now, or −1. It changes as other regions are removed. */
17
+ bodyOf(region) {
18
+ return this.bodyOfRegion.get(region) ?? -1;
19
+ }
20
+ /** Add a region as one body. A region already in the world is refused rather than doubled. */
21
+ add(region, desc) {
22
+ if (this.bodyOfRegion.has(region)) {
23
+ throw new Error(`StaticRegions: region ${region} is already in the world — remove it before adding it ` +
24
+ 'again, or its scenery would collide twice');
25
+ }
26
+ const body = this.world.addBody(desc);
27
+ while (body >= this.regionOfBody.length) {
28
+ const wider = new Int32Array(this.regionOfBody.length * 2).fill(-1);
29
+ wider.set(this.regionOfBody);
30
+ this.regionOfBody = wider;
31
+ }
32
+ this.regionOfBody[body] = region;
33
+ this.bodyOfRegion.set(region, body);
34
+ return body;
35
+ }
36
+ /** Take a region out of the world. A region that is not there is nothing to remove. */
37
+ remove(region) {
38
+ const body = this.bodyOfRegion.get(region);
39
+ if (body === undefined)
40
+ return;
41
+ this.bodyOfRegion.delete(region);
42
+ const moved = this.world.removeBody(body);
43
+ if (moved < 0) {
44
+ this.regionOfBody[body] = -1;
45
+ return;
46
+ }
47
+ /* The body that was last now sits at `body`: whichever region owned it follows it there. */
48
+ const owner = this.regionOfBody[moved] ?? -1;
49
+ this.regionOfBody[body] = owner;
50
+ this.regionOfBody[moved] = -1;
51
+ if (owner >= 0)
52
+ this.bodyOfRegion.set(owner, body);
53
+ }
54
+ }
package/dist/world.d.ts CHANGED
@@ -220,7 +220,12 @@ export declare class PhysicsWorld implements IslandSolver {
220
220
  * quietly stop the newcomer colliding with whatever the departed was jointed to.
221
221
  */
222
222
  private followRemoval;
223
- removeBody(index: number): void;
223
+ /**
224
+ * Remove a body, filling its index with the last body — whose index this returns, or −1 when the
225
+ * removed body was the last. **Every index past the removed one can change**, so a caller keeping
226
+ * body indices follows the returned one; `StaticRegions` is the worked example.
227
+ */
228
+ removeBody(index: number): number;
224
229
  /** Advance one fixed tick. */
225
230
  step(dt: number): void;
226
231
  /**
package/dist/world.js CHANGED
@@ -270,6 +270,11 @@ export class PhysicsWorld {
270
270
  for (const key of kept)
271
271
  this.ignoredPairs.add(key);
272
272
  }
273
+ /**
274
+ * Remove a body, filling its index with the last body — whose index this returns, or −1 when the
275
+ * removed body was the last. **Every index past the removed one can change**, so a caller keeping
276
+ * body indices follows the returned one; `StaticRegions` is the worked example.
277
+ */
273
278
  removeBody(index) {
274
279
  this.tree.remove(this.leafOf[index] ?? 0);
275
280
  const moved = this.bodies.remove(index);
@@ -282,6 +287,7 @@ export class PhysicsWorld {
282
287
  this.warm.clear();
283
288
  // Every index past the removed one may have moved, so a remembered pair is a lie.
284
289
  this.events.clear();
290
+ return moved;
285
291
  }
286
292
  /** Advance one fixed tick. */
287
293
  step(dt) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/physics",
3
- "version": "4.5.0",
3
+ "version": "4.6.1",
4
4
  "description": "Collision shapes, spatial queries and swept kinematic collision",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export type { Manifold, ShapePose } from './manifold.ts';
35
35
  export { MAX_CONTACTS, collideShapes, createManifold } from './manifold.ts';
36
36
  export type { Parallelism, WorldOptions } from './world.ts';
37
37
  export { PhysicsWorld } from './world.ts';
38
+ export { StaticRegions } from './staticRegions.ts';
38
39
  export {
39
40
  ContactConstraints,
40
41
  LINEAR_SLOP,
package/src/manifold.ts CHANGED
@@ -542,6 +542,23 @@ function closestOnPolytope(
542
542
  bestPlane = f;
543
543
  }
544
544
  }
545
+ /*
546
+ * **One face is a surface, not a solid: behind it is inside only where the point projects onto
547
+ * it.** A mesh's triangle arrives here as a polytope of one face, and "behind every face" is then
548
+ * the whole half-space behind the triangle's plane — so a capsule standing on a kerb, its axis a
549
+ * few centimetres past the kerb's front face, was "inside" that face's triangles half a metre above
550
+ * them, pushed back along the face normal and stopped dead on top of the kerb. Where the point
551
+ * projects outside the triangle the closest point is on its boundary, which the outside path finds
552
+ * and the mesh's one-sided rule then sorts. Behind the triangle and over it is still inside, which
553
+ * is what holds a sphere that has dipped a little through a floor.
554
+ */
555
+ if (
556
+ inside &&
557
+ faces === 1 &&
558
+ !projectsOntoFace(shape, world, planes, px, py, pz, bestPlane, bestPlaneDist)
559
+ ) {
560
+ inside = false;
561
+ }
545
562
  if (inside) {
546
563
  const nx = planes[bestPlane * 4] ?? 0;
547
564
  const ny = planes[bestPlane * 4 + 1] ?? 0;
@@ -597,6 +614,54 @@ function closestOnPolytope(
597
614
 
598
615
  const TRI = new Float64Array(3);
599
616
 
617
+ /**
618
+ * Whether `p`, `distance` from face `face`'s plane along its normal, projects inside that face: the
619
+ * closest point on the face to its projection is the projection itself.
620
+ */
621
+ function projectsOntoFace(
622
+ shape: ConvexShape,
623
+ world: Float32Array,
624
+ planes: Float32Array,
625
+ px: number,
626
+ py: number,
627
+ pz: number,
628
+ face: number,
629
+ distance: number,
630
+ ): boolean {
631
+ const n = faceVertices(shape, face, loop);
632
+ /* The world plane the distance was measured against, so a rotated mesh projects correctly. */
633
+ const nx = planes[face * 4] ?? 0;
634
+ const ny = planes[face * 4 + 1] ?? 0;
635
+ const nz = planes[face * 4 + 2] ?? 0;
636
+ const qx = px - nx * distance;
637
+ const qy = py - ny * distance;
638
+ const qz = pz - nz * distance;
639
+ const a = (loop[0] ?? 0) * 3;
640
+ for (let i = 1; i + 1 < n; i++) {
641
+ const b = (loop[i] ?? 0) * 3;
642
+ const c = (loop[i + 1] ?? 0) * 3;
643
+ closestOnTriangle(
644
+ qx,
645
+ qy,
646
+ qz,
647
+ world[a] ?? 0,
648
+ world[a + 1] ?? 0,
649
+ world[a + 2] ?? 0,
650
+ world[b] ?? 0,
651
+ world[b + 1] ?? 0,
652
+ world[b + 2] ?? 0,
653
+ world[c] ?? 0,
654
+ world[c + 1] ?? 0,
655
+ world[c + 2] ?? 0,
656
+ );
657
+ const dx = qx - TRI[0];
658
+ const dy = qy - TRI[1];
659
+ const dz = qz - TRI[2];
660
+ if (dx * dx + dy * dy + dz * dz < 1e-12) return true;
661
+ }
662
+ return false;
663
+ }
664
+
600
665
  /** Closest point on a triangle to `p`, by the standard barycentric region test. */
601
666
  function closestOnTriangle(
602
667
  px: number,
@@ -4,6 +4,7 @@ import { collideShapes, createManifold } from './manifold.ts';
4
4
  import type { Manifold, ShapePose } from './manifold.ts';
5
5
  import type { ConvexShape } from './shape.ts';
6
6
  import { shapeBounds } from './shape.ts';
7
+ import { faceCount } from './faces.ts';
7
8
 
8
9
  /**
9
10
  * Contacts between a convex shape and a static triangle mesh.
@@ -108,6 +109,8 @@ export function collideMesh(
108
109
  const otherPose = meshIsA ? poseB : poseA;
109
110
  /* Two meshes never touch: both are static, so nothing would move if they did. */
110
111
  if (isMesh(other)) return 0;
112
+ /* A sphere or capsule's contacts lie on the triangle, not on it; see `filterInteriorEdge`. */
113
+ const roundRadius = faceCount(other) === 0 ? other.radius : -1;
111
114
 
112
115
  /*
113
116
  * The moving shape's bounds, carried into the mesh's own frame — because the tree is in that
@@ -141,7 +144,8 @@ export function collideMesh(
141
144
  sign * scratch.nx * FACE[0] + sign * scratch.ny * FACE[1] + sign * scratch.nz * FACE[2];
142
145
  /* One-sided: a normal pointing into the back of the triangle is a body already through it. */
143
146
  if (away <= 0) continue;
144
- if (away < FACE_DOT) filterInteriorEdge(mesh, triangle, meshPose, sign);
147
+ if (away < FACE_DOT && !filterInteriorEdge(mesh, triangle, meshPose, sign, roundRadius))
148
+ continue;
145
149
 
146
150
  const target = out[count];
147
151
  if (target === undefined) break;
@@ -237,13 +241,24 @@ function worldCorner(
237
241
  * The separations are recomputed from the face plane rather than scaled, because the direction has
238
242
  * changed and a scaled depth would hold a body off the floor by the cosine of an angle nobody
239
243
  * chose.
244
+ *
245
+ * **A sphere or capsule is measured from its own centre, not from the contact point.** Its manifold's
246
+ * points lie on the triangle, so their distance from the face plane is zero whatever the real gap —
247
+ * and a capsule standing on a kerb, just past the top edge of the kerb's front face, read as touching
248
+ * that face with the face's normal, which faces backwards along its walk: a wall it had already
249
+ * stepped over, stopping it dead on top. So the centre that produced each contact is rebuilt from
250
+ * the edge normal before that normal is replaced, and the separation is its plane distance less the
251
+ * radius. **A centre behind the face plane drops the contact**, returning false: the one-sided rule,
252
+ * applied to the geometry after the swap as it is to the geometry before it. A polytope's points lie
253
+ * on the body, so their plane distance is already the separation, as before.
240
254
  */
241
255
  function filterInteriorEdge(
242
256
  mesh: NonNullable<ConvexShape['triangles']>,
243
257
  triangle: number,
244
258
  meshPose: ShapePose,
245
259
  sign: number,
246
- ): void {
260
+ roundRadius: number,
261
+ ): boolean {
247
262
  let deepest = 0;
248
263
  for (let i = 1; i < scratch.count; i++) {
249
264
  if ((scratch.separations[i] ?? 0) < (scratch.separations[deepest] ?? 0)) deepest = i;
@@ -264,13 +279,33 @@ function filterInteriorEdge(
264
279
  }
265
280
  }
266
281
  /* A convex edge is a real one: the ridge is there and the body should feel it. */
267
- if ((convexEdgesOf(mesh, triangle) >> nearest) & 1) return;
282
+ if ((convexEdgesOf(mesh, triangle) >> nearest) & 1) return true;
283
+
284
+ worldPlaneAnchor(mesh, triangle, meshPose, ANCHOR);
285
+ const plane = FACE[0] * ANCHOR[0] + FACE[1] * ANCHOR[1] + FACE[2] * ANCHOR[2];
286
+ if (roundRadius >= 0) {
287
+ /* Mesh-to-body along the old normal, out to the centre: the point plus that much of the gap. */
288
+ const ux = sign * scratch.nx;
289
+ const uy = sign * scratch.ny;
290
+ const uz = sign * scratch.nz;
291
+ for (let i = 0; i < scratch.count; i++) {
292
+ const reach = (scratch.separations[i] ?? 0) + roundRadius;
293
+ const cx = (scratch.points[i * 3] ?? 0) + ux * reach;
294
+ const cy = (scratch.points[i * 3 + 1] ?? 0) + uy * reach;
295
+ const cz = (scratch.points[i * 3 + 2] ?? 0) + uz * reach;
296
+ const height = cx * FACE[0] + cy * FACE[1] + cz * FACE[2] - plane;
297
+ if (height < 0) return false;
298
+ scratch.separations[i] = height - roundRadius;
299
+ }
300
+ scratch.nx = sign * FACE[0];
301
+ scratch.ny = sign * FACE[1];
302
+ scratch.nz = sign * FACE[2];
303
+ return true;
304
+ }
268
305
 
269
306
  scratch.nx = sign * FACE[0];
270
307
  scratch.ny = sign * FACE[1];
271
308
  scratch.nz = sign * FACE[2];
272
- worldPlaneAnchor(mesh, triangle, meshPose, ANCHOR);
273
- const plane = FACE[0] * ANCHOR[0] + FACE[1] * ANCHOR[1] + FACE[2] * ANCHOR[2];
274
309
  for (let i = 0; i < scratch.count; i++) {
275
310
  scratch.separations[i] =
276
311
  (scratch.points[i * 3] ?? 0) * FACE[0] +
@@ -278,6 +313,7 @@ function filterInteriorEdge(
278
313
  (scratch.points[i * 3 + 2] ?? 0) * FACE[2] -
279
314
  plane;
280
315
  }
316
+ return true;
281
317
  }
282
318
 
283
319
  const EDGE = new Float64Array(6);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A world's static scenery by region: one body a region, added when the region streams in and
3
+ * removed when it streams out, with each region's body index kept right as the world moves bodies.
4
+ *
5
+ * **One body a region, and a triangle mesh is what makes that cheap.** A city region holds hundreds
6
+ * of boxes and a few odd shapes; as bodies they would be tens of thousands for the city, every one
7
+ * an index to follow. As one static triangle mesh each — `meshShape`, which carries its own tree —
8
+ * the whole city is a few hundred bodies and a region comes and goes as one add and one remove.
9
+ *
10
+ * **Why the indices need following.** `PhysicsWorld.removeBody` fills the hole with its last body,
11
+ * so removing one region's body moves another's — possibly another region's, possibly anything
12
+ * else in the world. It returns the index that moved, and this uses it. **What it gives up** is any
13
+ * other caller's body index in the same world: a removal moves whatever was last, and only the
14
+ * caller that asked for it hears where it went. A world that also holds bodies somebody else tracks
15
+ * by index needs those removed through one place that follows them all.
16
+ */
17
+ import type { BodyDesc } from './bodies.ts';
18
+ import type { PhysicsWorld } from './world.ts';
19
+
20
+ export class StaticRegions {
21
+ private readonly bodyOfRegion = new Map<number, number>();
22
+ /** The region each body index belongs to, or −1. Grows with the world. */
23
+ private regionOfBody = new Int32Array(64).fill(-1);
24
+
25
+ constructor(private readonly world: PhysicsWorld) {}
26
+
27
+ /** How many regions are in the world. */
28
+ get size(): number {
29
+ return this.bodyOfRegion.size;
30
+ }
31
+
32
+ has(region: number): boolean {
33
+ return this.bodyOfRegion.has(region);
34
+ }
35
+
36
+ /** The body index a region stands as now, or −1. It changes as other regions are removed. */
37
+ bodyOf(region: number): number {
38
+ return this.bodyOfRegion.get(region) ?? -1;
39
+ }
40
+
41
+ /** Add a region as one body. A region already in the world is refused rather than doubled. */
42
+ add(region: number, desc: BodyDesc): number {
43
+ if (this.bodyOfRegion.has(region)) {
44
+ throw new Error(
45
+ `StaticRegions: region ${region} is already in the world — remove it before adding it ` +
46
+ 'again, or its scenery would collide twice',
47
+ );
48
+ }
49
+ const body = this.world.addBody(desc);
50
+ while (body >= this.regionOfBody.length) {
51
+ const wider = new Int32Array(this.regionOfBody.length * 2).fill(-1);
52
+ wider.set(this.regionOfBody);
53
+ this.regionOfBody = wider;
54
+ }
55
+ this.regionOfBody[body] = region;
56
+ this.bodyOfRegion.set(region, body);
57
+ return body;
58
+ }
59
+
60
+ /** Take a region out of the world. A region that is not there is nothing to remove. */
61
+ remove(region: number): void {
62
+ const body = this.bodyOfRegion.get(region);
63
+ if (body === undefined) return;
64
+ this.bodyOfRegion.delete(region);
65
+ const moved = this.world.removeBody(body);
66
+ if (moved < 0) {
67
+ this.regionOfBody[body] = -1;
68
+ return;
69
+ }
70
+ /* The body that was last now sits at `body`: whichever region owned it follows it there. */
71
+ const owner = this.regionOfBody[moved] ?? -1;
72
+ this.regionOfBody[body] = owner;
73
+ this.regionOfBody[moved] = -1;
74
+ if (owner >= 0) this.bodyOfRegion.set(owner, body);
75
+ }
76
+ }
package/src/world.ts CHANGED
@@ -433,7 +433,12 @@ export class PhysicsWorld implements IslandSolver {
433
433
  for (const key of kept) this.ignoredPairs.add(key);
434
434
  }
435
435
 
436
- removeBody(index: number): void {
436
+ /**
437
+ * Remove a body, filling its index with the last body — whose index this returns, or −1 when the
438
+ * removed body was the last. **Every index past the removed one can change**, so a caller keeping
439
+ * body indices follows the returned one; `StaticRegions` is the worked example.
440
+ */
441
+ removeBody(index: number): number {
437
442
  this.tree.remove(this.leafOf[index] ?? 0);
438
443
  const moved = this.bodies.remove(index);
439
444
  this.followRemoval(index, moved);
@@ -445,6 +450,7 @@ export class PhysicsWorld implements IslandSolver {
445
450
  this.warm.clear();
446
451
  // Every index past the removed one may have moved, so a remembered pair is a lie.
447
452
  this.events.clear();
453
+ return moved;
448
454
  }
449
455
 
450
456
  /** Advance one fixed tick. */