Skip to content

packages/core/src/features/physics/lib/2d/shapes.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 type {
2   PibblPhysicsAabb2D,
3   PibblPhysicsMassProperties2D,
4   PibblPhysicsPose2D,
5   PibblPhysicsShape2D,
6   PibblPhysicsTransform2D,
7   PibblPhysicsVector2,
8 } from '../../2d-geometry.js';
9 import {
10   copyFiniteVector2,
11   requireFiniteNumber,
12   requireNonnegativeNumber,
13   requirePositiveNumber,
14 } from '../shared/validation.js';
15 import {
16   CURVE_FLATTENING_MAX_DEPTH,
17   CURVE_FLATTENING_TOLERANCE,
18   IDENTITY_POSE_2D,
19   canonicalizeContour2D,
20   cleanZero,
21   composePose2D,
22   copyPose2D,
23   freezeBounds2D,
24   polygonMassData2D,
25   transformPointInto,
26   triangulateContour2D,
27   type InternalPose2D,
28 } from './math.js';
29 import { DEGREES_TO_RADIANS, GEOMETRY_EPSILON } from './numeric-policy.js';
30 import {
31   createShape2D,
32   requireShapeRecord2D,
33   type CapsuleLeaf2D,
34   type ConvexLeaf2D,
35   type PolygonLeaf2D,
36 } from './shape-records.js';
37 
38 const FULL_TURN_RADIANS = Math.PI * 2;
39 
40 /**
41  * Creates an immutable circle collision shape without initializing a physics world.
42  *
43  * @param input - Circle radius and optional center. See {@link PibblPhysicsVector2}.
44  * @returns An immutable circle collision shape. See {@link PibblPhysicsShape2D}.
45  *
46  * @see {@link PibblPhysicsVector2}
47  * @see {@link PibblPhysicsShape2D}
48  */
49 export function circleShape2D(
50   input: Readonly<{ radius: number; center?: PibblPhysicsVector2 }>,
51 ): PibblPhysicsShape2D {
52   const radius = requirePositiveNumber(input.radius, 'circle.radius');
53   const center = ownedVector(input.center ?? [0, 0], 'circle.center');
54   const area = Math.PI * radius * radius;
55   return createShapeFromLeaves(
56     [Object.freeze({ kind: 'circle', center, radius, canonicalLeafIndex: 0 })],
57     area,
58     center[0]!,
59     center[1]!,
60     area * radius * radius / 2,
61   );
62 }
63 
64 /**
65  * Creates an immutable ellipse collision shape without initializing a physics world.
66  *
67  * @param input - Ellipse radii, optional center, and rotation in degrees. See
68  * {@link PibblPhysicsVector2} .
69  * @returns An immutable ellipse collision shape. See {@link PibblPhysicsShape2D}.
70  *
71  * @see {@link PibblPhysicsVector2}
72  * @see {@link PibblPhysicsShape2D}
73  */
74 export function ellipseShape2D(
75   input: Readonly<{
76     radiusX: number;
77     radiusY: number;
78     center?: PibblPhysicsVector2;
79     rotationDegrees?: number;
80   }>,
81 ): PibblPhysicsShape2D {
82   const radiusX = requirePositiveNumber(input.radiusX, 'ellipse.radiusX');
83   const radiusY = requirePositiveNumber(input.radiusY, 'ellipse.radiusY');
84   const center = ownedVector(input.center ?? [0, 0], 'ellipse.center');
85   const rotationRadians =
86     requireFiniteNumber(input.rotationDegrees ?? 0, 'ellipse.rotationDegrees') *
87     DEGREES_TO_RADIANS;
88   const area = Math.PI * radiusX * radiusY;
89   return createShapeFromLeaves(
90     [Object.freeze({ kind: 'ellipse', center, radiusX, radiusY, rotationRadians, canonicalLeafIndex: 0 })],
91     area,
92     center[0]!,
93     center[1]!,
94     area * (radiusX * radiusX + radiusY * radiusY) / 4,
95   );
96 }
97 
98 /**
99  * Creates an immutable box collision shape without initializing a physics world.
100  *
101  * @param input - Box width, height, and optional center. See {@link PibblPhysicsVector2}.
102  * @returns An immutable box collision shape. See {@link PibblPhysicsShape2D}.
103  *
104  * @see {@link PibblPhysicsVector2}
105  * @see {@link PibblPhysicsShape2D}
106  */
107 export function boxShape2D(
108   input: Readonly<{
109     width: number;
110     height: number;
111     center?: PibblPhysicsVector2;
112   }>,
113 ): PibblPhysicsShape2D {
114   const width = requirePositiveNumber(input.width, 'box.width');
115   const height = requirePositiveNumber(input.height, 'box.height');
116   const center = ownedVector(input.center ?? [0, 0], 'box.center');
117   const area = width * height;
118   return createShapeFromLeaves(
119     [Object.freeze({ kind: 'box', center, halfWidth: width / 2, halfHeight: height / 2, canonicalLeafIndex: 0 })],
120     area,
121     center[0]!,
122     center[1]!,
123     area * (width * width + height * height) / 12,
124   );
125 }
126 
127 /**
128  * Creates an immutable rounded box collision shape without initializing a physics world.
129  *
130  * @param input - Box dimensions, center, and uniform or per-corner radii. See
131  * {@link PibblPhysicsVector2} .
132  * @returns An immutable rounded-box collision shape. See {@link PibblPhysicsShape2D}.
133  *
134  * @see {@link PibblPhysicsVector2}
135  * @see {@link PibblPhysicsShape2D}
136  */
137 export function roundedBoxShape2D(
138   input: Readonly<{
139     width: number;
140     height: number;
141     radius:
142       | number
143       | readonly [
144           topLeft: number,
145           topRight: number,
146           bottomRight: number,
147           bottomLeft: number,
148         ];
149     center?: PibblPhysicsVector2;
150   }>,
151 ): PibblPhysicsShape2D {
152   const width = requirePositiveNumber(input.width, 'roundedBox.width');
153   const height = requirePositiveNumber(input.height, 'roundedBox.height');
154   const center = ownedVector(input.center ?? [0, 0], 'roundedBox.center');
155   const sourceRadii = typeof input.radius === 'number'
156     ? [input.radius, input.radius, input.radius, input.radius] as const
157     : input.radius;
158   const radii = new Float64Array(4);
159   const maximum = Math.min(width, height) / 2;
160   for (let index = 0; index < radii.length; index += 1) {
161     const radius = requireNonnegativeNumber(
162       sourceRadii[index]!,
163       `roundedBox.radius[${index}]`,
164     );
165     if (radius > maximum) {
166       throw new RangeError(
167         `roundedBox.radius[${index}] must not exceed ${String(maximum)}; received ${String(radius)}.`,
168       );
169     }
170     radii[index] = radius;
171   }
172   const contour = roundedBoxContour(width / 2, height / 2, radii, center);
173   const mass = polygonMassData2D(contour);
174   return createShapeFromLeaves(
175     [Object.freeze({
176       kind: 'rounded-box',
177       center,
178       halfWidth: width / 2,
179       halfHeight: height / 2,
180       radii,
181       canonicalLeafIndex: 0,
182     })],
183     mass.area,
184     mass.centroidX,
185     mass.centroidY,
186     mass.unitInertia,
187   );
188 }
189 
190 /**
191  * Creates an immutable capsule collision shape without initializing a physics world.
192  *
193  * @param input - Capsule centerline endpoints and radius. See {@link PibblPhysicsVector2}.
194  * @returns An immutable capsule collision shape. See {@link PibblPhysicsShape2D}.
195  *
196  * @see {@link PibblPhysicsVector2}
197  * @see {@link PibblPhysicsShape2D}
198  */
199 export function capsuleShape2D(
200   input: Readonly<{
201     start: PibblPhysicsVector2;
202     end: PibblPhysicsVector2;
203     radius: number;
204   }>,
205 ): PibblPhysicsShape2D {
206   const start = ownedVector(input.start, 'capsule.start');
207   const end = ownedVector(input.end, 'capsule.end');
208   const radius = requirePositiveNumber(input.radius, 'capsule.radius');
209   const leaf: CapsuleLeaf2D = Object.freeze({
210     kind: 'capsule', start, end, radius, canonicalLeafIndex: 0,
211   });
212   const mass = capsuleMassData(start, end, radius);
213   return createShapeFromLeaves(
214     [leaf],
215     mass.area,
216     mass.centroidX,
217     mass.centroidY,
218     mass.unitInertia,
219   );
220 }
221 
222 /**
223  * Creates an immutable arc collision shape without initializing a physics world.
224  *
225  * @param input - Arc center, radius, angular endpoints in radians, sweep direction, and thickness.
226  * See {@link PibblPhysicsVector2} .
227  * @returns An immutable thick-arc collision shape. See {@link PibblPhysicsShape2D}.
228  *
229  * @see {@link PibblPhysicsVector2}
230  * @see {@link PibblPhysicsShape2D}
231  */
232 export function arcShape2D(
233   input: Readonly<{
234     center?: PibblPhysicsVector2;
235     radius: number;
236     startAngleRadians: number;
237     endAngleRadians: number;
238     counterclockwise?: boolean;
239     thickness: number;
240   }>,
241 ): PibblPhysicsShape2D {
242   const center = ownedVector(input.center ?? [0, 0], 'arc.center');
243   const radius = requirePositiveNumber(input.radius, 'arc.radius');
244   const startAngle = requireFiniteNumber(input.startAngleRadians, 'arc.startAngleRadians');
245   const endAngle = requireFiniteNumber(input.endAngleRadians, 'arc.endAngleRadians');
246   const thickness = requirePositiveNumber(input.thickness, 'arc.thickness');
247   const points = flattenCircularSweep(
248     center[0]!, center[1]!, radius, startAngle, endAngle, input.counterclockwise === true,
249   );
250   return chainFromOwnedPoints(points, false, thickness / 2);
251 }
252 
253 /**
254  * Creates an immutable wedge collision shape without initializing a physics world.
255  *
256  * @param input - Wedge center, radius, angular endpoints in radians, and sweep direction. See
257  * {@link PibblPhysicsVector2} .
258  * @returns An immutable wedge collision shape. See {@link PibblPhysicsShape2D}.
259  *
260  * @see {@link PibblPhysicsVector2}
261  * @see {@link PibblPhysicsShape2D}
262  */
263 export function wedgeShape2D(
264   input: Readonly<{
265     center?: PibblPhysicsVector2;
266     radius: number;
267     startAngleRadians: number;
268     endAngleRadians: number;
269     counterclockwise?: boolean;
270   }>,
271 ): PibblPhysicsShape2D {
272   const center = ownedVector(input.center ?? [0, 0], 'wedge.center');
273   const radius = requirePositiveNumber(input.radius, 'wedge.radius');
274   const startAngle = requireFiniteNumber(input.startAngleRadians, 'wedge.startAngleRadians');
275   const endAngle = requireFiniteNumber(input.endAngleRadians, 'wedge.endAngleRadians');
276   const points = flattenCircularSweep(
277     center[0]!, center[1]!, radius, startAngle, endAngle, input.counterclockwise === true,
278   );
279   const sweep = directedSweep(startAngle, endAngle, input.counterclockwise === true);
280   if (Math.abs(sweep) <= GEOMETRY_EPSILON) {
281     throw new RangeError('wedge angles must describe a nonzero sweep.');
282   }
283   const contourSource: PibblPhysicsVector2[] = Math.abs(sweep) >= FULL_TURN_RADIANS
284     ? points.slice(0, -1).map(asReadonlyVector)
285     : [asReadonlyVector(center), ...points.map(asReadonlyVector)];
286   const contour = canonicalizeContour2D(contourSource, {
287     strictlyConvex: false,
288     path: 'wedge.contour',
289   });
290   const triangles = triangulateContour2D(contour);
291   const leaves = triangles.map<PolygonLeaf2D>((vertices, canonicalLeafIndex) =>
292     Object.freeze({ kind: 'polygon', vertices, canonicalLeafIndex }),
293   );
294   const mass = aggregateLeafMass(leaves);
295   return createShapeFromLeaves(
296     leaves,
297     mass.area,
298     mass.centroidX,
299     mass.centroidY,
300     mass.unitInertia,
301   );
302 }
303 
304 /**
305  * Creates an immutable segment collision shape without initializing a physics world.
306  *
307  * @param input - Segment endpoints. See {@link PibblPhysicsVector2}.
308  * @returns An immutable line-segment collision shape. See {@link PibblPhysicsShape2D}.
309  *
310  * @see {@link PibblPhysicsVector2}
311  * @see {@link PibblPhysicsShape2D}
312  */
313 export function segmentShape2D(
314   input: Readonly<{ start: PibblPhysicsVector2; end: PibblPhysicsVector2 }>,
315 ): PibblPhysicsShape2D {
316   const start = ownedVector(input.start, 'segment.start');
317   const end = ownedVector(input.end, 'segment.end');
318   return createShapeFromLeaves(
319     [Object.freeze({ kind: 'segment', start, end, canonicalLeafIndex: 0 })],
320     0,
321     (start[0]! + end[0]!) / 2,
322     (start[1]! + end[1]!) / 2,
323     0,
324   );
325 }
326 
327 /**
328  * Creates an immutable chain collision shape without initializing a physics world.
329  *
330  * @param input - Ordered chain points, closure policy, and optional radius. See
331  * {@link PibblPhysicsVector2} .
332  * @returns An immutable chain collision shape. See {@link PibblPhysicsShape2D}.
333  *
334  * @see {@link PibblPhysicsVector2}
335  * @see {@link PibblPhysicsShape2D}
336  */
337 export function chainShape2D(
338   input: Readonly<{
339     points: readonly PibblPhysicsVector2[];
340     closed?: boolean;
341     radius?: number;
342   }>,
343 ): PibblPhysicsShape2D {
344   if (input.points.length < 2) {
345     throw new RangeError('chain.points must contain at least two points.');
346   }
347   const points = input.points.map((point, index) =>
348     ownedVector(point, `chain.points[${index}]`),
349   );
350   const radius = requireNonnegativeNumber(input.radius ?? 0, 'chain.radius');
351   return chainFromOwnedPoints(points, input.closed === true, radius);
352 }
353 
354 /**
355  * Creates an immutable polygon collision shape without initializing a physics world.
356  *
357  * @param input - Ordered polygon vertices. See {@link PibblPhysicsVector2}.
358  * @returns An immutable polygon collision shape. See {@link PibblPhysicsShape2D}.
359  *
360  * @see {@link PibblPhysicsVector2}
361  * @see {@link PibblPhysicsShape2D}
362  */
363 export function polygonShape2D(
364   input: Readonly<{ points: readonly PibblPhysicsVector2[] }>,
365 ): PibblPhysicsShape2D {
366   const vertices = canonicalizeContour2D(input.points, {
367     strictlyConvex: true,
368     path: 'polygon.points',
369   });
370   const mass = polygonMassData2D(vertices);
371   return createShapeFromLeaves(
372     [Object.freeze({ kind: 'polygon', vertices, canonicalLeafIndex: 0 })],
373     mass.area,
374     mass.centroidX,
375     mass.centroidY,
376     mass.unitInertia,
377   );
378 }
379 
380 /**
381  * Computes axis-aligned bounds for an immutable shape at an optional pose.
382  *
383  * @param shape - Shape whose bounds are needed. See {@link PibblPhysicsShape2D}.
384  * @param pose - Optional pose at which to evaluate the shape. See {@link PibblPhysicsPose2D}.
385  * @returns Axis-aligned bounds of the posed shape. See {@link PibblPhysicsAabb2D}.
386  *
387  * @see {@link PibblPhysicsShape2D}
388  * @see {@link PibblPhysicsPose2D}
389  * @see {@link PibblPhysicsAabb2D}
390  */
391 export function boundsOfShape2D(
392   shape: PibblPhysicsShape2D,
393   pose?: PibblPhysicsPose2D,
394 ): PibblPhysicsAabb2D {
395   const record = requireShapeRecord2D(shape);
396   const outerPose = copyPose2D(pose, 'pose');
397   return boundsForLeaves(record.leaves, composePose2D(outerPose, record.localPose));
398 }
399 
400 /**
401  * Computes shape area, centroid, mass, and rotational inertia at the requested density.
402  *
403  * @param shape - Shape whose mass properties are needed. See {@link PibblPhysicsShape2D}.
404  * @param options - Optional material density.
405  * @returns Mass, center of mass, and inertia for the shape. See {@link PibblPhysicsMassProperties2D}
406  * .
407  *
408  * @see {@link PibblPhysicsShape2D}
409  * @see {@link PibblPhysicsMassProperties2D}
410  */
411 export function massProperties2D(
412   shape: PibblPhysicsShape2D,
413   options?: Readonly<{ density?: number }>,
414 ): PibblPhysicsMassProperties2D {
415   const record = requireShapeRecord2D(shape);
416   const density = requirePositiveNumber(options?.density ?? 1, 'options.density');
417   const transformedCentroid = new Float64Array(2);
418   transformPointInto(
419     transformedCentroid,
420     0,
421     record.centroidX,
422     record.centroidY,
423     record.localPose,
424   );
425   return Object.freeze({
426     area: cleanZero(record.area),
427     centroid: Object.freeze([
428       cleanZero(transformedCentroid[0]!),
429       cleanZero(transformedCentroid[1]!),
430     ] as const),
431     mass: cleanZero(record.area * density),
432     rotationalInertia: cleanZero(record.unitInertia * density),
433   });
434 }
435 
436 /**
437  * Returns independent shape geometry transformed by a rigid 2D pose.
438  *
439  * @param shape - Source shape; it is not modified. See {@link PibblPhysicsShape2D}.
440  * @param transform - Transform to apply to the shape. See {@link PibblPhysicsTransform2D}.
441  * @returns A shape containing the transformed geometry. See {@link PibblPhysicsShape2D}.
442  *
443  * @see {@link PibblPhysicsShape2D}
444  * @see {@link PibblPhysicsTransform2D}
445  */
446 export function transformShape2D(
447   shape: PibblPhysicsShape2D,
448   transform: PibblPhysicsTransform2D,
449 ): PibblPhysicsShape2D {
450   const source = requireShapeRecord2D(shape);
451   const localPose = composePose2D(copyPose2D(transform, 'transform'), source.localPose);
452   const localBounds = mutableBoundsForLeaves(source.leaves, localPose);
453   return createShape2D({
454     leaves: source.leaves,
455     localPose,
456     localBounds,
457     area: source.area,
458     centroidX: source.centroidX,
459     centroidY: source.centroidY,
460     unitInertia: source.unitInertia,
461   });
462 }
463 
464 function createShapeFromLeaves(
465   leaves: readonly ConvexLeaf2D[],
466   area: number,
467   centroidX: number,
468   centroidY: number,
469   unitInertia: number,
470 ): PibblPhysicsShape2D {
471   return createShape2D({
472     leaves,
473     localBounds: mutableBoundsForLeaves(leaves, IDENTITY_POSE_2D),
474     area,
475     centroidX,
476     centroidY,
477     unitInertia: Math.max(0, unitInertia),
478   });
479 }
480 
481 function chainFromOwnedPoints(
482   points: readonly Float64Array[],
483   closed: boolean,
484   radius: number,
485 ): PibblPhysicsShape2D {
486   const segmentCount = points.length === 1 ? 0 : points.length - 1 + (closed ? 1 : 0);
487   const leaves: ConvexLeaf2D[] = [];
488   for (let index = 0; index < segmentCount; index += 1) {
489     const start = points[index]!;
490     const end = points[(index + 1) % points.length]!;
491     leaves.push(Object.freeze(radius === 0
492       ? { kind: 'segment', start, end, canonicalLeafIndex: index }
493       : { kind: 'capsule', start, end, radius, canonicalLeafIndex: index }));
494   }
495   if (leaves.length === 0) {
496     const point = points[0]!;
497     const area = Math.PI * radius * radius;
498     leaves.push(Object.freeze({
499       kind: 'circle', center: point, radius, canonicalLeafIndex: 0,
500     }));
501     return createShapeFromLeaves(
502       leaves,
503       area,
504       point[0]!,
505       point[1]!,
506       area * radius * radius / 2,
507     );
508   }
509   if (radius > 0) {
510     const mass = aggregateLeafMass(leaves);
511     return createShapeFromLeaves(
512       leaves,
513       mass.area,
514       mass.centroidX,
515       mass.centroidY,
516       mass.unitInertia,
517     );
518   }
519   const centroid = lineCentroid(points, closed);
520   return createShapeFromLeaves(leaves, 0, centroid[0], centroid[1], 0);
521 }
522 
523 function boundsForLeaves(
524   leaves: readonly ConvexLeaf2D[],
525   pose: InternalPose2D,
526 ): PibblPhysicsAabb2D {
527   const bounds = mutableBoundsForLeaves(leaves, pose);
528   return freezeBounds2D(bounds[0]!, bounds[1]!, bounds[2]!, bounds[3]!);
529 }
530 
531 function mutableBoundsForLeaves(
532   leaves: readonly ConvexLeaf2D[],
533   pose: InternalPose2D,
534 ): Float64Array {
535   const result = new Float64Array([
536     Number.POSITIVE_INFINITY,
537     Number.POSITIVE_INFINITY,
538     Number.NEGATIVE_INFINITY,
539     Number.NEGATIVE_INFINITY,
540   ]);
541   const point = new Float64Array(2);
542   const include = (x: number, y: number, radius = 0): void => {
543     transformPointInto(point, 0, x, y, pose);
544     result[0] = Math.min(result[0]!, point[0]! - radius);
545     result[1] = Math.min(result[1]!, point[1]! - radius);
546     result[2] = Math.max(result[2]!, point[0]! + radius);
547     result[3] = Math.max(result[3]!, point[1]! + radius);
548   };
549   for (const leaf of leaves) {
550     switch (leaf.kind) {
551       case 'circle':
552         include(leaf.center[0]!, leaf.center[1]!, leaf.radius);
553         break;
554       case 'ellipse': {
555         transformPointInto(point, 0, leaf.center[0]!, leaf.center[1]!, pose);
556         const angle = pose.rotationRadians + leaf.rotationRadians;
557         const cosine = Math.cos(angle);
558         const sine = Math.sin(angle);
559         const extentX = Math.hypot(leaf.radiusX * cosine, leaf.radiusY * sine);
560         const extentY = Math.hypot(leaf.radiusX * sine, leaf.radiusY * cosine);
561         result[0] = Math.min(result[0]!, point[0]! - extentX);
562         result[1] = Math.min(result[1]!, point[1]! - extentY);
563         result[2] = Math.max(result[2]!, point[0]! + extentX);
564         result[3] = Math.max(result[3]!, point[1]! + extentY);
565         break;
566       }
567       case 'box':
568         includeBoxCorners(leaf.center, leaf.halfWidth, leaf.halfHeight, pose, result, point);
569         break;
570       case 'rounded-box':
571         includeRoundedBoxBounds(leaf, pose, result, point);
572         break;
573       case 'capsule':
574         include(leaf.start[0]!, leaf.start[1]!, leaf.radius);
575         include(leaf.end[0]!, leaf.end[1]!, leaf.radius);
576         break;
577       case 'segment':
578         include(leaf.start[0]!, leaf.start[1]!);
579         include(leaf.end[0]!, leaf.end[1]!);
580         break;
581       case 'polygon':
582         for (let offset = 0; offset < leaf.vertices.length; offset += 2) {
583           include(leaf.vertices[offset]!, leaf.vertices[offset + 1]!);
584         }
585         break;
586     }
587   }
588   return result;
589 }
590 
591 function includeBoxCorners(
592   center: Float64Array,
593   halfWidth: number,
594   halfHeight: number,
595   pose: InternalPose2D,
596   bounds: Float64Array,
597   point: Float64Array,
598 ): void {
599   for (const xSign of [-1, 1]) {
600     for (const ySign of [-1, 1]) {
601       transformPointInto(
602         point,
603         0,
604         center[0]! + xSign * halfWidth,
605         center[1]! + ySign * halfHeight,
606         pose,
607       );
608       bounds[0] = Math.min(bounds[0]!, point[0]!);
609       bounds[1] = Math.min(bounds[1]!, point[1]!);
610       bounds[2] = Math.max(bounds[2]!, point[0]!);
611       bounds[3] = Math.max(bounds[3]!, point[1]!);
612     }
613   }
614 }
615 
616 function includeRoundedBoxBounds(
617   leaf: Extract<ConvexLeaf2D, { kind: 'rounded-box' }>,
618   pose: InternalPose2D,
619   bounds: Float64Array,
620   point: Float64Array,
621 ): void {
622   const xSigns = [-1, 1, 1, -1] as const;
623   const ySigns = [-1, -1, 1, 1] as const;
624   for (let index = 0; index < 4; index += 1) {
625     const radius = leaf.radii[index]!;
626     transformPointInto(
627       point,
628       0,
629       leaf.center[0]! + xSigns[index]! * (leaf.halfWidth - radius),
630       leaf.center[1]! + ySigns[index]! * (leaf.halfHeight - radius),
631       pose,
632     );
633     bounds[0] = Math.min(bounds[0]!, point[0]! - radius);
634     bounds[1] = Math.min(bounds[1]!, point[1]! - radius);
635     bounds[2] = Math.max(bounds[2]!, point[0]! + radius);
636     bounds[3] = Math.max(bounds[3]!, point[1]! + radius);
637   }
638 }
639 
640 function aggregateLeafMass(leaves: readonly ConvexLeaf2D[]): Readonly<{
641   area: number;
642   centroidX: number;
643   centroidY: number;
644   unitInertia: number;
645 }> {
646   const entries = leaves.map(leafMassData);
647   const area = entries.reduce((sum, entry) => sum + entry.area, 0);
648   if (area === 0) {
649     return Object.freeze({ area: 0, centroidX: 0, centroidY: 0, unitInertia: 0 });
650   }
651   const centroidX = entries.reduce((sum, entry) => sum + entry.area * entry.centroidX, 0) / area;
652   const centroidY = entries.reduce((sum, entry) => sum + entry.area * entry.centroidY, 0) / area;
653   const unitInertia = entries.reduce((sum, entry) => {
654     const dx = entry.centroidX - centroidX;
655     const dy = entry.centroidY - centroidY;
656     return sum + entry.unitInertia + entry.area * (dx * dx + dy * dy);
657   }, 0);
658   return Object.freeze({ area, centroidX, centroidY, unitInertia });
659 }
660 
661 function leafMassData(leaf: ConvexLeaf2D): Readonly<{
662   area: number;
663   centroidX: number;
664   centroidY: number;
665   unitInertia: number;
666 }> {
667   switch (leaf.kind) {
668     case 'circle': {
669       const area = Math.PI * leaf.radius * leaf.radius;
670       return { area, centroidX: leaf.center[0]!, centroidY: leaf.center[1]!, unitInertia: area * leaf.radius * leaf.radius / 2 };
671     }
672     case 'ellipse': {
673       const area = Math.PI * leaf.radiusX * leaf.radiusY;
674       return { area, centroidX: leaf.center[0]!, centroidY: leaf.center[1]!, unitInertia: area * (leaf.radiusX * leaf.radiusX + leaf.radiusY * leaf.radiusY) / 4 };
675     }
676     case 'box': {
677       const width = leaf.halfWidth * 2;
678       const height = leaf.halfHeight * 2;
679       const area = width * height;
680       return { area, centroidX: leaf.center[0]!, centroidY: leaf.center[1]!, unitInertia: area * (width * width + height * height) / 12 };
681     }
682     case 'capsule':
683       return capsuleMassData(leaf.start, leaf.end, leaf.radius);
684     case 'polygon':
685       return polygonMassData2D(leaf.vertices);
686     case 'segment':
687       return { area: 0, centroidX: (leaf.start[0]! + leaf.end[0]!) / 2, centroidY: (leaf.start[1]! + leaf.end[1]!) / 2, unitInertia: 0 };
688     case 'rounded-box': {
689       const contour = roundedBoxContour(leaf.halfWidth, leaf.halfHeight, leaf.radii, leaf.center);
690       return polygonMassData2D(contour);
691     }
692   }
693 }
694 
695 function capsuleMassData(
696   start: Float64Array,
697   end: Float64Array,
698   radius: number,
699 ): Readonly<{ area: number; centroidX: number; centroidY: number; unitInertia: number }> {
700   const dx = end[0]! - start[0]!;
701   const dy = end[1]! - start[1]!;
702   const length = Math.hypot(dx, dy);
703   const rectangleArea = 2 * radius * length;
704   const circleArea = Math.PI * radius * radius;
705   const area = rectangleArea + circleArea;
706   const rectangleInertia = rectangleArea * (length * length + 4 * radius * radius) / 12;
707   const capsInertia =
708     Math.PI * radius ** 4 / 2 +
709     circleArea * length * length / 4 +
710     4 * length * radius ** 3 / 3;
711   return Object.freeze({
712     area,
713     centroidX: (start[0]! + end[0]!) / 2,
714     centroidY: (start[1]! + end[1]!) / 2,
715     unitInertia: rectangleInertia + capsInertia,
716   });
717 }
718 
719 function lineCentroid(
720   points: readonly Float64Array[],
721   closed: boolean,
722 ): readonly [number, number] {
723   const segmentCount = points.length - 1 + (closed ? 1 : 0);
724   let totalLength = 0;
725   let weightedX = 0;
726   let weightedY = 0;
727   for (let index = 0; index < segmentCount; index += 1) {
728     const start = points[index]!;
729     const end = points[(index + 1) % points.length]!;
730     const length = Math.hypot(end[0]! - start[0]!, end[1]! - start[1]!);
731     totalLength += length;
732     weightedX += length * (start[0]! + end[0]!) / 2;
733     weightedY += length * (start[1]! + end[1]!) / 2;
734   }
735   if (totalLength > 0) {
736     return [weightedX / totalLength, weightedY / totalLength];
737   }
738   return [points.reduce((sum, point) => sum + point[0]!, 0) / points.length, points.reduce((sum, point) => sum + point[1]!, 0) / points.length];
739 }
740 
741 function roundedBoxContour(
742   halfWidth: number,
743   halfHeight: number,
744   radii: Float64Array,
745   center: Float64Array,
746 ): Float64Array {
747   const points: Float64Array[] = [];
748   const cornerData = [
749     [halfWidth - radii[1]!, -halfHeight + radii[1]!, -Math.PI / 2, 0, radii[1]!],
750     [halfWidth - radii[2]!, halfHeight - radii[2]!, 0, Math.PI / 2, radii[2]!],
751     [-halfWidth + radii[3]!, halfHeight - radii[3]!, Math.PI / 2, Math.PI, radii[3]!],
752     [-halfWidth + radii[0]!, -halfHeight + radii[0]!, Math.PI, 3 * Math.PI / 2, radii[0]!],
753   ] as const;
754   for (const [x, y, start, end, radius] of cornerData) {
755     const cornerPoints = radius === 0
756       ? [new Float64Array([center[0]! + x, center[1]! + y])]
757       : flattenCircularSweep(center[0]! + x, center[1]! + y, radius, start, end, false);
758     points.push(...cornerPoints);
759   }
760   return canonicalizeContour2D(points.map(asReadonlyVector), {
761     strictlyConvex: false,
762     path: 'roundedBox.contour',
763   });
764 }
765 
766 function flattenCircularSweep(
767   centerX: number,
768   centerY: number,
769   radius: number,
770   startAngle: number,
771   endAngle: number,
772   counterclockwise: boolean,
773 ): Float64Array[] {
774   const sweep = directedSweep(startAngle, endAngle, counterclockwise);
775   const points = [pointOnCircle(centerX, centerY, radius, startAngle)];
776   appendFlattenedArc(points, centerX, centerY, radius, startAngle, startAngle + sweep, 0);
777   if (Math.abs(sweep) >= FULL_TURN_RADIANS) {
778     points[points.length - 1] = new Float64Array(points[0]!);
779   }
780   return points;
781 }
782 
783 function appendFlattenedArc(
784   output: Float64Array[],
785   centerX: number,
786   centerY: number,
787   radius: number,
788   startAngle: number,
789   endAngle: number,
790   depth: number,
791 ): void {
792   const middleAngle = (startAngle + endAngle) / 2;
793   const halfSweep = Math.abs(endAngle - startAngle) / 2;
794   const chordError = radius * (1 - Math.cos(halfSweep));
795   if (
796     chordError <= CURVE_FLATTENING_TOLERANCE ||
797     depth >= CURVE_FLATTENING_MAX_DEPTH
798   ) {
799     output.push(pointOnCircle(centerX, centerY, radius, endAngle));
800     return;
801   }
802   appendFlattenedArc(output, centerX, centerY, radius, startAngle, middleAngle, depth + 1);
803   appendFlattenedArc(output, centerX, centerY, radius, middleAngle, endAngle, depth + 1);
804 }
805 
806 function directedSweep(
807   startAngle: number,
808   endAngle: number,
809   counterclockwise: boolean,
810 ): number {
811   const raw = endAngle - startAngle;
812   if (Math.abs(raw) >= FULL_TURN_RADIANS) {
813     return counterclockwise ? -FULL_TURN_RADIANS : FULL_TURN_RADIANS;
814   }
815   if (counterclockwise) {
816     const magnitude = ((-raw % FULL_TURN_RADIANS) + FULL_TURN_RADIANS) % FULL_TURN_RADIANS;
817     return -magnitude;
818   }
819   return ((raw % FULL_TURN_RADIANS) + FULL_TURN_RADIANS) % FULL_TURN_RADIANS;
820 }
821 
822 function pointOnCircle(
823   centerX: number,
824   centerY: number,
825   radius: number,
826   angle: number,
827 ): Float64Array {
828   return new Float64Array([
829     centerX + radius * Math.cos(angle),
830     centerY + radius * Math.sin(angle),
831   ]);
832 }
833 
834 function ownedVector(value: PibblPhysicsVector2, path: string): Float64Array {
835   const copied = copyFiniteVector2(value, path);
836   return new Float64Array(copied);
837 }
838 
839 function asReadonlyVector(value: Float64Array): PibblPhysicsVector2 {
840   return [value[0]!, value[1]!] as const;
841 }
842 

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