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