@driftengine/physics 4.8.0 → 4.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.
@@ -303,6 +303,8 @@ export declare class CharacterController {
303
303
  * would climb twice.
304
304
  */
305
305
  private tryStep;
306
+ /** Put the body back where a step began, and say the step was not taken. */
307
+ private stepBack;
306
308
  /**
307
309
  * Sweep the foot ball down, and fall back to a ray when what it finds is not walkable.
308
310
  *
@@ -36,6 +36,8 @@ const SLIDE_PASSES = 4;
36
36
  * own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
37
37
  */
38
38
  const SNAP_EPS = 0.001;
39
+ /** How far past a step's edge a step that landed on the edge carries the body's axis. */
40
+ const STEP_OVER = 0.01;
39
41
  export class CharacterController {
40
42
  x = 0;
41
43
  y = 0;
@@ -452,26 +454,69 @@ export class CharacterController {
452
454
  this.z += liftZ;
453
455
  this.readPose();
454
456
  if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
455
- this.x = startX;
456
- this.y = startY;
457
- this.z = startZ;
458
- return false;
457
+ return this.stepBack(startX, startY, startZ);
459
458
  }
460
459
  this.x += flatX;
461
460
  this.y += flatY;
462
461
  this.z += flatZ;
463
462
  this.readPose();
464
- if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
465
- const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
466
- this.x -= this.upX * drop;
467
- this.y -= this.upY * drop;
468
- this.z -= this.upZ * drop;
469
- return true;
463
+ if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
464
+ // Nothing beneath the lifted position, so this was a gap rather than a step.
465
+ return this.stepBack(startX, startY, startZ);
470
466
  }
471
- // Nothing beneath the lifted position, so this was a gap rather than a step.
472
- this.x = startX;
473
- this.y = startY;
474
- this.z = startZ;
467
+ let landing = this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
468
+ if (landing < this.slopeCos) {
469
+ /*
470
+ * **Landed on the lip, so go on over it.** The forward part of a step is what was left of
471
+ * the tick's move when the body met the step, and at a tick's length that can be millimetres:
472
+ * the foot ball then comes down on the step's edge, a contact far too steep to stand on, and
473
+ * the body was set there airborne, slid off, and never stepped again. Measured with a 0.32 m
474
+ * radius and a 0.35 m step height: a 0.25 m rise climbed and a 0.28 m one did not.
475
+ *
476
+ * The edge's normal points from the edge at the ball's centre, so its part across up is how
477
+ * far the axis stands short of the edge. Moving that far again, plus a centimetre, puts the
478
+ * ball over the step's top, where the drop meets a face. The body moves up to one radius
479
+ * further in that tick than it was going to, which is the price of a step taken in one tick.
480
+ */
481
+ const lipX = this.stepHit.nx - this.upX * landing;
482
+ const lipY = this.stepHit.ny - this.upY * landing;
483
+ const lipZ = this.stepHit.nz - this.upZ * landing;
484
+ const lip = Math.sqrt(lipX * lipX + lipY * lipY + lipZ * lipZ);
485
+ // An edge behind the move, or beside it, is not one this step was climbing.
486
+ if (lip < 1e-6 || lipX * flatX + lipY * flatY + lipZ * flatZ >= 0) {
487
+ return this.stepBack(startX, startY, startZ);
488
+ }
489
+ const reach = (this.radius * lip + STEP_OVER) / lip;
490
+ const overX = -lipX * reach;
491
+ const overY = -lipY * reach;
492
+ const overZ = -lipZ * reach;
493
+ this.readPose();
494
+ if (world.shapecast(this.shape, this.pose, overX, overY, overZ, this.stepHit, this.filter)) {
495
+ return this.stepBack(startX, startY, startZ);
496
+ }
497
+ this.x += overX;
498
+ this.y += overY;
499
+ this.z += overZ;
500
+ this.readPose();
501
+ if (!world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
502
+ return this.stepBack(startX, startY, startZ);
503
+ }
504
+ landing =
505
+ this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
506
+ if (landing < this.slopeCos)
507
+ return this.stepBack(startX, startY, startZ);
508
+ }
509
+ const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
510
+ this.x -= this.upX * drop;
511
+ this.y -= this.upY * drop;
512
+ this.z -= this.upZ * drop;
513
+ return true;
514
+ }
515
+ /** Put the body back where a step began, and say the step was not taken. */
516
+ stepBack(x, y, z) {
517
+ this.x = x;
518
+ this.y = y;
519
+ this.z = z;
475
520
  return false;
476
521
  }
