packages/core/src/features/physics/lib/2d/collision/casts.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.