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