477
522
  /**
package/dist/manifold.js CHANGED
@@ -113,6 +113,68 @@ function roundRound(a, b, margin, out) {
113
113
  return true;
114
114
  }
115
115
  /* ---------- sphere or capsule against a polytope ---------- */
116
+ /** Where along a capsule's segment the search settles: 0.618 to the 30th of 1.2 m is under a micron. */
117
+ const SEGMENT_STEPS = 30;
118
+ const INV_PHI = 0.6180339887498949;
119
+ /** Each end's direction away from the polytope, and its separation, read by the segment search. */
120
+ const END_DIR = new Float64Array(9);
121
+ const END_SEP = new Float64Array(2);
122
+ /** The manifold being filled by `roundPolytope`, kept here so adding a contact allocates nothing. */
123
+ const ROUND = { count: 0, bestSep: Infinity, nx: 0, ny: 1, nz: 0, margin: 0, flipped: false };
124
+ /** Add the contact `CLOSEST` and `END_DIR[slot]` describe, at `separation`, as `feature`. */
125
+ function emitRound(out, slot, separation, feature) {
126
+ if (separation > ROUND.margin || ROUND.count >= MAX_CONTACTS)
127
+ return;
128
+ // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
129
+ const ux = END_DIR[slot * 3] ?? 0;
130
+ const uy = END_DIR[slot * 3 + 1] ?? 0;
131
+ const uz = END_DIR[slot * 3 + 2] ?? 0;
132
+ if (separation < ROUND.bestSep) {
133
+ ROUND.bestSep = separation;
134
+ ROUND.nx = ROUND.flipped ? ux : -ux;
135
+ ROUND.ny = ROUND.flipped ? uy : -uy;
136
+ ROUND.nz = ROUND.flipped ? uz : -uz;
137
+ }
138
+ const at = ROUND.count * 3;
139
+ out.points[at] = CLOSEST[0];
140
+ out.points[at + 1] = CLOSEST[1];
141
+ out.points[at + 2] = CLOSEST[2];
142
+ out.separations[ROUND.count] = separation;
143
+ out.featureIds[ROUND.count] = feature;
144
+ ROUND.count++;
145
+ }
146
+ /**
147
+ * The signed distance from a point to the polytope, with the direction away from it left in
148
+ * `END_DIR[slot]`: positive outside, the deepest face's plane distance inside.
149
+ */
150
+ function signedDistance(poly, polyWorld, faces, px, py, pz, slot) {
151
+ closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
152
+ let ux = px - CLOSEST[0];
153
+ let uy = py - CLOSEST[1];
154
+ let uz = pz - CLOSEST[2];
155
+ let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
156
+ if (CLOSEST[6] < 0) {
157
+ // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
158
+ ux = CLOSEST[3];
159
+ uy = CLOSEST[4];
160
+ uz = CLOSEST[5];
161
+ d = CLOSEST[6];
162
+ }
163
+ else if (d === 0) {
164
+ ux = CLOSEST[3];
165
+ uy = CLOSEST[4];
166
+ uz = CLOSEST[5];
167
+ }
168
+ else {
169
+ ux /= d;
170
+ uy /= d;
171
+ uz /= d;
172
+ }
173
+ END_DIR[slot * 3] = ux;
174
+ END_DIR[slot * 3 + 1] = uy;
175
+ END_DIR[slot * 3 + 2] = uz;
176
+ return d;
177
+ }
116
178
  function roundPolytope(round, poly, polyPose, margin, out, flipped) {
117
179
  // `worldA` holds whichever shape was passed first, so read the round one from the right buffer.
118
180
  const roundWorld = flipped ? worldB : worldA;
@@ -120,67 +182,79 @@ function roundPolytope(round, poly, polyPose, margin, out, flipped) {
120
182
  planesToWorld(poly, polyPose, polyWorld, planesB);
121
183
  const points = round.vertices.length / 3;
122
184
  const faces = faceCount(poly);
123
- let count = 0;
124
- let bestSep = Infinity;
125
- let nx = 0;
126
- let ny = 1;
127
- let nz = 0;
128
- for (let p = 0; p < points; p++) {
129
- const px = roundWorld[p * 3] ?? 0;
130
- const py = roundWorld[p * 3 + 1] ?? 0;
131
- const pz = roundWorld[p * 3 + 2] ?? 0;
132
- closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
133
- let ux = px - CLOSEST[0];
134
- let uy = py - CLOSEST[1];
135
- let uz = pz - CLOSEST[2];
136
- let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
137
- if (CLOSEST[6] < 0) {
138
- // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
139
- ux = CLOSEST[3];
140
- uy = CLOSEST[4];
141
- uz = CLOSEST[5];
142
- d = CLOSEST[6];
143
- }
144
- else if (d === 0) {
145
- ux = CLOSEST[3];
146
- uy = CLOSEST[4];
147
- uz = CLOSEST[5];
148
- }
149
- else {
150
- ux /= d;
151
- uy /= d;
152
- uz /= d;
153
- }
154
- const separation = d - round.radius - poly.radius;
155
- if (separation > margin)
156
- continue;
157
- if (count >= MAX_CONTACTS)
158
- break;
159
- // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
160
- const sx = flipped ? ux : -ux;
161
- const sy = flipped ? uy : -uy;
162
- const sz = flipped ? uz : -uz;
163
- if (separation < bestSep) {
164
- bestSep = separation;
165
- nx = sx;
166
- ny = sy;
167
- nz = sz;
168
- }
169
- const at = count * 3;
170
- out.points[at] = CLOSEST[0];
171
- out.points[at + 1] = CLOSEST[1];
172
- out.points[at + 2] = CLOSEST[2];
173
- out.separations[count] = separation;
185
+ const radii = round.radius + poly.radius;
186
+ ROUND.count = 0;
187
+ ROUND.bestSep = Infinity;
188
+ ROUND.nx = 0;
189
+ ROUND.ny = 1;
190
+ ROUND.nz = 0;
191
+ ROUND.margin = margin;
192
+ ROUND.flipped = flipped;
193
+ for (let p = 0; p < points && p < 2; p++) {
194
+ const separation = signedDistance(poly, polyWorld, faces, roundWorld[p * 3] ?? 0, roundWorld[p * 3 + 1] ?? 0, roundWorld[p * 3 + 2] ?? 0, p) - radii;
195
+ END_SEP[p] = separation;
174
196
  // The endpoint that produced it, which is stable while the capsule keeps its orientation.
175
- out.featureIds[count] = 0x40000000 | p;
176
- count++;
197
+ emitRound(out, p, separation, 0x40000000 | p);
177
198
  }
178
- if (count === 0)
199
+ /*
200
+ * **A capsule is a segment, and its middle can be the part that touches.** This used to ask only
201
+ * its two ends, which is exact for a ball and for a capsule lying on a face, and wrong wherever the
202
+ * segment crosses an edge: a standing capsule walked through a slab across its waist, because
203
+ * neither end ball came within reach of it, and a capsule lying across a rail fell through it.
204
+ *
205
+ * The distance from a point on the segment to a convex polytope is convex along the segment, so
206
+ * its least value is at an end exactly when the slope there points away from the other end. Each
207
+ * end's direction away from the polytope is that slope, so a capsule whose ends are the answer, as
208
+ * one standing on a floor always is, costs nothing more. Otherwise a fixed number of golden-section
209
+ * steps finds the interior point, and it is kept only where it is nearer than both ends.
210
+ */
211
+ if (points === 2) {
212
+ const ax = roundWorld[0] ?? 0;
213
+ const ay = roundWorld[1] ?? 0;
214
+ const az = roundWorld[2] ?? 0;
215
+ const sx = (roundWorld[3] ?? 0) - ax;
216
+ const sy = (roundWorld[4] ?? 0) - ay;
217
+ const sz = (roundWorld[5] ?? 0) - az;
218
+ const leavesFirst = sx * (END_DIR[0] ?? 0) + sy * (END_DIR[1] ?? 0) + sz * (END_DIR[2] ?? 0);
219
+ const leavesSecond = -(sx * (END_DIR[3] ?? 0) +
220
+ sy * (END_DIR[4] ?? 0) +
221
+ sz * (END_DIR[5] ?? 0));
222
+ if (leavesFirst < 0 && leavesSecond < 0) {
223
+ let lo = 0;
224
+ let hi = 1;
225
+ let m1 = hi - (hi - lo) * INV_PHI;
226
+ let m2 = lo + (hi - lo) * INV_PHI;
227
+ let f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
228
+ let f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
229
+ for (let step = 0; step < SEGMENT_STEPS; step++) {
230
+ if (f1 <= f2) {
231
+ hi = m2;
232
+ m2 = m1;
233
+ f2 = f1;
234
+ m1 = hi - (hi - lo) * INV_PHI;
235
+ f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
236
+ }
237
+ else {
238
+ lo = m1;
239
+ m1 = m2;
240
+ f1 = f2;
241
+ m2 = lo + (hi - lo) * INV_PHI;
242
+ f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
243
+ }
244
+ }
245
+ const at = (lo + hi) / 2;
246
+ const separation = signedDistance(poly, polyWorld, faces, ax + sx * at, ay + sy * at, az + sz * at, 2) - radii;
247
+ if (separation < Math.min(END_SEP[0] ?? Infinity, END_SEP[1] ?? Infinity) - 1e-7) {
248
+ emitRound(out, 2, separation, 0x40000000 | 2);
249
+ }
250
+ }
251
+ }
252
+ if (ROUND.count === 0)
179
253
  return false;
180
- out.nx = nx;
181
- out.ny = ny;
182
- out.nz = nz;
183
- out.count = count;
254
+ out.nx = ROUND.nx;
255
+ out.ny = ROUND.ny;
256
+ out.nz = ROUND.nz;
257
+ out.count = ROUND.count;
184
258
  return true;
185
259
  }
186
260
  /* ---------- polytope against polytope ---------- */
package/dist/query.js CHANGED
@@ -697,7 +697,15 @@ export function shapecastWorld(bodies, tree, shape, pose, dx, dy, dz, out, filte
697
697
  }
698
698
  else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold))
