Skip to content

packages/core/src/features/physics/lib/2d/runtime/body-handle.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 { signal, type WritableSignal } from "@pibbl/core";
2 import {
3   pibblInternalCurrentSimulationOwner,
4   pibblInternalQueueSimulationBegin,
5   useInternalHookSlot,
6   type PibblInternalSignalBatch,
7   type PibblInternalSimulationOwner,
8 } from "@pibbl/core/internal";
9 import type {
10   PibblPhysicsPose2D,
11   PibblPhysicsVector2,
12 } from "../../../2d-geometry.js";
13 import type { PibblPhysicsBodyHandle2D } from "../types.js";
14 import type { PhysicsBodyCommand2D } from "../world/world.js";
15 import {
16   copyFiniteVector2,
17   requireFiniteNumber,
18 } from "../../shared/validation.js";
19 import type { PhysicsWorld2DController } from "./controller.js";
20 
21 export type PhysicsBodyHandleField2D =
22   | "position"
23   | "rotationDegrees"
24   | "linearVelocity"
25   | "angularVelocityDegreesPerSecond"
26   | "sleeping"
27   | "enabled"
28   | "generation";
29 
30 export interface PhysicsBodyHandleBinding2D {
31   readonly controller: PhysicsWorld2DController;
32   readonly bodyId: number;
33   readonly bindingGeneration: number;
34 }
35 
36 interface PhysicsBodyHandleState2D {
37   readonly owner: PibblInternalSimulationOwner;
38   readonly signals: Partial<{
39     [Field in PhysicsBodyHandleField2D]: WritableSignal<
40       BodyFieldValue2D[Field]
41     >;
42   }>;
43   binding: PhysicsBodyHandleBinding2D | undefined;
44   nextBindingGeneration: number;
45   active: boolean;
46 }
47 
48 interface BodyFieldValue2D {
49   readonly position: PibblPhysicsVector2;
50   readonly rotationDegrees: number;
51   readonly linearVelocity: PibblPhysicsVector2;
52   readonly angularVelocityDegreesPerSecond: number;
53   readonly sleeping: boolean;
54   readonly enabled: boolean;
55   readonly generation: number;
56 }
57 
58 export interface PhysicsBodyHandleValues2D extends BodyFieldValue2D {}
59 
60 const states = new WeakMap<object, PhysicsBodyHandleState2D>();
61 const externalBindings = new WeakMap<
62   object,
63   {
64     binding: PhysicsBodyHandleBinding2D | undefined;
65     nextBindingGeneration: number;
66   }
67 >();
68 
69 /**
70  * Returns one stable, component-owned 2D body endpoint for this hook slot.
71  *
72  * @returns A stable body handle that can be attached to a physics body. See
73  * {@link PibblPhysicsBodyHandle2D} .
74  *
75  * @see {@link PibblPhysicsBodyHandle2D}
76  */
77 export function pibblPhysicsBody2D(): PibblPhysicsBodyHandle2D {
78   const owner = pibblInternalCurrentSimulationOwner();
79   const slot = useInternalHookSlot("physicsBody2DHandle", (teardowns) => {
80     const handle = createPhysicsBodyHandle2D(owner);
81     teardowns.add(() => disposePhysicsBodyHandle2D(handle));
82     return handle;
83   });
84   return slot.value;
85 }
86 
87 /**
88  * Queues a force on a body, optionally at a world point and with wake-up control.
89  *
90  * @param body - Body receiving the force. See {@link PibblPhysicsBodyHandle2D}.
91  * @param force - Force vector in the world's public coordinate system. See
92  * {@link PibblPhysicsVector2} .
93  * @param options - Optional application point and wake policy. See {@link PibblPhysicsVector2}.
94  *
95  * @see {@link PibblPhysicsBodyHandle2D}
96  * @see {@link PibblPhysicsVector2}
97  */
98 export function applyForce2D(
99   body: PibblPhysicsBodyHandle2D,
100   force: PibblPhysicsVector2,
101   options: Readonly<{ at?: PibblPhysicsVector2; wake?: boolean }> = {},
102 ): void {
103   const copied = copyFiniteVector2(force, "force");
104   const point =
105     options.at === undefined
106       ? undefined
107       : copyFiniteVector2(options.at, "options.at");
108   queueBodyCommand(body, {
109     kind: "apply-force",
110     bodyId: 0,
111     forceX: copied[0],
112     forceY: copied[1],
113     pointX: point?.[0],
114     pointY: point?.[1],
115     wake: requireBoolean(options.wake ?? true, "options.wake"),
116   });
117 }
118 
119 /**
120  * Queues an instantaneous momentum change, optionally at a world point and with wake-up control.
121  *
122  * @param body - Body receiving the impulse. See {@link PibblPhysicsBodyHandle2D}.
123  * @param impulse - Impulse vector in the world's public coordinate system. See
124  * {@link PibblPhysicsVector2} .
125  * @param options - Optional application point and wake policy. See {@link PibblPhysicsVector2}.
126  *
127  * @see {@link PibblPhysicsBodyHandle2D}
128  * @see {@link PibblPhysicsVector2}
129  */
130 export function applyImpulse2D(
131   body: PibblPhysicsBodyHandle2D,
132   impulse: PibblPhysicsVector2,
133   options: Readonly<{ at?: PibblPhysicsVector2; wake?: boolean }> = {},
134 ): void {
135   const copied = copyFiniteVector2(impulse, "impulse");
136   const point =
137     options.at === undefined
138       ? undefined
139       : copyFiniteVector2(options.at, "options.at");
140   queueBodyCommand(body, {
141     kind: "apply-impulse",
142     bodyId: 0,
143     impulseX: copied[0],
144     impulseY: copied[1],
145     pointX: point?.[0],
146     pointY: point?.[1],
147     wake: requireBoolean(options.wake ?? true, "options.wake"),
148   });
149 }
150 
151 /**
152  * Queues torque on a dynamic body through the shared scheduler.
153  *
154  * @param body - Body receiving the torque. See {@link PibblPhysicsBodyHandle2D}.
155  * @param torque - Torque to apply in the world's public units.
156  *
157  * @see {@link PibblPhysicsBodyHandle2D}
158  */
159 export function applyTorque2D(
160   body: PibblPhysicsBodyHandle2D,
161   torque: number,
162 ): void {
163   queueBodyCommand(body, {
164     kind: "apply-torque",
165     bodyId: 0,
166     torque: requireFiniteNumber(torque, "torque"),
167   });
168 }
169 
170 /**
171  * Queues a body's linear velocity in physics units per second.
172  *
173  * @param body - Body whose linear velocity changes. See {@link PibblPhysicsBodyHandle2D}.
174  * @param velocity - Velocity vector in public distance units per second. See
175  * {@link PibblPhysicsVector2} .
176  *
177  * @see {@link PibblPhysicsBodyHandle2D}
178  * @see {@link PibblPhysicsVector2}
179  */
180 export function setLinearVelocity2D(
181   body: PibblPhysicsBodyHandle2D,
182   velocity: PibblPhysicsVector2,
183 ): void {
184   const copied = copyFiniteVector2(velocity, "velocity");
185   queueBodyCommand(body, {
186     kind: "set-linear-velocity",
187     bodyId: 0,
188     linearVelocityX: copied[0],
189     linearVelocityY: copied[1],
190   });
191 }
192 
193 /**
194  * Queues a body's angular velocity in degrees per second.
195  *
196  * @param body - Body whose angular velocity changes. See {@link PibblPhysicsBodyHandle2D}.
197  * @param degreesPerSecond - Angular speed in degrees per second.
198  *
199  * @see {@link PibblPhysicsBodyHandle2D}
200  */
201 export function setAngularVelocity2D(
202   body: PibblPhysicsBodyHandle2D,
203   degreesPerSecond: number,
204 ): void {
205   queueBodyCommand(body, {
206     kind: "set-angular-velocity",
207     bodyId: 0,
208     angularVelocityDegrees: requireFiniteNumber(
209       degreesPerSecond,
210       "degreesPerSecond",
211     ),
212   });
213 }
214 
215 /**
216  * Queues an immediate pose change for a body at the next admitted simulation command phase.
217  *
218  * @param body - Body to reposition. See {@link PibblPhysicsBodyHandle2D}.
219  * @param pose - Destination position and rotation in public coordinates. See
220  * {@link PibblPhysicsPose2D} .
221  *
222  * @see {@link PibblPhysicsBodyHandle2D}
223  * @see {@link PibblPhysicsPose2D}
224  */
225 export function teleportBody2D(
226   body: PibblPhysicsBodyHandle2D,
227   pose: PibblPhysicsPose2D,
228 ): void {
229   queueBodyCommand(body, {
230     kind: "set-pose",
231     bodyId: 0,
232     pose: Object.freeze({
233       position: copyFiniteVector2(pose.position, "pose.position"),
234       rotationDegrees: requireFiniteNumber(
235         pose.rotationDegrees,
236         "pose.rotationDegrees",
237       ),
238     }),
239   });
240 }
241 
242 /**
243  * Queues a request to wake a sleeping body.
244  *
245  * @param body - Body to wake. See {@link PibblPhysicsBodyHandle2D}.
246  *
247  * @see {@link PibblPhysicsBodyHandle2D}
248  */
249 export function wakeBody2D(body: PibblPhysicsBodyHandle2D): void {
250   queueBodyCommand(body, { kind: "wake", bodyId: 0 });
251 }
252 
253 /**
254  * Queues a request to put a body to sleep.
255  *
256  * @param body - Body to put to sleep. See {@link PibblPhysicsBodyHandle2D}.
257  *
258  * @see {@link PibblPhysicsBodyHandle2D}
259  */
260 export function sleepBody2D(body: PibblPhysicsBodyHandle2D): void {
261   queueBodyCommand(body, { kind: "sleep", bodyId: 0 });
262 }
263 
264 /**
265  * Queues re-enabling a body with the requested velocity-preservation policy.
266  *
267  * @param body - Body to re-enable. See {@link PibblPhysicsBodyHandle2D}.
268  * @param options - Whether to zero or preserve velocity when enabling.
269  *
270  * @see {@link PibblPhysicsBodyHandle2D}
271  */
272 export function enableBody2D(
273   body: PibblPhysicsBodyHandle2D,
274   options: Readonly<{ velocity?: "zero" | "preserve" }> = {},
275 ): void {
276   const velocity = options.velocity ?? "zero";
277   if (velocity !== "zero" && velocity !== "preserve") {
278     throw new TypeError('options.velocity must be "zero" or "preserve".');
279   }
280   queueBodyCommand(body, { kind: "enable", bodyId: 0, velocity });
281 }
282 
283 /**
284  * Queues removal of a body's active participation in the simulation.
285  *
286  * @param body - Body to disable. See {@link PibblPhysicsBodyHandle2D}.
287  *
288  * @see {@link PibblPhysicsBodyHandle2D}
289  */
290 export function disableBody2D(body: PibblPhysicsBodyHandle2D): void {
291   queueBodyCommand(body, { kind: "disable", bodyId: 0 });
292 }
293 
294 export function createPhysicsBodyHandle2D(
295   owner: PibblInternalSimulationOwner,
296 ): PibblPhysicsBodyHandle2D {
297   const target = {} as Record<PropertyKey, unknown>;
298   const state: PhysicsBodyHandleState2D = {
299     owner,
300     signals: {},
301     binding: undefined,
302     nextBindingGeneration: 0,
303     active: true,
304   };
305   for (const field of BODY_FIELDS_2D) {
306     Object.defineProperty(target, field, {
307       enumerable: true,
308       get: () => materializeSignal(state, field).asReadonly(),
309     });
310   }
311   const handle = Object.freeze(target) as unknown as PibblPhysicsBodyHandle2D;
312   states.set(handle, state);
313   return handle;
314 }
315 
316 export function bindPhysicsBodyHandle2D(
317   handle: PibblPhysicsBodyHandle2D,
318   controller: PhysicsWorld2DController,
319   bodyId: number,
320 ): number {
321   const state = states.get(handle as object);
322   if (state === undefined) {
323     if (typeof handle !== "object" || handle === null) {
324       throw new TypeError("Expected a live Pibbl physics 2D body handle.");
325     }
326     let external = externalBindings.get(handle as object);
327     if (external?.binding !== undefined) {
328       if (
329         external.binding.controller === controller &&
330         external.binding.bodyId === bodyId
331       ) {
332         return external.binding.bindingGeneration;
333       }
334       throw new Error(
335         "A Pibbl physics 2D body handle may bind to at most one live body.",
336       );
337     }
338     external ??= { binding: undefined, nextBindingGeneration: 0 };
339     const bindingGeneration = ++external.nextBindingGeneration;
340     external.binding = Object.freeze({ controller, bodyId, bindingGeneration });
341     externalBindings.set(handle as object, external);
342     return bindingGeneration;
343   }
344   const current = state.binding;
345   if (current !== undefined) {
346     if (current.controller === controller && current.bodyId === bodyId) {
347       return current.bindingGeneration;
348     }
349     throw new Error(
350       "A Pibbl physics 2D body handle may bind to at most one live body.",
351     );
352   }
353   const bindingGeneration = ++state.nextBindingGeneration;
354   state.binding = Object.freeze({ controller, bodyId, bindingGeneration });
355   return bindingGeneration;
356 }
357 
358 export function unbindPhysicsBodyHandle2D(
359   handle: PibblPhysicsBodyHandle2D,
360   controller: PhysicsWorld2DController,
361   bodyId: number,
362   bindingGeneration: number,
363 ): void {
364   const state = states.get(handle as object);
365   if (state === undefined) {
366     const external = externalBindings.get(handle as object);
367     const binding = external?.binding;
368     if (
369       binding?.controller === controller &&
370       binding.bodyId === bodyId &&
371       binding.bindingGeneration === bindingGeneration
372     ) {
373       external!.binding = undefined;
374     }
375     return;
376   }
377   const binding = state?.binding;
378   if (
379     binding?.controller === controller &&
380     binding.bodyId === bodyId &&
381     binding.bindingGeneration === bindingGeneration
382   ) {
383     state!.binding = undefined;
384   }
385 }
386 
387 export function physicsBodyHandleBinding2D(
388   handle: PibblPhysicsBodyHandle2D,
389 ): PhysicsBodyHandleBinding2D | undefined {
390   return (
391     states.get(handle as object)?.binding ??
392     externalBindings.get(handle as object)?.binding
393   );
394 }
395 
396 export function isPhysicsBodyHandle2D(
397   value: unknown,
398 ): value is PibblPhysicsBodyHandle2D {
399   return (
400     typeof value === "object" &&
401     value !== null &&
402     (states.has(value) || externalBindings.has(value))
403   );
404 }
405 
406 export function physicsBodyHandleOwner2D(
407   handle: PibblPhysicsBodyHandle2D,
408 ): PibblInternalSimulationOwner {
409   return requireState(handle).owner;
410 }
411 
412 export function stagePhysicsBodyHandle2D(
413   batch: PibblInternalSignalBatch,
414   handle: PibblPhysicsBodyHandle2D,
415   values: PhysicsBodyHandleValues2D,
416   writer: object,
417 ): void {
418   const state = states.get(handle as object);
419   if (state === undefined) {
420     stageLegacyHandle(batch, handle, values, writer);
421     return;
422   }
423   for (const field of BODY_FIELDS_2D) {
424     const target = state.signals[field] as
425       WritableSignal<BodyFieldValue2D[typeof field]> | undefined;
426     if (target !== undefined) batch.stage(target, values[field], writer);
427   }
428 }
429 
430 export function snapshotPhysicsBodyHandle2DForTest(
431   handle: PibblPhysicsBodyHandle2D,
432 ): Readonly<{
433   readonly bound: boolean;
434   readonly bindingGeneration: number;
435   readonly materializedFields: readonly PhysicsBodyHandleField2D[];
436 }> {
437   const state = requireState(handle);
438   return Object.freeze({
439     bound: state.binding !== undefined,
440     bindingGeneration: state.binding?.bindingGeneration ?? 0,
441     materializedFields: Object.freeze(
442       BODY_FIELDS_2D.filter((field) => state.signals[field] !== undefined),
443     ),
444   });
445 }
446 
447 function disposePhysicsBodyHandle2D(handle: PibblPhysicsBodyHandle2D): void {
448   const state = states.get(handle as object);
449   if (state === undefined || !state.active) return;
450   state.active = false;
451   state.binding = undefined;
452 }
453 
454 function queueBodyCommand(
455   handle: PibblPhysicsBodyHandle2D,
456   command: PhysicsBodyCommand2D,
457 ): void {
458   const state = requireState(handle);
459   const captured = state.binding;
460   pibblInternalQueueSimulationBegin(state.owner, () => {
461     const current = state.binding;
462     if (
463       captured === undefined ||
464       current === undefined ||
465       current.bindingGeneration !== captured.bindingGeneration ||
466       current.controller !== captured.controller ||
467       current.bodyId !== captured.bodyId
468     ) {
469       traceStaleCommand(command.kind, captured?.bindingGeneration ?? 0);
470       return;
471     }
472     if (
473       !current.controller.acceptBodyHandleCommand(
474         handle,
475         current.bindingGeneration,
476         Object.freeze({
477           ...command,
478           bodyId: current.bodyId,
479         }) as PhysicsBodyCommand2D,
480       )
481     ) {
482       traceStaleCommand(command.kind, current.bindingGeneration);
483     }
484   });
485 }
486 
487 function traceStaleCommand(
488   kind: PhysicsBodyCommand2D["kind"],
489   generation: number,
490 ): void {
491   const runtime = globalThis as typeof globalThis & {
492     readonly process?: Readonly<{
493       readonly env?: Readonly<{ readonly NODE_ENV?: string }>;
494     }>;
495   };
496   if (runtime.process?.env?.NODE_ENV === "production") return;
497   console.warn(
498     `[Pibbl physics] Ignored stale ${kind} command for body handle generation ${String(generation)}.`,
499   );
500 }
501 
502 function requireBoolean(value: boolean, path: string): boolean {
503   if (typeof value !== "boolean")
504     throw new TypeError(`${path} must be boolean.`);
505   return value;
506 }
507 
508 function requireState(
509   handle: PibblPhysicsBodyHandle2D,
510 ): PhysicsBodyHandleState2D {
511   const state =
512     typeof handle === "object" && handle !== null
513       ? states.get(handle as object)
514       : undefined;
515   if (state === undefined || !state.active) {
516     throw new TypeError("Expected a live Pibbl physics 2D body handle.");
517   }
518   return state;
519 }
520 
521 function materializeSignal<Field extends PhysicsBodyHandleField2D>(
522   state: PhysicsBodyHandleState2D,
523   field: Field,
524 ): WritableSignal<BodyFieldValue2D[Field]> {
525   let target = state.signals[field] as
526     WritableSignal<BodyFieldValue2D[Field]> | undefined;
527   if (target === undefined) {
528     const binding = state.binding;
529     const current =
530       binding === undefined
531         ? undefined
532         : binding.controller.bodyHandleValues(binding.bodyId);
533     target = signal(current?.[field] ?? INITIAL_VALUES_2D[field], {
534       debugName: `PhysicsBody2D.${field}`,
535     });
536     state.signals[field] = target as never;
537   }
538   return target;
539 }
540 
541 function stageLegacyHandle(
542   batch: PibblInternalSignalBatch,
543   handle: PibblPhysicsBodyHandle2D,
544   values: PhysicsBodyHandleValues2D,
545   writer: object,
546 ): void {
547   for (const field of BODY_FIELDS_2D) {
548     const target = handle[field] as WritableSignal<
549       BodyFieldValue2D[typeof field]
550     >;
551     batch.stage(target, values[field], writer);
552   }
553 }
554 
555 const ZERO_VECTOR_2D = Object.freeze([0, 0] as const);
556 
557 const INITIAL_VALUES_2D: BodyFieldValue2D = Object.freeze({
558   position: ZERO_VECTOR_2D,
559   rotationDegrees: 0,
560   linearVelocity: ZERO_VECTOR_2D,
561   angularVelocityDegreesPerSecond: 0,
562   sleeping: false,
563   enabled: false,
564   generation: 0,
565 });
566 
567 const BODY_FIELDS_2D = Object.freeze([
568   "position",
569   "rotationDegrees",
570   "linearVelocity",
571   "angularVelocityDegreesPerSecond",
572   "sleeping",
573   "enabled",
574   "generation",
575 ] as const);
576 

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