Skip to content

packages/core/src/features/physics/lib/2d/collision/casts.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import type {
2   PibblPhysicsDistance2D,
3   PibblPhysicsPose2D,
4   PibblPhysicsRay2D,
5   PibblPhysicsShape2D,
6   PibblPhysicsShapeHit2D,
7   PibblPhysicsVector2,
8 } from '../../../2d-geometry.js';
9 import {
10   copyFiniteVector2,
11   requireNonnegativeNumber,
12 } from '../../shared/validation.js';
13 import {
14   IDENTITY_POSE_2D,
15   composePose2D,
16   copyPose2D,
17   transformPointInto,
18   type InternalPose2D,
19 } from '../math.js';
20 import { GEOMETRY_EPSILON } from '../numeric-policy.js';
21 import {
22   createShape2D,
23   requireShapeRecord2D,
24   type CircleLeaf2D,
25   type ConvexLeaf2D,
26   type ShapeRecord2D,
27 } from '../shape-records.js';
28 import { distancePointToSegment2D } from './analytic.js';
29 import {
30   containsPointInShape2D,
31   distanceBetweenPreparedShapes2D,
32 } from './pairs.js';
33 
34 const CAST_MAX_ITERATIONS = 32;
35 const CAST_REFINEMENT_ITERATIONS = 64;
36 const CAST_HIT_TOLERANCE = 1e-7;
37 const CAST_TIME_TOLERANCE = 1e-12;
38 
39 interface CastCandidate2D {
40   readonly leafKey: readonly [number, number];
41   readonly pointX: number;
42   readonly pointY: number;
43   readonly normalX: number;
44   readonly normalY: number;
45   readonly distance: number;
46   readonly timeOfImpact: number;
47 }
48 
49 interface PreparedCastShape2D {
50   readonly record: ShapeRecord2D;
51   readonly pose: InternalPose2D;
52 }
53 
54 /**
55  * Casts a ray against one posed shape and returns its first hit or null.
56  *
57  * @param shape - Shape to intersect. See {@link PibblPhysicsShape2D}.
58  * @param publicPose - Target shape pose. See {@link PibblPhysicsPose2D}.
59  * @param ray - Ray origin, direction, and travel limit. See {@link PibblPhysicsRay2D}.
60  * @param options - Whether initial overlap counts as a hit.
61  * @returns The first shape hit, or null when there is no hit. See {@link PibblPhysicsShapeHit2D}.
62  *
63  * @see {@link PibblPhysicsShape2D}
64  * @see {@link PibblPhysicsPose2D}
65  * @see {@link PibblPhysicsRay2D}
66  * @see {@link PibblPhysicsShapeHit2D}
67  */
68 export function raycastShape2D(
69   shape: PibblPhysicsShape2D,
70   publicPose: PibblPhysicsPose2D,
71   ray: PibblPhysicsRay2D,
72   options?: Readonly<{ includeInitialOverlap?: boolean }>,
73 ): PibblPhysicsShapeHit2D | null {
74   const prepared = prepareShape(shape, publicPose, 'pose');
75   const origin = copyFiniteVector2(ray.origin, 'ray.origin');
76   const sourceDirection = copyFiniteVector2(ray.direction, 'ray.direction');
77   const directionScale = Math.max(
78     Math.abs(sourceDirection[0]),
79     Math.abs(sourceDirection[1]),
80   );
81   if (directionScale === 0) {
82     throw new RangeError('ray.direction must be non-zero.');
83   }
84   const maxDistance = requireNonnegativeNumber(ray.maxDistance, 'ray.maxDistance');
85   const scaledDirectionX = sourceDirection[0] / directionScale;
86   const scaledDirectionY = sourceDirection[1] / directionScale;
87   const scaledDirectionLength = Math.hypot(scaledDirectionX, scaledDirectionY);
88   const directionX = scaledDirectionX / scaledDirectionLength;
89   const directionY = scaledDirectionY / scaledDirectionLength;
90   let selected: CastCandidate2D | null = null;
91 
92   for (const leaf of prepared.record.leaves) {
93     const leafShape = shapeForLeaf(leaf);
94     const initiallyOverlapping = containsPointInShape2D(
95       leafShape,
96       internalPoseAsPublic(prepared.pose),
97       origin,
98     );
99     let candidate: CastCandidate2D | null;
100     if (initiallyOverlapping) {
101       candidate = options?.includeInitialOverlap === true
102         ? {
103             leafKey: [0, leaf.canonicalLeafIndex],
104             pointX: origin[0],
105             pointY: origin[1],
106             normalX: -directionX,
107             normalY: -directionY,
108             distance: 0,
109             timeOfImpact: 0,
110           }
111         : null;
112     } else if (maxDistance === 0) {
113       candidate = null;
114     } else {
115       candidate = raycastLeaf(
116         leaf,
117         leafShape,
118         prepared.pose,
119         origin[0],
120         origin[1],
121         directionX,
122         directionY,
123         maxDistance,
124       );
125     }
126     if (candidate !== null && isEarlierCandidate(candidate, selected)) {
127       selected = candidate;
128     }
129   }
130 
131   return selected === null ? null : publicHit(selected);
132 }
133 
134 /**
135  * Sweeps a posed shape along a translation against another posed shape and returns a hit or null.
136  *
137  * @param moving - Shape to sweep. See {@link PibblPhysicsShape2D}.
138  * @param publicFrom - Initial pose of the moving shape. See {@link PibblPhysicsPose2D}.
139  * @param publicTranslation - Translation vector for the sweep. See {@link PibblPhysicsVector2}.
140  * @param target - Stationary shape to test against. See {@link PibblPhysicsShape2D}.
141  * @param publicTargetPose - Pose of the stationary shape. See {@link PibblPhysicsPose2D}.
142  * @param options - Whether initial overlap counts as a hit.
143  * @returns The first sweep hit, or null when there is no hit. See {@link PibblPhysicsShapeHit2D}.
144  *
145  * @see {@link PibblPhysicsShape2D}
146  * @see {@link PibblPhysicsPose2D}
147  * @see {@link PibblPhysicsVector2}
148  * @see {@link PibblPhysicsShapeHit2D}
149  */
150 export function shapeCastAgainstShape2D(
151   moving: PibblPhysicsShape2D,
152   publicFrom: PibblPhysicsPose2D,
153   publicTranslation: PibblPhysicsVector2,
154   target: PibblPhysicsShape2D,
155   publicTargetPose: PibblPhysicsPose2D,
156   options?: Readonly<{ includeInitialOverlap?: boolean }>,
157 ): PibblPhysicsShapeHit2D | null {
158   const preparedMoving = prepareShape(moving, publicFrom, 'from');
159   const translation = copyFiniteVector2(publicTranslation, 'translation');
160   const preparedTarget = prepareShape(target, publicTargetPose, 'targetPose');
161   const translationLength = Math.hypot(translation[0], translation[1]);
162   if (!Number.isFinite(translationLength)) {
163     throw new RangeError('translation magnitude must be finite.');
164   }
165   let selected: CastCandidate2D | null = null;
166 
167   for (const movingLeaf of preparedMoving.record.leaves) {
168     const movingLeafShape = shapeForLeaf(movingLeaf);
169     for (const targetLeaf of preparedTarget.record.leaves) {
170       const targetLeafShape = shapeForLeaf(targetLeaf);
171       const leafKey = pairLeafKey(movingLeaf, targetLeaf);
172       const candidate = movingLeaf.kind === 'circle' && targetLeaf.kind === 'circle'
173         ? castCircleAgainstCircle(
174             movingLeaf,
175             preparedMoving.pose,
176             translation[0],
177             translation[1],
178             translationLength,
179             targetLeaf,
180             preparedTarget.pose,
181             leafKey,
182             options?.includeInitialOverlap === true,
183           )
184         : conservativelyCastLeafPair(
185             movingLeafShape,
186             preparedMoving.pose,
187             translation[0],
188             translation[1],
189             translationLength,
190             targetLeafShape,
191             preparedTarget.pose,
192             leafKey,
193             options?.includeInitialOverlap === true,
194           );
195       if (candidate !== null && isEarlierCandidate(candidate, selected)) {
196         selected = candidate;
197       }
198     }
199   }
200 
201   return selected === null ? null : publicHit(selected);
202 }
203 
204 function raycastLeaf(
205   leaf: ConvexLeaf2D,
206   leafShape: PibblPhysicsShape2D,
207   pose: InternalPose2D,
208   originX: number,
209   originY: number,
210   directionX: number,
211   directionY: number,
212   maxDistance: number,
213 ): CastCandidate2D | null {
214   switch (leaf.kind) {
215     case 'circle':
216       return raycastCircle(
217         leaf,
218         pose,
219         originX,
220         originY,
221         directionX,
222         directionY,
223         maxDistance,
224       );
225     case 'capsule':
226       return raycastCapsule(
227         leaf,
228         pose,
229         originX,
230         originY,
231         directionX,
232         directionY,
233         maxDistance,
234       );
235     case 'segment':
236       return raycastSegment(
237         leaf,
238         pose,
239         originX,
240         originY,
241         directionX,
242         directionY,
243         maxDistance,
244       );
245     default: {
246       const pointShape = shapeForPoint();
247       return conservativelyCastLeafPair(
248         pointShape,
249         { x: originX, y: originY, rotationRadians: 0 },
250         directionX * maxDistance,
251         directionY * maxDistance,
252         maxDistance,
253         leafShape,
254         pose,
255         [0, leaf.canonicalLeafIndex],
256         false,
257       );
258     }
259   }
260 }
261 
262 function raycastCircle(
263   leaf: CircleLeaf2D,
264   pose: InternalPose2D,
265   originX: number,
266   originY: number,
267   directionX: number,
268   directionY: number,
269   maxDistance: number,
270 ): CastCandidate2D | null {
271   const center = new Float64Array(2);
272   transformPointInto(center, 0, leaf.center[0]!, leaf.center[1]!, pose);
273   const hit = rayCircleDistance(
274     originX,
275     originY,
276     directionX,
277     directionY,
278     center[0]!,
279     center[1]!,
280     leaf.radius,
281     maxDistance,
282   );
283   if (hit === null) return null;
284   const pointX = originX + directionX * hit;
285   const pointY = originY + directionY * hit;
286   const normal = unitAgainstMotion(
287     pointX - center[0]!,
288     pointY - center[1]!,
289     directionX,
290     directionY,
291   );
292   return {
293     leafKey: [0, leaf.canonicalLeafIndex],
294     pointX,
295     pointY,
296     normalX: normal[0],
297     normalY: normal[1],
298     distance: hit,
299     timeOfImpact: hit / maxDistance,
300   };
301 }
302 
303 function raycastCapsule(
304   leaf: Extract<ConvexLeaf2D, { kind: 'capsule' }>,
305   pose: InternalPose2D,
306   originX: number,
307   originY: number,
308   directionX: number,
309   directionY: number,
310   maxDistance: number,
311 ): CastCandidate2D | null {
312   const endpoints = new Float64Array(4);
313   transformPointInto(endpoints, 0, leaf.start[0]!, leaf.start[1]!, pose);
314   transformPointInto(endpoints, 2, leaf.end[0]!, leaf.end[1]!, pose);
315   const startX = endpoints[0]!;
316   const startY = endpoints[1]!;
317   const endX = endpoints[2]!;
318   const endY = endpoints[3]!;
319   const segmentX = endX - startX;
320   const segmentY = endY - startY;
321   const segmentLength = Math.hypot(segmentX, segmentY);
322   if (segmentLength <= GEOMETRY_EPSILON) {
323     return raycastCircle(
324       { ...leaf, kind: 'circle', center: new Float64Array([leaf.start[0]!, leaf.start[1]!]) },
325       pose,
326       originX,
327       originY,
328       directionX,
329       directionY,
330       maxDistance,
331     );
332   }
333 
334   const tangentX = segmentX / segmentLength;
335   const tangentY = segmentY / segmentLength;
336   const sideNormalX = -tangentY;
337   const sideNormalY = tangentX;
338   const relativeX = originX - startX;
339   const relativeY = originY - startY;
340   const alongOrigin = relativeX * tangentX + relativeY * tangentY;
341   const acrossOrigin = relativeX * sideNormalX + relativeY * sideNormalY;
342   const alongDirection = directionX * tangentX + directionY * tangentY;
343   const acrossDirection = directionX * sideNormalX + directionY * sideNormalY;
344   const distances: number[] = [];
345   if (Math.abs(acrossDirection) > GEOMETRY_EPSILON) {
346     for (const side of [-1, 1] as const) {
347       const distance = (side * leaf.radius - acrossOrigin) / acrossDirection;
348       const along = alongOrigin + distance * alongDirection;
349       if (
350         distance >= -CAST_TIME_TOLERANCE &&
351         distance <= maxDistance + CAST_TIME_TOLERANCE &&
352         along >= -CAST_HIT_TOLERANCE &&
353         along <= segmentLength + CAST_HIT_TOLERANCE
354       ) {
355         distances.push(Math.max(0, distance));
356       }
357     }
358   }
359   for (const [centerX, centerY] of [[startX, startY], [endX, endY]] as const) {
360     const distance = rayCircleDistance(
361       originX,
362       originY,
363       directionX,
364       directionY,
365       centerX,
366       centerY,
367       leaf.radius,
368       maxDistance,
369     );
370     if (distance !== null) distances.push(distance);
371   }
372   distances.sort((first, second) => first - second);
373   for (const distance of distances) {
374     const pointX = originX + directionX * distance;
375     const pointY = originY + directionY * distance;
376     const closest = distancePointToSegment2D(
377       pointX,
378       pointY,
379       startX,
380       startY,
381       endX,
382       endY,
383     );
384     if (Math.abs(closest.separation - leaf.radius) > CAST_HIT_TOLERANCE * 8) continue;
385     const normal = unitAgainstMotion(
386       pointX - closest.secondX,
387       pointY - closest.secondY,
388       directionX,
389       directionY,
390     );
391     if (normal[0] * directionX + normal[1] * directionY > CAST_HIT_TOLERANCE) continue;
392     return {
393       leafKey: [0, leaf.canonicalLeafIndex],
394       pointX,
395       pointY,
396       normalX: normal[0],
397       normalY: normal[1],
398       distance,
399       timeOfImpact: distance / maxDistance,
400     };
401   }
402   return null;
403 }
404 
405 function raycastSegment(
406   leaf: Extract<ConvexLeaf2D, { kind: 'segment' }>,
407   pose: InternalPose2D,
408   originX: number,
409   originY: number,
410   directionX: number,
411   directionY: number,
412   maxDistance: number,
413 ): CastCandidate2D | null {
414   const endpoints = new Float64Array(4);
415   transformPointInto(endpoints, 0, leaf.start[0]!, leaf.start[1]!, pose);
416   transformPointInto(endpoints, 2, leaf.end[0]!, leaf.end[1]!, pose);
417   const startX = endpoints[0]!;
418   const startY = endpoints[1]!;
419   const segmentX = endpoints[2]! - startX;
420   const segmentY = endpoints[3]! - startY;
421   const relativeX = startX - originX;
422   const relativeY = startY - originY;
423   const denominator = cross(directionX, directionY, segmentX, segmentY);
424   let distance: number;
425   if (Math.abs(denominator) > GEOMETRY_EPSILON) {
426     distance = cross(relativeX, relativeY, segmentX, segmentY) / denominator;
427     const segmentTime = cross(relativeX, relativeY, directionX, directionY) / denominator;
428     if (
429       distance < -CAST_TIME_TOLERANCE ||
430       distance > maxDistance + CAST_TIME_TOLERANCE ||
431       segmentTime < -CAST_TIME_TOLERANCE ||
432       segmentTime > 1 + CAST_TIME_TOLERANCE
433     ) {
434       return null;
435     }
436   } else {
437     if (Math.abs(cross(relativeX, relativeY, directionX, directionY)) > GEOMETRY_EPSILON) {
438       return null;
439     }
440     const first = relativeX * directionX + relativeY * directionY;
441     const second = first + segmentX * directionX + segmentY * directionY;
442     distance = Math.min(first, second);
443     if (distance < -CAST_TIME_TOLERANCE) distance = Math.max(first, second);
444     if (distance < -CAST_TIME_TOLERANCE || distance > maxDistance + CAST_TIME_TOLERANCE) {
445       return null;
446     }
447   }
448   distance = Math.max(0, Math.min(maxDistance, distance));
449   const pointX = originX + directionX * distance;
450   const pointY = originY + directionY * distance;
451   const normal = segmentNormalAgainstMotion(segmentX, segmentY, directionX, directionY);
452   return {
453     leafKey: [0, leaf.canonicalLeafIndex],
454     pointX,
455     pointY,
456     normalX: normal[0],
457     normalY: normal[1],
458     distance,
459     timeOfImpact: distance / maxDistance,
460   };
461 }
462 
463 function castCircleAgainstCircle(
464   moving: CircleLeaf2D,
465   movingPose: InternalPose2D,
466   translationX: number,
467   translationY: number,
468   translationLength: number,
469   target: CircleLeaf2D,
470   targetPose: InternalPose2D,
471   leafKey: readonly [number, number],
472   includeInitialOverlap: boolean,
473 ): CastCandidate2D | null {
474   const centers = new Float64Array(4);
475   transformPointInto(centers, 0, moving.center[0]!, moving.center[1]!, movingPose);
476   transformPointInto(centers, 2, target.center[0]!, target.center[1]!, targetPose);
477   const relativeX = centers[0]! - centers[2]!;
478   const relativeY = centers[1]! - centers[3]!;
479   const radius = moving.radius + target.radius;
480   if (relativeX * relativeX + relativeY * relativeY <= radius * radius) {
481     if (!includeInitialOverlap) return null;
482     const normal = unitAgainstMotion(relativeX, relativeY, translationX, translationY);
483     return {
484       leafKey,
485       pointX: centers[2]! + normal[0] * target.radius,
486       pointY: centers[3]! + normal[1] * target.radius,
487       normalX: normal[0],
488       normalY: normal[1],
489       distance: 0,
490       timeOfImpact: 0,
491     };
492   }
493   if (translationLength === 0) return null;
494   const a = translationX * translationX + translationY * translationY;
495   const b = 2 * (relativeX * translationX + relativeY * translationY);
496   const c = relativeX * relativeX + relativeY * relativeY - radius * radius;
497   const discriminant = b * b - 4 * a * c;
498   if (discriminant < -GEOMETRY_EPSILON) return null;
499   const time = (-b - Math.sqrt(Math.max(0, discriminant))) / (2 * a);
500   if (time < -CAST_TIME_TOLERANCE || time > 1 + CAST_TIME_TOLERANCE) return null;
501   const timeOfImpact = Math.max(0, Math.min(1, time));
502   const movingX = centers[0]! + translationX * timeOfImpact;
503   const movingY = centers[1]! + translationY * timeOfImpact;
504   const normal = unitAgainstMotion(
505     movingX - centers[2]!,
506     movingY - centers[3]!,
507     translationX,
508     translationY,
509   );
510   return {
511     leafKey,
512     pointX: centers[2]! + normal[0] * target.radius,
513     pointY: centers[3]! + normal[1] * target.radius,
514     normalX: normal[0],
515     normalY: normal[1],
516     distance: translationLength * timeOfImpact,
517     timeOfImpact,
518   };
519 }
520 
521 function conservativelyCastLeafPair(
522   moving: PibblPhysicsShape2D,
523   from: InternalPose2D,
524   translationX: number,
525   translationY: number,
526   translationLength: number,
527   target: PibblPhysicsShape2D,
528   targetPose: InternalPose2D,
529   leafKey: readonly [number, number],
530   includeInitialOverlap: boolean,
531 ): CastCandidate2D | null {
532   let time = 0;
533   for (let iteration = 0; iteration < CAST_MAX_ITERATIONS; iteration += 1) {
534     const movingPose = {
535       x: from.x + translationX * time,
536       y: from.y + translationY * time,
537       rotationRadians: from.rotationRadians,
538     };
539     const distance = samplePairDistance(moving, movingPose, target, targetPose);
540     if (distance === null) return null;
541     if (distance.separation <= (time === 0 ? 0 : CAST_HIT_TOLERANCE)) {
542       if (time === 0 && !includeInitialOverlap) return null;
543       const normal = unitAgainstMotion(
544         distance.normal[0],
545         distance.normal[1],
546         translationX,
547         translationY,
548       );
549       return {
550         leafKey,
551         pointX: distance.pointOnSecond[0],
552         pointY: distance.pointOnSecond[1],
553         normalX: normal[0],
554         normalY: normal[1],
555         distance: translationLength * time,
556         timeOfImpact: time,
557       };
558     }
559     if (translationLength === 0) return null;
560     const closingSpeed = -(
561       distance.normal[0] * translationX +
562       distance.normal[1] * translationY
563     );
564     if (!Number.isFinite(closingSpeed) || closingSpeed <= 0) break;
565     const step = distance.separation / closingSpeed;
566     if (!Number.isFinite(step) || step <= CAST_TIME_TOLERANCE) break;
567     const nextTime = time + step;
568     if (!Number.isFinite(nextTime)) return null;
569     if (nextTime <= time) break;
570     if (nextTime > 1 + CAST_TIME_TOLERANCE) break;
571     time = Math.min(1, nextTime);
572   }
573   return refineBoundedTangent(
574     moving,
575     from,
576     translationX,
577     translationY,
578     translationLength,
579     target,
580     targetPose,
581     leafKey,
582   );
583 }
584 
585 function refineBoundedTangent(
586   moving: PibblPhysicsShape2D,
587   from: InternalPose2D,
588   translationX: number,
589   translationY: number,
590   translationLength: number,
591   target: PibblPhysicsShape2D,
592   targetPose: InternalPose2D,
593   leafKey: readonly [number, number],
594 ): CastCandidate2D | null {
595   let minimumTime = 0;
596   let maximumTime = 1;
597   let tangentTime = 0;
598   let tangent = sampleTranslatedDistance(
599     moving,
600     from,
601     translationX,
602     translationY,
603     tangentTime,
604     target,
605     targetPose,
606   );
607   if (tangent === null) return null;
608   for (let iteration = 0; iteration < CAST_REFINEMENT_ITERATIONS; iteration += 1) {
609     const middleTime = (minimumTime + maximumTime) / 2;
610     const middle = sampleTranslatedDistance(
611       moving,
612       from,
613       translationX,
614       translationY,
615       middleTime,
616       target,
617       targetPose,
618     );
619     if (middle === null) return null;
620     if (middle.separation < tangent.separation) {
621       tangentTime = middleTime;
622       tangent = middle;
623     }
624     const derivative =
625       middle.normal[0] * translationX + middle.normal[1] * translationY;
626     if (!Number.isFinite(derivative)) return null;
627     if (derivative < 0) {
628       minimumTime = middleTime;
629     } else {
630       maximumTime = middleTime;
631     }
632   }
633   if (tangent.separation > CAST_HIT_TOLERANCE) return null;
634 
635   let impactTime = tangentTime;
636   if (tangent.separation < 0) {
637     let separatedTime = 0;
638     for (let iteration = 0; iteration < CAST_REFINEMENT_ITERATIONS; iteration += 1) {
639       const middleTime = (separatedTime + impactTime) / 2;
640       const middle = sampleTranslatedDistance(
641         moving,
642         from,
643         translationX,
644         translationY,
645         middleTime,
646         target,
647         targetPose,
648       );
649       if (middle === null) return null;
650       if (middle.separation <= CAST_HIT_TOLERANCE) {
651         impactTime = middleTime;
652         tangent = middle;
653       } else {
654         separatedTime = middleTime;
655       }
656     }
657   }
658   const normal = unitAgainstMotion(
659     tangent.normal[0],
660     tangent.normal[1],
661     translationX,
662     translationY,
663   );
664   return {
665     leafKey,
666     pointX: tangent.pointOnSecond[0],
667     pointY: tangent.pointOnSecond[1],
668     normalX: normal[0],
669     normalY: normal[1],
670     distance: translationLength * impactTime,
671     timeOfImpact: impactTime,
672   };
673 }
674 
675 function sampleTranslatedDistance(
676   moving: PibblPhysicsShape2D,
677   from: InternalPose2D,
678   translationX: number,
679   translationY: number,
680   time: number,
681   target: PibblPhysicsShape2D,
682   targetPose: InternalPose2D,
683 ): PibblPhysicsDistance2D | null {
684   return samplePairDistance(
685     moving,
686     {
687       x: from.x + translationX * time,
688       y: from.y + translationY * time,
689       rotationRadians: from.rotationRadians,
690     },
691     target,
692     targetPose,
693   );
694 }
695 
696 function samplePairDistance(
697   moving: PibblPhysicsShape2D,
698   movingPose: InternalPose2D,
699   target: PibblPhysicsShape2D,
700   targetPose: InternalPose2D,
701 ): PibblPhysicsDistance2D | null {
702   try {
703     const distance = distanceBetweenPreparedShapes2D(
704       moving,
705       internalPoseAsPublic(movingPose),
706       target,
707       internalPoseAsPublic(targetPose),
708     );
709     return isFiniteDistance(distance) ? distance : null;
710   } catch {
711     return null;
712   }
713 }
714 
715 function prepareShape(
716   shape: PibblPhysicsShape2D,
717   publicPose: PibblPhysicsPose2D,
718   posePath: string,
719 ): PreparedCastShape2D {
720   const record = requireShapeRecord2D(shape);
721   const pose = composePose2D(copyPose2D(publicPose, posePath), record.localPose);
722   return { record, pose };
723 }
724 
725 function shapeForLeaf(leaf: ConvexLeaf2D): PibblPhysicsShape2D {
726   return createShape2D({
727     leaves: Object.freeze([leaf]),
728     localPose: IDENTITY_POSE_2D,
729     localBounds: new Float64Array(4),
730     area: 0,
731     centroidX: 0,
732     centroidY: 0,
733     unitInertia: 0,
734   });
735 }
736 
737 function shapeForPoint(): PibblPhysicsShape2D {
738   return shapeForLeaf(Object.freeze({
739     kind: 'segment',
740     start: new Float64Array(2),
741     end: new Float64Array(2),
742     canonicalLeafIndex: 0,
743   }));
744 }
745 
746 function rayCircleDistance(
747   originX: number,
748   originY: number,
749   directionX: number,
750   directionY: number,
751   centerX: number,
752   centerY: number,
753   radius: number,
754   maxDistance: number,
755 ): number | null {
756   const relativeX = originX - centerX;
757   const relativeY = originY - centerY;
758   const projection = relativeX * directionX + relativeY * directionY;
759   const discriminant = projection * projection -
760     (relativeX * relativeX + relativeY * relativeY - radius * radius);
761   if (discriminant < -GEOMETRY_EPSILON) return null;
762   const distance = -projection - Math.sqrt(Math.max(0, discriminant));
763   if (distance < -CAST_TIME_TOLERANCE || distance > maxDistance + CAST_TIME_TOLERANCE) {
764     return null;
765   }
766   return Math.max(0, Math.min(maxDistance, distance));
767 }
768 
769 function unitAgainstMotion(
770   x: number,
771   y: number,
772   motionX: number,
773   motionY: number,
774 ): readonly [number, number] {
775   const length = Math.hypot(x, y);
776   if (length > GEOMETRY_EPSILON) {
777     const unitX = x / length;
778     const unitY = y / length;
779     return unitX * motionX + unitY * motionY > 0
780       ? [-unitX, -unitY]
781       : [unitX, unitY];
782   }
783   const motionLength = Math.hypot(motionX, motionY);
784   return motionLength > 0
785     ? [-motionX / motionLength, -motionY / motionLength]
786     : [1, 0];
787 }
788 
789 function segmentNormalAgainstMotion(
790   segmentX: number,
791   segmentY: number,
792   motionX: number,
793   motionY: number,
794 ): readonly [number, number] {
795   const length = Math.hypot(segmentX, segmentY);
796   if (length <= GEOMETRY_EPSILON) {
797     return unitAgainstMotion(0, 0, motionX, motionY);
798   }
799   let normalX = -segmentY / length;
800   let normalY = segmentX / length;
801   if (normalX * motionX + normalY * motionY > 0) {
802     normalX = -normalX;
803     normalY = -normalY;
804   }
805   return [normalX, normalY];
806 }
807 
808 function internalPoseAsPublic(pose: InternalPose2D): PibblPhysicsPose2D {
809   return {
810     position: [pose.x, pose.y],
811     rotationDegrees: pose.rotationRadians * 180 / Math.PI,
812   };
813 }
814 
815 function pairLeafKey(
816   first: ConvexLeaf2D,
817   second: ConvexLeaf2D,
818 ): readonly [number, number] {
819   return [first.canonicalLeafIndex, second.canonicalLeafIndex];
820 }
821 
822 function isEarlierCandidate(
823   candidate: CastCandidate2D,
824   selected: CastCandidate2D | null,
825 ): boolean {
826   if (selected === null) return true;
827   const timeDifference = candidate.timeOfImpact - selected.timeOfImpact;
828   return Math.abs(timeDifference) > CAST_TIME_TOLERANCE
829     ? timeDifference < 0
830     : candidate.leafKey[0] < selected.leafKey[0] ||
831         (candidate.leafKey[0] === selected.leafKey[0] &&
832           candidate.leafKey[1] < selected.leafKey[1]);
833 }
834 
835 function isFiniteDistance(distance: Readonly<{
836   separation: number;
837   pointOnFirst: PibblPhysicsVector2;
838   pointOnSecond: PibblPhysicsVector2;
839   normal: PibblPhysicsVector2;
840 }>): boolean {
841   return Number.isFinite(distance.separation) &&
842     Number.isFinite(distance.pointOnFirst[0]) &&
843     Number.isFinite(distance.pointOnFirst[1]) &&
844     Number.isFinite(distance.pointOnSecond[0]) &&
845     Number.isFinite(distance.pointOnSecond[1]) &&
846     Number.isFinite(distance.normal[0]) &&
847     Number.isFinite(distance.normal[1]);
848 }
849 
850 function publicHit(candidate: CastCandidate2D): PibblPhysicsShapeHit2D {
851   return Object.freeze({
852     point: freezeVector(candidate.pointX, candidate.pointY),
853     normal: freezeVector(candidate.normalX, candidate.normalY),
854     distance: cleanZero(candidate.distance),
855     timeOfImpact: cleanZero(candidate.timeOfImpact),
856   });
857 }
858 
859 function freezeVector(x: number, y: number): PibblPhysicsVector2 {
860   return Object.freeze([cleanZero(x), cleanZero(y)] as const);
861 }
862 
863 function cleanZero(value: number): number {
864   return Object.is(value, -0) || Math.abs(value) <= Number.EPSILON ? 0 : value;
865 }
866 
867 function cross(ax: number, ay: number, bx: number, by: number): number {
868   return ax * by - ay * bx;
869 }
870 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.