699
699
  break;
700
- const gap = manifold.separations[0] ?? 0;
700
+ /*
701
+ * **The nearest contact, not the first one written.** A capsule's manifold holds a point per
702
+ * end, and the first is its lower end: reading only that advanced the sweep by the distance
703
+ * from the lower ball while the upper one was the nearer, so a standing capsule swept clean
704
+ * through a beam at head height.
705
+ */
706
+ let gap = Infinity;
707
+ for (let c = 0; c < manifold.count; c++)
708
+ gap = Math.min(gap, manifold.separations[c] ?? gap);
701
709
  if (gap < 1e-4) {
702
710
  /*
703
711
  * A shape already touching a surface it is travelling *along* has not run into it.
@@ -713,7 +721,25 @@ export function shapecastWorld(bodies, tree, shape, pose, dx, dy, dz, out, filte
713
721
  touched = true;
714
722
  break;
715
723
  }
716
- t += gap / travel;
724
+ /*
725
+ * **How far the shapes may close along the contact normal, not along the travel.** The
726
+ * manifold's normal separates the two by `gap`, so they cannot meet before the sweep has
727
+ * closed that much along it. Dividing by the whole travel was always safe and crawled
728
+ * wherever the travel grazes the surface: a ball dropping past the lip of a step closes on it
729
+ * at a sixth of its speed, and twenty-four steps ended before it arrived, so the sweep said
730
+ * nothing was there. A mesh keeps the old step, since one triangle's normal does not separate
731
+ * the whole mesh.
732
+ */
733
+ if (isMesh(shape) || isMesh(other)) {
734
+ t += gap / travel;
735
+ }
736
+ else {
737
+ const closing = dx * manifold.nx + dy * manifold.ny + dz * manifold.nz;
738
+ // Not closing along the normal that separates them, so they never meet on this travel.
739
+ if (closing <= 1e-12)
740
+ break;
741
+ t += gap / closing;
742
+ }
717
743
  if (t > 1)
718
744
  break;
719
745
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/physics",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
4
4
  "description": "Collision shapes, spatial queries and swept kinematic collision",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/controller.ts CHANGED
@@ -170,6 +170,8 @@ const SLIDE_PASSES = 4;
170
170
  * own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
171
171
  */
172
172
  const SNAP_EPS = 0.001;
173
+ /** How far past a step's edge a step that landed on the edge carries the body's axis. */
174
+ const STEP_OVER = 0.01;
173
175
 
174
176
  export class CharacterController {
175
177
  x = 0;
@@ -603,26 +605,74 @@ export class CharacterController {
603
605
  this.z += liftZ;
604
606
  this.readPose();
605
607
  if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
606
- this.x = startX;
607
- this.y = startY;
608
- this.z = startZ;
609
- return false;
608
+ return this.stepBack(startX, startY, startZ);
610
609
  }
611
610
  this.x += flatX;
612
611
  this.y += flatY;
613
612
  this.z += flatZ;
614
613
  this.readPose();
615
- if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
616
- const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
617
- this.x -= this.upX * drop;
618
- this.y -= this.upY * drop;
619
- this.z -= this.upZ * drop;
620
- return true;
614
+ if (
615
+ !world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)
616
+ ) {
617
+ // Nothing beneath the lifted position, so this was a gap rather than a step.
618
+ return this.stepBack(startX, startY, startZ);
619
+ }
620
+ let landing =
621
+ this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
622
+ if (landing < this.slopeCos) {
623
+ /*
624
+ * **Landed on the lip, so go on over it.** The forward part of a step is what was left of
625
+ * the tick's move when the body met the step, and at a tick's length that can be millimetres:
626
+ * the foot ball then comes down on the step's edge, a contact far too steep to stand on, and
627
+ * the body was set there airborne, slid off, and never stepped again. Measured with a 0.32 m
628
+ * radius and a 0.35 m step height: a 0.25 m rise climbed and a 0.28 m one did not.
629
+ *
630
+ * The edge's normal points from the edge at the ball's centre, so its part across up is how
631
+ * far the axis stands short of the edge. Moving that far again, plus a centimetre, puts the
632
+ * ball over the step's top, where the drop meets a face. The body moves up to one radius
633
+ * further in that tick than it was going to, which is the price of a step taken in one tick.
634
+ */
635
+ const lipX = this.stepHit.nx - this.upX * landing;
636
+ const lipY = this.stepHit.ny - this.upY * landing;
637
+ const lipZ = this.stepHit.nz - this.upZ * landing;
638
+ const lip = Math.sqrt(lipX * lipX + lipY * lipY + lipZ * lipZ);
639
+ // An edge behind the move, or beside it, is not one this step was climbing.
640
+ if (lip < 1e-6 || lipX * flatX + lipY * flatY + lipZ * flatZ >= 0) {
641
+ return this.stepBack(startX, startY, startZ);
642
+ }
643
+ const reach = (this.radius * lip + STEP_OVER) / lip;
644
+ const overX = -lipX * reach;
645
+ const overY = -lipY * reach;
646
+ const overZ = -lipZ * reach;
647
+ this.readPose();
648
+ if (world.shapecast(this.shape, this.pose, overX, overY, overZ, this.stepHit, this.filter)) {
649
+ return this.stepBack(startX, startY, startZ);
650
+ }
651
+ this.x += overX;
652
+ this.y += overY;
653
+ this.z += overZ;
654
+ this.readPose();
655
+ if (
656
+ !world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)
657
+ ) {
658
+ return this.stepBack(startX, startY, startZ);
659
+ }
660
+ landing =
661
+ this.stepHit.nx * this.upX + this.stepHit.ny * this.upY + this.stepHit.nz * this.upZ;
662
+ if (landing < this.slopeCos) return this.stepBack(startX, startY, startZ);
621
663
  }
