Skip to content

packages/core/src/features/physics/lib/2d/runtime/descriptors.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 { Group, isSignal } from "@pibbl/core";
2 import {
3   pibblInternalCreatePropAttachment,
4   type PibblInternalPrimitiveAttachmentInput,
5   type PibblInternalMountedPropAttachment,
6 } from "@pibbl/core/internal";
7 import type {
8   PibblPhysicsShape2D,
9   PibblPhysicsVector2,
10 } from "../../../2d-geometry.js";
11 import { transformShape2D } from "../../../2d-geometry.js";
12 import { requireShapeRecord2D, type ShapeRecord2D } from "../shape-records.js";
13 import type {
14   PibblPhysicsAttachment2D,
15   PibblPhysicsBodyAttachment2D,
16   PibblPhysicsBodyHandle2D,
17   PibblPhysicsColliderAttachment2D,
18   PibblPhysicsColliderConfiguration2D,
19   PibblPhysicsCollisionFilter,
20   PibblPhysicsDynamicBody2DOptions,
21   PibblPhysicsKinematicBody2DOptions,
22   PibblPhysicsKinematicTarget2D,
23   PibblPhysicsStaticBody2DOptions,
24 } from "../types.js";
25 import {
26   copyFiniteVector2,
27   requireFiniteNumber,
28   requireNonnegativeNumber,
29   requirePositiveNumber,
30   requireSafeIntegerInRange,
31 } from "../../shared/validation.js";
32 import {
33   pushPhysicsBody2D,
34   requireCurrentPhysicsBody2D,
35   requireCurrentPhysicsWorld2D,
36 } from "./context.js";
37 import {
38   bakeCanvasShapeTransform2D,
39   inferCanvasColliderShape2D,
40 } from "../canvas/inference.js";
41 import {
42   bodyLocalTransformFromPrimitive2D,
43   captureBodyPresentation2D,
44   matrixFromPose,
45   resolveDynamicPresentationTransform2D,
46   type CanvasBodyPresentationCapture2D,
47 } from "../canvas/presentation.js";
48 
49 const bodyFramesByWorld2D = new WeakMap<object, Map<number, BodyFrame2D>>();
50 
51 interface BodyFrame2D {
52   readonly poseMatrix: DOMMatrix;
53   readonly preBodyLineage: PibblInternalPrimitiveAttachmentInput["presentationTransformLineage"];
54   readonly untrackedCanvasTransformCount: number;
55 }
56 
57 const attachmentRecord2DSymbol = Symbol("PibblPhysicsAttachment2D");
58 
59 // Exact Pibbl presentation anchors preserve authored transforms as doubles. A
60 // relative collider frame still performs one inversion and one multiplication,
61 // so accept only their bounded IEEE-754 double-operation noise. This is never
62 // an authored-geometry epsilon: a post-mount 1e-7 translation or scale change
63 // remains observable.
64 const RELATIVE_MATRIX_ROUNDOFF_OPERATIONS = 32;
65 
66 export interface NormalizedPhysicsCollisionFilter2D {
67   readonly category: number;
68   readonly mask: number;
69   readonly group: number;
70 }
71 
72 export interface NormalizedPhysicsColliderConfiguration2D {
73   readonly shape: "self" | PibblPhysicsShape2D;
74   readonly offset: PibblPhysicsVector2;
75   readonly rotationDegrees: number;
76   readonly density: number;
77   readonly friction: number;
78   readonly restitution: number;
79   readonly sensor: boolean;
80   readonly contributesToMass: boolean;
81   readonly collisionFilter: NormalizedPhysicsCollisionFilter2D;
82   readonly tag: unknown;
83   readonly handlers: Readonly<{
84     readonly onCollisionStart?: PibblPhysicsColliderConfiguration2D["onCollisionStart"];
85     readonly onCollisionEnd?: PibblPhysicsColliderConfiguration2D["onCollisionEnd"];
86     readonly onHit?: PibblPhysicsColliderConfiguration2D["onHit"];
87     readonly onOverlapStart?: PibblPhysicsColliderConfiguration2D["onOverlapStart"];
88     readonly onOverlapEnd?: PibblPhysicsColliderConfiguration2D["onOverlapEnd"];
89   }>;
90 }
91 
92 export interface NormalizedPhysicsDynamicBody2DOptions {
93   readonly enabled: boolean;
94   readonly handle: PibblPhysicsBodyHandle2D | undefined;
95   readonly linearVelocity: PibblPhysicsVector2;
96   readonly angularVelocityDegreesPerSecond: number;
97   readonly linearDamping: number;
98   readonly angularDamping: number;
99   readonly gravityScale: number;
100   readonly mass: "auto" | number;
101   readonly fixedRotation: boolean;
102   readonly allowSleep: boolean;
103   readonly ccd: boolean;
104   readonly reenableVelocity: "zero" | "preserve";
105   readonly collider: false | NormalizedPhysicsColliderConfiguration2D;
106 }
107 
108 export interface NormalizedPhysicsStaticBody2DOptions {
109   readonly enabled: boolean;
110   readonly handle: PibblPhysicsBodyHandle2D | undefined;
111   readonly collider: false | NormalizedPhysicsColliderConfiguration2D;
112 }
113 
114 export interface NormalizedPhysicsKinematicBody2DOptions {
115   readonly enabled: boolean;
116   readonly handle: PibblPhysicsBodyHandle2D | undefined;
117   readonly target: Readonly<PibblPhysicsKinematicTarget2D>;
118   readonly allowSleep: boolean;
119   readonly collider: false | NormalizedPhysicsColliderConfiguration2D;
120 }
121 
122 export type PhysicsBodyAttachment2DDescriptor =
123   | Readonly<{
124       readonly kind: "body";
125       readonly bodyType: "dynamic";
126       readonly options: NormalizedPhysicsDynamicBody2DOptions;
127     }>
128   | Readonly<{
129       readonly kind: "body";
130       readonly bodyType: "static";
131       readonly options: NormalizedPhysicsStaticBody2DOptions;
132     }>
133   | Readonly<{
134       readonly kind: "body";
135       readonly bodyType: "kinematic";
136       readonly options: NormalizedPhysicsKinematicBody2DOptions;
137     }>;
138 
139 export interface PhysicsColliderAttachment2DDescriptor {
140   readonly kind: "collider";
141   readonly options: NormalizedPhysicsColliderConfiguration2D;
142 }
143 
144 export type PhysicsAttachment2DDescriptor =
145   PhysicsBodyAttachment2DDescriptor | PhysicsColliderAttachment2DDescriptor;
146 
147 type RegisteredAttachment2D = Readonly<{
148   readonly [attachmentRecord2DSymbol]: PhysicsAttachment2DDescriptor;
149 }>;
150 
151 /**
152  * Creates a declarative body attachment whose motion is advanced by the physics solver.
153  *
154  * @param options - Dynamic body, collider, and initial motion configuration. See
155  * {@link PibblPhysicsDynamicBody2DOptions} .
156  * @returns A dynamic-body attachment for a supported primitive's physics prop. See
157  * {@link PibblPhysicsBodyAttachment2D} .
158  *
159  * @see {@link PibblPhysicsDynamicBody2DOptions}
160  * @see {@link PibblPhysicsBodyAttachment2D}
161  */
162 export function dynamicBody2D(
163   options: PibblPhysicsDynamicBody2DOptions = {},
164 ): PibblPhysicsBodyAttachment2D {
165   const mass = options.mass ?? "auto";
166   if (mass !== "auto") requirePositiveNumber(mass, "options.mass");
167   const reenableVelocity = options.reenableVelocity ?? "zero";
168   if (reenableVelocity !== "zero" && reenableVelocity !== "preserve") {
169     throw new TypeError(
170       'options.reenableVelocity must be "zero" or "preserve".',
171     );
172   }
173   return createAttachment({
174     kind: "body",
175     bodyType: "dynamic",
176     options: Object.freeze({
177       enabled: options.enabled ?? true,
178       handle: options.handle,
179       linearVelocity: copyFiniteVector2(
180         options.linearVelocity ?? [0, 0],
181         "options.linearVelocity",
182       ),
183       angularVelocityDegreesPerSecond: requireFiniteNumber(
184         options.angularVelocityDegreesPerSecond ?? 0,
185         "options.angularVelocityDegreesPerSecond",
186       ),
187       linearDamping: requireNonnegativeNumber(
188         options.linearDamping ?? 0,
189         "options.linearDamping",
190       ),
191       angularDamping: requireNonnegativeNumber(
192         options.angularDamping ?? 0,
193         "options.angularDamping",
194       ),
195       gravityScale: requireFiniteNumber(
196         options.gravityScale ?? 1,
197         "options.gravityScale",
198       ),
199       mass,
200       fixedRotation: options.fixedRotation ?? false,
201       allowSleep: options.allowSleep ?? true,
202       ccd: options.ccd ?? false,
203       reenableVelocity,
204       collider: normalizeOptionalCollider(options.collider, "options.collider"),
205     }),
206   });
207 }
208 
209 /**
210  * Creates a declarative body attachment that remains fixed in the simulation.
211  *
212  * @param options - Static body and collider configuration. See
213  * {@link PibblPhysicsStaticBody2DOptions} .
214  * @returns A static-body attachment for a supported primitive's physics prop. See
215  * {@link PibblPhysicsBodyAttachment2D} .
216  *
217  * @see {@link PibblPhysicsStaticBody2DOptions}
218  * @see {@link PibblPhysicsBodyAttachment2D}
219  */
220 export function staticBody2D(
221   options: PibblPhysicsStaticBody2DOptions = {},
222 ): PibblPhysicsBodyAttachment2D {
223   return createAttachment({
224     kind: "body",
225     bodyType: "static",
226     options: Object.freeze({
227       enabled: options.enabled ?? true,
228       handle: options.handle,
229       collider: normalizeOptionalCollider(options.collider, "options.collider"),
230     }),
231   });
232 }
233 
234 /**
235  * Creates a declarative body attachment driven by application-supplied target signals.
236  *
237  * @param options - Kinematic body, collider, and motion configuration. See
238  * {@link PibblPhysicsKinematicBody2DOptions} .
239  * @returns A kinematic-body attachment for a supported primitive's physics prop. See
240  * {@link PibblPhysicsBodyAttachment2D} .
241  *
242  * @see {@link PibblPhysicsKinematicBody2DOptions}
243  * @see {@link PibblPhysicsBodyAttachment2D}
244  */
245 export function kinematicBody2D(
246   options: PibblPhysicsKinematicBody2DOptions = {},
247 ): PibblPhysicsBodyAttachment2D {
248   const target = options.target ?? {};
249   if (target.position !== undefined && !isSignal(target.position)) {
250     throw new TypeError("options.target.position must be a Pibbl signal.");
251   }
252   if (
253     target.rotationDegrees !== undefined &&
254     !isSignal(target.rotationDegrees)
255   ) {
256     throw new TypeError("options.target.rotationDegrees must be a Pibbl signal.");
257   }
258   return createAttachment({
259     kind: "body",
260     bodyType: "kinematic",
261     options: Object.freeze({
262       enabled: options.enabled ?? true,
263       handle: options.handle,
264       target: Object.freeze({
265         position: target.position,
266         rotationDegrees: target.rotationDegrees,
267       }),
268       allowSleep: options.allowSleep ?? true,
269       collider: normalizeOptionalCollider(options.collider, "options.collider"),
270     }),
271   });
272 }
273 
274 /**
275  * Creates a declarative collider attachment for a primitive within a physics body.
276  *
277  * @param options - Collider shape, material, filtering, and sensor configuration. See
278  * {@link PibblPhysicsColliderConfiguration2D} .
279  * @returns A collider attachment for a supported primitive's physics prop. See
280  * {@link PibblPhysicsColliderAttachment2D} .
281  *
282  * @see {@link PibblPhysicsColliderConfiguration2D}
283  * @see {@link PibblPhysicsColliderAttachment2D}
284  */
285 export function collider2D(
286   options: PibblPhysicsColliderConfiguration2D = {},
287 ): PibblPhysicsColliderAttachment2D {
288   return createAttachment({
289     kind: "collider",
290     options: normalizeCollider(options, "options"),
291   });
292 }
293 
294 export function requirePhysicsAttachment2DDescriptor(
295   value: PibblPhysicsAttachment2D,
296 ): PhysicsAttachment2DDescriptor {
297   const descriptor =
298     typeof value === "object" && value !== null
299       ? (value as Partial<RegisteredAttachment2D>)[attachmentRecord2DSymbol]
300       : undefined;
301   if (descriptor === undefined) {
302     throw new TypeError("Expected a registered Pibbl physics 2D attachment.");
303   }
304   return descriptor;
305 }
306 
307 function createAttachment<T extends PhysicsAttachment2DDescriptor>(
308   descriptor: T,
309 ): T["kind"] extends "body"
310   ? PibblPhysicsBodyAttachment2D
311   : PibblPhysicsColliderAttachment2D {
312   const frozenDescriptor = Object.freeze(descriptor);
313   const value = Object.freeze({
314     [attachmentRecord2DSymbol]: frozenDescriptor,
315   }) as unknown as RegisteredAttachment2D & PibblPhysicsAttachment2D;
316   return pibblInternalCreatePropAttachment(
317     "physics",
318     value,
319     () => new MountedPhysicsAttachment2D(value),
320   ) as never;
321 }
322 
323 export class MountedPhysicsAttachment2D implements PibblInternalMountedPropAttachment {
324   private descriptor: PhysicsAttachment2DDescriptor;
325   private world: ReturnType<typeof requireCurrentPhysicsWorld2D> | undefined;
326   private bodyId: number | undefined;
327   private releaseBodyScope: (() => void) | undefined;
328   private cachedShapeInput:
329     NormalizedPhysicsColliderConfiguration2D | undefined;
330   private cachedShape: ShapeRecord2D | undefined;
331   private cachedSelfShapeKey: string | undefined;
332   private cachedSelfShape: PibblPhysicsShape2D | undefined;
333   private cachedBakedSource: PibblPhysicsShape2D | undefined;
334   private cachedBakedTransform: readonly number[] | undefined;
335   private cachedBakedShape: PibblPhysicsShape2D | undefined;
336   private bodyPresentation: CanvasBodyPresentationCapture2D | undefined;
337   // `exit` runs after every traversal, while `dispose` runs only when this
338   // mounted attachment is actually removed. Keep the last ownership key so a
339   // removed keyed body releases its presentation lineage from the long-lived
340   // world's frame map.
341   private bodyFrameWorld: object | undefined;
342   private bodyFrameId: number | undefined;
343   private wasEnabled = false;
344 
345   constructor(value: PibblPhysicsAttachment2D) {
346     this.descriptor = requirePhysicsAttachment2DDescriptor(value);
347   }
348 
349   update(value: unknown): void {
350     const next = requirePhysicsAttachment2DDescriptor(
351       value as PibblPhysicsAttachment2D,
352     );
353     if (this.descriptor.kind === "body" && next.kind !== "body") {
354       this.releaseBodyFrame();
355     }
356     this.descriptor = next;
357   }
358 
359   enter(): void {
360     const world = requireCurrentPhysicsWorld2D();
361     this.world = world;
362     if (this.descriptor.kind === "body") {
363       const bodyId = world.bodyIdFor(this);
364       if (
365         this.bodyFrameWorld !== undefined &&
366         (this.bodyFrameWorld !== world || this.bodyFrameId !== bodyId)
367       ) {
368         this.releaseBodyFrame();
369       }
370       this.bodyId = bodyId;
371       this.releaseBodyScope = pushPhysicsBody2D({ world, bodyId });
372     } else {
373       this.bodyId = requireCurrentPhysicsBody2D().bodyId;
374     }
375   }
376 
377   preparePrimitive(
378     input: PibblInternalPrimitiveAttachmentInput,
379   ): Readonly<{ readonly presentationTransform2D?: DOMMatrix }> | void {
380     const world = this.world;
381     const bodyId = this.bodyId;
382     if (world === undefined || bodyId === undefined) {
383       throw new Error(
384         "Physics 2D attachment preparation requires its entered scope.",
385       );
386     }
387     if (this.descriptor.kind === "body") {
388       const descriptor = this.descriptor;
389       const simulationParent = world.simulationTransform(
390         input.parentWorldTransform,
391       );
392       const enabled = descriptor.options.enabled;
393       const reenabled = !this.wasEnabled && enabled;
394       const local = bodyLocalTransformFromPrimitive2D(
395         input.componentType,
396         input.style,
397       );
398       if (
399         this.bodyPresentation === undefined ||
400         reenabled ||
401         descriptor.bodyType !== "dynamic"
402       ) {
403         this.bodyPresentation = captureBodyPresentation2D(
404           simulationParent,
405           local,
406           input.componentName,
407           descriptor.bodyType === "dynamic" && enabled,
408         );
409       }
410       const presentation = this.bodyPresentation;
411       let bodyFrames = bodyFramesByWorld2D.get(world);
412       if (!bodyFrames) {
413         bodyFrames = new Map();
414         bodyFramesByWorld2D.set(world, bodyFrames);
415       }
416       let presentationTransform2D: DOMMatrix | undefined;
417       let effectiveBodyFrame: DOMMatrix;
418       if (!enabled || descriptor.bodyType !== "dynamic") {
419         effectiveBodyFrame = world.rootTransform(
420           matrixFromPose(presentation.initialPose),
421         );
422       } else {
423         world.handle.revision.get();
424         const presentedPose = reenabled
425           ? presentation.initialPose
426           : (world.presentationPose(bodyId) ?? presentation.initialPose);
427         presentationTransform2D = resolveDynamicPresentationTransform2D(
428           presentation,
429           presentedPose,
430           simulationParent,
431           local,
432           input.componentName,
433         );
434         effectiveBodyFrame = input.parentWorldTransform.multiply(
435           presentationTransform2D.multiply(local),
436         );
437       }
438       bodyFrames.set(
439         bodyId,
440         Object.freeze({
441           poseMatrix: effectiveBodyFrame,
442           preBodyLineage: input.presentationTransformLineage,
443           untrackedCanvasTransformCount:
444             (input.presentationTransformLineage
445               ?.untrackedCanvasTransformCount ?? 0) +
446             (input.hasUntrackedCanvasTransform ? 1 : 0),
447         }),
448       );
449       this.bodyFrameWorld = world;
450       this.bodyFrameId = bodyId;
451       const collider = descriptor.options.collider;
452       const effectiveCollider =
453         collider === false
454           ? false
455           : collider.shape === "self" && input.componentType === Group
456             ? false
457             : withColliderShape(
458                 collider,
459                 this.bakeShape(
460                   collider.shape === "self"
461                     ? this.resolveSelfShape(input)
462                     : collider.shape,
463                   descriptor.bodyType === "dynamic"
464                     ? matrixFromPose(presentation.initialPose)
465                         .inverse()
466                         .multiply(simulationParent)
467                         .multiply(local)
468                     : relativeColliderTransform2D(
469                         effectiveBodyFrame,
470                         input.parentWorldTransform,
471                       ).multiply(local),
472                 ),
473               );
474       const effectiveDescriptor =
475         effectiveCollider === collider
476           ? descriptor
477           : (Object.freeze({
478               ...descriptor,
479               options: Object.freeze({
480                 ...descriptor.options,
481                 collider: effectiveCollider,
482               }),
483             }) as PhysicsBodyAttachment2DDescriptor);
484       world.collectBody(
485         this,
486         bodyId,
487         effectiveDescriptor,
488         presentation.initialPose,
489       );
490       this.wasEnabled = enabled;
491       return presentationTransform2D === undefined
492         ? undefined
493         : { presentationTransform2D };
494     } else {
495       const bodyFrame = requireBodyFrame(world, bodyId);
496       const lineageTransform = relativeColliderTransformFromLineage2D(
497         bodyFrame,
498         input,
499       );
500       const options = withColliderShape(
501         this.descriptor.options,
502         this.bakeShape(
503           this.descriptor.options.shape === "self"
504             ? this.resolveSelfShape(input)
505             : this.descriptor.options.shape,
506           (
507             lineageTransform ??
508             relativeColliderTransform2D(
509               bodyFrame.poseMatrix,
510               input.parentWorldTransform,
511             )
512           ).multiply(
513             bodyLocalTransformFromPrimitive2D(input.componentType, input.style),
514           ),
515           lineageTransform === undefined,
516         ),
517       );
518       world.collectCollider(this, bodyId, options);
519     }
520   }
521 
522   exit(_success: boolean): void {
523     try {
524       this.releaseBodyScope?.();
525     } finally {
526       this.releaseBodyScope = undefined;
527       this.bodyId = undefined;
528       this.world = undefined;
529     }
530   }
531 
532   dispose(): void {
533     try {
534       this.exit(false);
535     } finally {
536       this.releaseBodyFrame();
537     }
538   }
539 
540   private releaseBodyFrame(): void {
541     const world = this.bodyFrameWorld;
542     const bodyId = this.bodyFrameId;
543     if (world !== undefined && bodyId !== undefined) {
544       const frames = bodyFramesByWorld2D.get(world);
545       frames?.delete(bodyId);
546       if (frames?.size === 0) bodyFramesByWorld2D.delete(world);
547     }
548     this.bodyFrameWorld = undefined;
549     this.bodyFrameId = undefined;
550   }
551 
552   resolveShape(
553     options: NormalizedPhysicsColliderConfiguration2D,
554   ): ShapeRecord2D {
555     if (
556       this.cachedShapeInput &&
557       sameColliderShapeInput(this.cachedShapeInput, options)
558     ) {
559       return this.cachedShape!;
560     }
561     if (options.shape === "self") {
562       throw new Error(
563         "Self collider inference is not available in this runtime task.",
564       );
565     }
566     const transformed =
567       options.offset[0] === 0 &&
568       options.offset[1] === 0 &&
569       options.rotationDegrees === 0
570         ? options.shape
571         : transformShape2D(options.shape, {
572             position: options.offset,
573             rotationDegrees: options.rotationDegrees,
574           });
575     this.cachedShapeInput = options;
576     this.cachedShape = requireShapeRecord2D(transformed);
577     return this.cachedShape;
578   }
579 
580   private resolveSelfShape(
581     input: PibblInternalPrimitiveAttachmentInput,
582   ): PibblPhysicsShape2D {
583     const key = `${input.componentName}:${stableGeometryKey(input.style)}`;
584     if (key === this.cachedSelfShapeKey) return this.cachedSelfShape!;
585     const shape = inferCanvasColliderShape2D(input);
586     this.cachedSelfShapeKey = key;
587     this.cachedSelfShape = shape;
588     return shape;
589   }
590 
591   private bakeShape(
592     shape: PibblPhysicsShape2D,
593     transform: DOMMatrixReadOnly,
594     stabilizeFallback = true,
595   ): PibblPhysicsShape2D {
596     const candidate = [
597       transform.a,
598       transform.b,
599       transform.c,
600       transform.d,
601       transform.e,
602       transform.f,
603     ];
604     const values =
605       stabilizeFallback &&
606       this.cachedBakedSource === shape &&
607       this.cachedBakedTransform !== undefined &&
608       sameRelativeTransformWithinCanvasRoundoff(
609         this.cachedBakedTransform,
610         candidate,
611       )
612         ? this.cachedBakedTransform
613         : candidate;
614     if (
615       this.cachedBakedSource === shape &&
616       this.cachedBakedTransform?.every(
617         (value, index) => value === values[index],
618       )
619     ) {
620       return this.cachedBakedShape!;
621     }
622     const baked = bakeCanvasShapeTransform2D(shape, new DOMMatrix([...values]));
623     this.cachedBakedSource = shape;
624     this.cachedBakedTransform = Object.freeze(values);
625     this.cachedBakedShape = baked;
626     return baked;
627   }
628 }
629 
630 /** @internal Test-only ownership observation. */
631 export function snapshotPhysicsBodyFrameCountForTest(world: object): number {
632   return bodyFramesByWorld2D.get(world)?.size ?? 0;
633 }
634 
635 function sameRelativeTransformWithinCanvasRoundoff(
636   previous: readonly number[],
637   candidate: readonly number[],
638 ): boolean {
639   return previous.every((value, index) => {
640     const next = candidate[index]!;
641     const tolerance =
642       index < 4
643         ? linearTransformRoundoffTolerance(value, next)
644         : translationRoundoffTolerance(value, next);
645     return Math.abs(value - next) <= tolerance;
646   });
647 }
648 
649 function linearTransformRoundoffTolerance(
650   previous: number,
651   candidate: number,
652 ): number {
653   const magnitude = Math.max(1, Math.abs(previous), Math.abs(candidate));
654   return (
655     RELATIVE_MATRIX_ROUNDOFF_OPERATIONS * Number.EPSILON * magnitude * magnitude
656   );
657 }
658 
659 function translationRoundoffTolerance(
660   previous: number,
661   candidate: number,
662 ): number {
663   return (
664     RELATIVE_MATRIX_ROUNDOFF_OPERATIONS *
665     Number.EPSILON *
666     (1 + Math.max(Math.abs(previous), Math.abs(candidate)))
667   );
668 }
669 
670 function relativeColliderTransform2D(
671   bodyFrame: DOMMatrixReadOnly,
672   colliderParentFrame: DOMMatrixReadOnly,
673 ): DOMMatrixReadOnly {
674   return sameMatrixExactly2D(bodyFrame, colliderParentFrame)
675     ? new DOMMatrix()
676     : bodyFrame.inverse().multiply(colliderParentFrame);
677 }
678 
679 function relativeColliderTransformFromLineage2D(
680   bodyFrame: BodyFrame2D,
681   input: PibblInternalPrimitiveAttachmentInput,
682 ): DOMMatrix | undefined {
683   const inputUntrackedCanvasTransformCount =
684     (input.presentationTransformLineage?.untrackedCanvasTransformCount ?? 0) +
685     (input.hasUntrackedCanvasTransform ? 1 : 0);
686   if (
687     inputUntrackedCanvasTransformCount !==
688     bodyFrame.untrackedCanvasTransformCount
689   ) {
690     return undefined;
691   }
692   const nodes: DOMMatrix[] = [];
693   let cursor = input.presentationTransformLineage;
694   while (cursor !== bodyFrame.preBodyLineage) {
695     if (cursor === undefined) return undefined;
696     nodes.push(cursor.localTransform);
697     cursor = cursor.parent;
698   }
699   nodes.reverse();
700   if (nodes.length === 0) return new DOMMatrix();
701   let relative = new DOMMatrix();
702   for (const transform of nodes.slice(1)) {
703     relative = relative.multiply(transform);
704   }
705   return relative;
706 }
707 
708 function sameMatrixExactly2D(
709   left: DOMMatrixReadOnly,
710   right: DOMMatrixReadOnly,
711 ): boolean {
712   return (
713     left.a === right.a &&
714     left.b === right.b &&
715     left.c === right.c &&
716     left.d === right.d &&
717     left.e === right.e &&
718     left.f === right.f
719   );
720 }
721 
722 function requireBodyFrame(world: object, bodyId: number): BodyFrame2D {
723   const frame = bodyFramesByWorld2D.get(world)?.get(bodyId);
724   if (!frame)
725     throw new Error("A collider requires its body geometry to prepare first.");
726   return frame;
727 }
728 
729 function withColliderShape(
730   options: NormalizedPhysicsColliderConfiguration2D,
731   shape: PibblPhysicsShape2D,
732 ): NormalizedPhysicsColliderConfiguration2D {
733   return Object.freeze({ ...options, shape });
734 }
735 
736 function stableGeometryKey(
737   style: Readonly<Record<PropertyKey, unknown>>,
738 ): string {
739   return JSON.stringify(style, (key, value: unknown) => {
740     if (
741       ["fill", "stroke", "opacity", "cursor", "filter", "src"].includes(key)
742     ) {
743       return undefined;
744     }
745     if (typeof value === "function") return undefined;
746     if (typeof Path2D !== "undefined" && value instanceof Path2D) {
747       return "[opaque Path2D]";
748     }
749     return value;
750   });
751 }
752 
753 function normalizeOptionalCollider(
754   value:
755     false | PibblPhysicsColliderConfiguration2D | PibblPhysicsShape2D | undefined,
756   path: string,
757 ): false | NormalizedPhysicsColliderConfiguration2D {
758   if (value === false) return false;
759   if (value === undefined) return normalizeCollider({}, path);
760   if (isPhysicsShape2D(value)) return normalizeCollider({ shape: value }, path);
761   return normalizeCollider(value, path);
762 }
763 
764 function normalizeCollider(
765   options: PibblPhysicsColliderConfiguration2D,
766   path: string,
767 ): NormalizedPhysicsColliderConfiguration2D {
768   const shape = options.shape ?? "self";
769   if (shape !== "self") requireShapeRecord2D(shape);
770   const restitution = requireNonnegativeNumber(
771     options.restitution ?? 0,
772     `${path}.restitution`,
773   );
774   if (restitution > 1) {
775     throw new RangeError(`${path}.restitution must be from zero through one.`);
776   }
777   const sensor = options.sensor ?? false;
778   return Object.freeze({
779     shape,
780     offset: copyFiniteVector2(options.offset ?? [0, 0], `${path}.offset`),
781     rotationDegrees: requireFiniteNumber(
782       options.rotationDegrees ?? 0,
783       `${path}.rotationDegrees`,
784     ),
785     density: requirePositiveNumber(options.density ?? 1, `${path}.density`),
786     friction: requireNonnegativeNumber(
787       options.friction ?? 0.5,
788       `${path}.friction`,
789     ),
790     restitution,
791     sensor,
792     contributesToMass: options.contributesToMass ?? !sensor,
793     collisionFilter: normalizeCollisionFilter(options.collisionFilter, path),
794     tag: options.tag,
795     handlers: normalizeHandlers(options, path),
796   });
797 }
798 
799 function normalizeCollisionFilter(
800   filter: PibblPhysicsCollisionFilter | undefined,
801   path: string,
802 ): NormalizedPhysicsCollisionFilter2D {
803   return Object.freeze({
804     category: requireSafeIntegerInRange(
805       filter?.category ?? 1,
806       0,
807       0xffff_ffff,
808       `${path}.collisionFilter.category`,
809     ),
810     mask: requireSafeIntegerInRange(
811       filter?.mask ?? 0xffff_ffff,
812       0,
813       0xffff_ffff,
814       `${path}.collisionFilter.mask`,
815     ),
816     group: requireSafeIntegerInRange(
817       filter?.group ?? 0,
818       -0x8000_0000,
819       0x7fff_ffff,
820       `${path}.collisionFilter.group`,
821     ),
822   });
823 }
824 
825 function normalizeHandlers(
826   options: PibblPhysicsColliderConfiguration2D,
827   path: string,
828 ): NormalizedPhysicsColliderConfiguration2D["handlers"] {
829   const handlers = {
830     onCollisionStart: options.onCollisionStart,
831     onCollisionEnd: options.onCollisionEnd,
832     onHit: options.onHit,
833     onOverlapStart: options.onOverlapStart,
834     onOverlapEnd: options.onOverlapEnd,
835   };
836   for (const [name, handler] of Object.entries(handlers)) {
837     if (handler !== undefined && typeof handler !== "function") {
838       throw new TypeError(`${path}.${name} must be a function.`);
839     }
840   }
841   return Object.freeze(handlers);
842 }
843 
844 function isPhysicsShape2D(value: object): value is PibblPhysicsShape2D {
845   try {
846     requireShapeRecord2D(value as PibblPhysicsShape2D);
847     return true;
848   } catch {
849     return false;
850   }
851 }
852 
853 function sameColliderShapeInput(
854   left: NormalizedPhysicsColliderConfiguration2D,
855   right: NormalizedPhysicsColliderConfiguration2D,
856 ): boolean {
857   return (
858     left.shape === right.shape &&
859     left.offset[0] === right.offset[0] &&
860     left.offset[1] === right.offset[1] &&
861     left.rotationDegrees === right.rotationDegrees
862   );
863 }
864 

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