@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 +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/manifold.js +41 -0
- package/dist/meshContact.js +40 -6
- package/dist/staticRegions.d.ts +34 -0
- package/dist/staticRegions.js +54 -0
- package/dist/world.d.ts +6 -1
- package/dist/world.js +6 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/manifold.ts +65 -0
- package/src/meshContact.ts +41 -5
- package/src/staticRegions.ts +76 -0
- package/src/world.ts +7 -1
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.
|
|
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;
|
package/dist/meshContact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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,
|
package/src/meshContact.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|