622
- // Nothing beneath the lifted position, so this was a gap rather than a step.
623
- this.x = startX;
624
- this.y = startY;
625
- this.z = startZ;
664
+ const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
665
+ this.x -= this.upX * drop;
666
+ this.y -= this.upY * drop;
667
+ this.z -= this.upZ * drop;
668
+ return true;
669
+ }
670
+
671
+ /** Put the body back where a step began, and say the step was not taken. */
672
+ private stepBack(x: number, y: number, z: number): false {
673
+ this.x = x;
674
+ this.y = y;
675
+ this.z = z;
626
676
  return false;
627
677
  }
628
678
 
package/src/manifold.ts CHANGED
@@ -207,6 +207,76 @@ function roundRound(a: ConvexShape, b: ConvexShape, margin: number, out: Manifol
207
207
 
208
208
  /* ---------- sphere or capsule against a polytope ---------- */
209
209
 
210
+ /** Where along a capsule's segment the search settles: 0.618 to the 30th of 1.2 m is under a micron. */
211
+ const SEGMENT_STEPS = 30;
212
+ const INV_PHI = 0.6180339887498949;
213
+ /** Each end's direction away from the polytope, and its separation, read by the segment search. */
214
+ const END_DIR = new Float64Array(9);
215
+ const END_SEP = new Float64Array(2);
216
+ /** The manifold being filled by `roundPolytope`, kept here so adding a contact allocates nothing. */
217
+ const ROUND = { count: 0, bestSep: Infinity, nx: 0, ny: 1, nz: 0, margin: 0, flipped: false };
218
+
219
+ /** Add the contact `CLOSEST` and `END_DIR[slot]` describe, at `separation`, as `feature`. */
220
+ function emitRound(out: Manifold, slot: number, separation: number, feature: number): void {
221
+ if (separation > ROUND.margin || ROUND.count >= MAX_CONTACTS) return;
222
+ // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
223
+ const ux = END_DIR[slot * 3] ?? 0;
224
+ const uy = END_DIR[slot * 3 + 1] ?? 0;
225
+ const uz = END_DIR[slot * 3 + 2] ?? 0;
226
+ if (separation < ROUND.bestSep) {
227
+ ROUND.bestSep = separation;
228
+ ROUND.nx = ROUND.flipped ? ux : -ux;
229
+ ROUND.ny = ROUND.flipped ? uy : -uy;
230
+ ROUND.nz = ROUND.flipped ? uz : -uz;
231
+ }
232
+ const at = ROUND.count * 3;
233
+ out.points[at] = CLOSEST[0];
234
+ out.points[at + 1] = CLOSEST[1];
235
+ out.points[at + 2] = CLOSEST[2];
236
+ out.separations[ROUND.count] = separation;
237
+ out.featureIds[ROUND.count] = feature;
238
+ ROUND.count++;
239
+ }
240
+
241
+ /**
242
+ * The signed distance from a point to the polytope, with the direction away from it left in
243
+ * `END_DIR[slot]`: positive outside, the deepest face's plane distance inside.
244
+ */
245
+ function signedDistance(
246
+ poly: ConvexShape,
247
+ polyWorld: Float32Array,
248
+ faces: number,
249
+ px: number,
250
+ py: number,
251
+ pz: number,
252
+ slot: number,
253
+ ): number {
254
+ closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
255
+ let ux = px - CLOSEST[0];
256
+ let uy = py - CLOSEST[1];
257
+ let uz = pz - CLOSEST[2];
258
+ let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
259
+ if (CLOSEST[6] < 0) {
260
+ // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
261
+ ux = CLOSEST[3];
262
+ uy = CLOSEST[4];
263
+ uz = CLOSEST[5];
264
+ d = CLOSEST[6];
265
+ } else if (d === 0) {
266
+ ux = CLOSEST[3];
267
+ uy = CLOSEST[4];
268
+ uz = CLOSEST[5];
269
+ } else {
270
+ ux /= d;
271
+ uy /= d;
272
+ uz /= d;
273
+ }
274
+ END_DIR[slot * 3] = ux;
275
+ END_DIR[slot * 3 + 1] = uy;
276
+ END_DIR[slot * 3 + 2] = uz;
277
+ return d;
278
+ }
279
+
210
280
  function roundPolytope(
211
281
  round: ConvexShape,
212
282
  poly: ConvexShape,
@@ -222,63 +292,92 @@ function roundPolytope(
222
292
 
223
293
  const points = round.vertices.length / 3;
224
294
  const faces = faceCount(poly);
225
- let count = 0;
226
- let bestSep = Infinity;
227
- let nx = 0;
228
- let ny = 1;
229
- let nz = 0;
230
-
231
- for (let p = 0; p < points; p++) {
232
- const px = roundWorld[p * 3] ?? 0;
233
- const py = roundWorld[p * 3 + 1] ?? 0;
234
- const pz = roundWorld[p * 3 + 2] ?? 0;
235
- closestOnPolytope(poly, polyWorld, planesB, faces, px, py, pz);
236
- let ux = px - CLOSEST[0];
237
- let uy = py - CLOSEST[1];
238
- let uz = pz - CLOSEST[2];
239
- let d = Math.sqrt(ux * ux + uy * uy + uz * uz);
240
- if (CLOSEST[6] < 0) {
241
- // Inside: the closest face's outward normal is the direction, and depth is its plane distance.
242
- ux = CLOSEST[3];
243
- uy = CLOSEST[4];
244
- uz = CLOSEST[5];
245
- d = CLOSEST[6];
246
- } else if (d === 0) {
247
- ux = CLOSEST[3];
248
- uy = CLOSEST[4];
249
- uz = CLOSEST[5];
250
- } else {
251
- ux /= d;
252
- uy /= d;
253
- uz /= d;
254
- }
255
- const separation = d - round.radius - poly.radius;
256
- if (separation > margin) continue;
257
- if (count >= MAX_CONTACTS) break;
258
- // Normal points from the round shape toward the polytope, then flipped if A was the polytope.
259
- const sx = flipped ? ux : -ux;
260
- const sy = flipped ? uy : -uy;
261
- const sz = flipped ? uz : -uz;
262
- if (separation < bestSep) {
263
- bestSep = separation;
264
- nx = sx;
265
- ny = sy;
266
- nz = sz;
267
- }
268
- const at = count * 3;
269
- out.points[at] = CLOSEST[0];
270
- out.points[at + 1] = CLOSEST[1];
271
- out.points[at + 2] = CLOSEST[2];
272
- out.separations[count] = separation;
295
+ const radii = round.radius + poly.radius;
296
+ ROUND.count = 0;
297
+ ROUND.bestSep = Infinity;
298
+ ROUND.nx = 0;
299
+ ROUND.ny = 1;
300
+ ROUND.nz = 0;
301
+ ROUND.margin = margin;
302
+ ROUND.flipped = flipped;
303
+
304
+ for (let p = 0; p < points && p < 2; p++) {
305
+ const separation =
306
+ signedDistance(
307
+ poly,
308
+ polyWorld,
309
+ faces,
310
+ roundWorld[p * 3] ?? 0,
311
+ roundWorld[p * 3 + 1] ?? 0,
312
+ roundWorld[p * 3 + 2] ?? 0,
313
+ p,
314
+ ) - radii;
315
+ END_SEP[p] = separation;
273
316
  // The endpoint that produced it, which is stable while the capsule keeps its orientation.
274
- out.featureIds[count] = 0x40000000 | p;
275
- count++;
317
+ emitRound(out, p, separation, 0x40000000 | p);
276
318
  }
277
- if (count === 0) return false;
278
- out.nx = nx;
279
- out.ny = ny;
280
- out.nz = nz;
281
- out.count = count;
319
+
320
+ /*
321
+ * **A capsule is a segment, and its middle can be the part that touches.** This used to ask only
322
+ * its two ends, which is exact for a ball and for a capsule lying on a face, and wrong wherever the
323
+ * segment crosses an edge: a standing capsule walked through a slab across its waist, because
324
+ * neither end ball came within reach of it, and a capsule lying across a rail fell through it.
325
+ *
326
+ * The distance from a point on the segment to a convex polytope is convex along the segment, so
327
+ * its least value is at an end exactly when the slope there points away from the other end. Each
328
+ * end's direction away from the polytope is that slope, so a capsule whose ends are the answer, as
329
+ * one standing on a floor always is, costs nothing more. Otherwise a fixed number of golden-section
330
+ * steps finds the interior point, and it is kept only where it is nearer than both ends.
331
+ */
332
+ if (points === 2) {
333
+ const ax = roundWorld[0] ?? 0;
334
+ const ay = roundWorld[1] ?? 0;
335
+ const az = roundWorld[2] ?? 0;
336
+ const sx = (roundWorld[3] ?? 0) - ax;
337
+ const sy = (roundWorld[4] ?? 0) - ay;
338
+ const sz = (roundWorld[5] ?? 0) - az;
339
+ const leavesFirst = sx * (END_DIR[0] ?? 0) + sy * (END_DIR[1] ?? 0) + sz * (END_DIR[2] ?? 0);
340
+ const leavesSecond = -(
341
+ sx * (END_DIR[3] ?? 0) +
342
+ sy * (END_DIR[4] ?? 0) +
343
+ sz * (END_DIR[5] ?? 0)
344
+ );
345
+ if (leavesFirst < 0 && leavesSecond < 0) {
346
+ let lo = 0;
347
+ let hi = 1;
348
+ let m1 = hi - (hi - lo) * INV_PHI;
349
+ let m2 = lo + (hi - lo) * INV_PHI;
350
+ let f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
351
+ let f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
352
+ for (let step = 0; step < SEGMENT_STEPS; step++) {
353
+ if (f1 <= f2) {
354
+ hi = m2;
355
+ m2 = m1;
356
+ f2 = f1;
357
+ m1 = hi - (hi - lo) * INV_PHI;
358
+ f1 = signedDistance(poly, polyWorld, faces, ax + sx * m1, ay + sy * m1, az + sz * m1, 2);
359
+ } else {
360
+ lo = m1;
361
+ m1 = m2;
362
+ f1 = f2;
363
+ m2 = lo + (hi - lo) * INV_PHI;
364
+ f2 = signedDistance(poly, polyWorld, faces, ax + sx * m2, ay + sy * m2, az + sz * m2, 2);
365
+ }
366
+ }
367
+ const at = (lo + hi) / 2;
368
+ const separation =
369
+ signedDistance(poly, polyWorld, faces, ax + sx * at, ay + sy * at, az + sz * at, 2) - radii;
370
+ if (separation < Math.min(END_SEP[0] ?? Infinity, END_SEP[1] ?? Infinity) - 1e-7) {
371
+ emitRound(out, 2, separation, 0x40000000 | 2);
372
+ }
373
+ }
374
+ }
375
+
376
+ if (ROUND.count === 0) return false;
377
+ out.nx = ROUND.nx;
378
+ out.ny = ROUND.ny;
379
+ out.nz = ROUND.nz;
380
+ out.count = ROUND.count;
282
381
  return true;
283
382
  }
284
383
 
package/src/query.ts CHANGED
@@ -858,7 +858,14 @@ export function shapecastWorld(
858
858
  if (chosen === undefined) break;
859
859
  copyManifold(chosen, manifold);
860
860
  } else if (!collideShapes(shape, poseA, other, poseB, 1e9, manifold)) break;
861
- const gap = manifold.separations[0] ?? 0;
861
+ /*
862
+ * **The nearest contact, not the first one written.** A capsule's manifold holds a point per
863
+ * end, and the first is its lower end: reading only that advanced the sweep by the distance
864
+ * from the lower ball while the upper one was the nearer, so a standing capsule swept clean
865
+ * through a beam at head height.
866
+ */
867
+ let gap = Infinity;
868
+ for (let c = 0; c < manifold.count; c++) gap = Math.min(gap, manifold.separations[c] ?? gap);
862
869
  if (gap < 1e-4) {
863
870
  /*
864
871
  * A shape already touching a surface it is travelling *along* has not run into it.
@@ -874,7 +881,23 @@ export function shapecastWorld(
874
881
  touched = true;
875
882
  break;
876
883
  }
877
- t += gap / travel;
884
+ /*
885
+ * **How far the shapes may close along the contact normal, not along the travel.** The
886
+ * manifold's normal separates the two by `gap`, so they cannot meet before the sweep has
887
+ * closed that much along it. Dividing by the whole travel was always safe and crawled
888
+ * wherever the travel grazes the surface: a ball dropping past the lip of a step closes on it
889
+ * at a sixth of its speed, and twenty-four steps ended before it arrived, so the sweep said
890
+ * nothing was there. A mesh keeps the old step, since one triangle's normal does not separate
891
+ * the whole mesh.
892
+ */
893
+ if (isMesh(shape) || isMesh(other)) {
894
+ t += gap / travel;
895
+ } else {
896
+ const closing = dx * manifold.nx + dy * manifold.ny + dz * manifold.nz;
897
+ // Not closing along the normal that separates them, so they never meet on this travel.
898
+ if (closing <= 1e-12) break;
899
+ t += gap / closing;
900
+ }
878
901
  if (t > 1) break;
879
902
  }
880
903
  if (brushing || !touched || t > 1) continue;