Skip to content

packages/core/src/features/physics/lib/2d/canvas/components.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 { resolveTexturePaint } from '@pibbl/core/internal';
2 import type { FillStyle, StrokeStyle } from '@pibbl/core';
3 import {
4   useEventTarget,
5   definePrimitive,
6   type RenderingContext2D,
7 } from '@pibbl/core';
8 import type {
9   Capsule2DProps,
10   Capsule2DStyle,
11   Chain2DProps,
12   Chain2DStyle,
13   PhysicsShape2DProps,
14   PhysicsShape2DStyle,
15   Segment2DProps,
16   Segment2DStyle,
17 } from '../types.js';
18 import type { PibblPhysicsShape2D, PibblPhysicsVector2 } from '../../../2d-geometry.js';
19 import { boundsOfShape2D, capsuleShape2D, chainShape2D, segmentShape2D } from '../shapes.js';
20 import { requireShapeRecord2D, type ConvexLeaf2D } from '../shape-records.js';
21 import { installCustomShapeResolver2D } from './inference.js';
22 
23 type ResolvedPhysicsShapeStyle2D = Readonly<PhysicsShape2DStyle>;
24 type ResolvedCapsuleStyle2D = Omit<Readonly<Capsule2DStyle>, 'start' | 'end'> & {
25   readonly start: PibblPhysicsVector2;
26   readonly end: PibblPhysicsVector2;
27 };
28 type ResolvedSegmentStyle2D = Omit<Readonly<Segment2DStyle>, 'start' | 'end'> & {
29   readonly start: PibblPhysicsVector2;
30   readonly end: PibblPhysicsVector2;
31 };
32 type ResolvedChainStyle2D = Omit<Readonly<Chain2DStyle>, 'points'> & {
33   readonly points: readonly PibblPhysicsVector2[];
34 };
35 
36 function renderPhysicsShape2D(
37   props: PhysicsShape2DProps,
38   style: ResolvedPhysicsShapeStyle2D,
39   context: RenderingContext2D,
40 ): void {
41   renderShape(props, style, style.shape, context);
42 }
43 
44 function renderCapsule2D(
45   props: Capsule2DProps,
46   style: Readonly<ResolvedCapsuleStyle2D>,
47   context: RenderingContext2D,
48 ): void {
49   renderShape(props, style, capsuleShape2D(style), context);
50 }
51 
52 function renderSegment2D(
53   props: Segment2DProps,
54   style: Readonly<ResolvedSegmentStyle2D>,
55   context: RenderingContext2D,
56 ): void {
57   renderShape(props, style, segmentShape2D(style), context);
58 }
59 
60 function renderChain2D(
61   props: Chain2DProps,
62   style: Readonly<ResolvedChainStyle2D>,
63   context: RenderingContext2D,
64 ): void {
65   renderShape(props, style, chainShape2D(style), context);
66 }
67 
68 /**
69  * Draws an immutable physics shape as a Canvas primitive with optional body or collider
70  * attachment.
71  *
72  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
73  * props and style types.
74  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
75  *
76  * @see {@link PhysicsShape2DProps}
77  * @see {@link PhysicsShape2DStyle}
78  */
79 export const PhysicsShape2D = definePrimitive<
80   PhysicsShape2DProps,
81   PhysicsShape2DStyle,
82   PhysicsShape2DStyle,
83   PhysicsShape2DStyle
84 >(renderPhysicsShape2D, { resolveStyle: resolvePhysicsShapeStyle });
85 
86 /**
87  * Draws a capsule between two endpoints with optional physics attachment.
88  *
89  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
90  * props and style types.
91  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
92  *
93  * @see {@link Capsule2DProps}
94  * @see {@link Capsule2DStyle}
95  */
96 export const Capsule2D = definePrimitive<
97   Capsule2DProps,
98   Capsule2DStyle,
99   Capsule2DStyle,
100   ResolvedCapsuleStyle2D
101 >(renderCapsule2D, { resolveStyle: style => ({
102   ...stripLayout(style),
103   start: copyPoint(style.start, 'Capsule2D.start'),
104   end: copyPoint(style.end, 'Capsule2D.end'),
105   radius: positive(style.radius, 'Capsule2D.radius'),
106 }) });
107 
108 /**
109  * Draws a line segment with optional physics attachment.
110  *
111  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
112  * props and style types.
113  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
114  *
115  * @see {@link Segment2DProps}
116  * @see {@link Segment2DStyle}
117  */
118 export const Segment2D = definePrimitive<
119   Segment2DProps,
120   Segment2DStyle,
121   Segment2DStyle,
122   ResolvedSegmentStyle2D
123 >(renderSegment2D, { resolveStyle: style => ({
124   ...stripLayout(style),
125   start: copyPoint(style.start, 'Segment2D.start'),
126   end: copyPoint(style.end, 'Segment2D.end'),
127 }) });
128 
129 /**
130  * Draws an open or closed point chain with optional physics attachment.
131  *
132  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
133  * props and style types.
134  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
135  *
136  * @see {@link Chain2DProps}
137  * @see {@link Chain2DStyle}
138  */
139 export const Chain2D = definePrimitive<
140   Chain2DProps,
141   Chain2DStyle,
142   Chain2DStyle,
143   ResolvedChainStyle2D
144 >(renderChain2D, { resolveStyle: style => ({
145   ...stripLayout(style),
146   points: Object.freeze(style.points.map((point, index) => copyPoint(point, `Chain2D.points[${index}]`))),
147   closed: style.closed ?? false,
148   radius: nonnegative(style.radius ?? 0, 'Chain2D.radius'),
149 }) });
150 
151 installCustomShapeResolver2D(PhysicsShape2D, style => requireShape(style.shape, 'PhysicsShape2D.shape'));
152 installCustomShapeResolver2D(Capsule2D, style => capsuleShape2D({
153   start: copyPoint(style.start, 'Capsule2D.start'),
154   end: copyPoint(style.end, 'Capsule2D.end'),
155   radius: positive(style.radius, 'Capsule2D.radius'),
156 }));
157 installCustomShapeResolver2D(Segment2D, style => segmentShape2D({
158   start: copyPoint(style.start, 'Segment2D.start'),
159   end: copyPoint(style.end, 'Segment2D.end'),
160 }));
161 installCustomShapeResolver2D(Chain2D, style => chainShape2D({
162   points: copyPoints(style.points, 'Chain2D.points'),
163   closed: style.closed === true,
164   radius: nonnegative(style.radius ?? 0, 'Chain2D.radius'),
165 }));
166 
167 function resolvePhysicsShapeStyle(style: Readonly<PhysicsShape2DStyle>): ResolvedPhysicsShapeStyle2D {
168   requireShape(style.shape, 'PhysicsShape2D.shape');
169   return stripLayout(style) as ResolvedPhysicsShapeStyle2D;
170 }
171 
172 function renderShape(
173   props: PhysicsShape2DProps,
174   style: Readonly<{
175     readonly fill?: FillStyle;
176     readonly stroke?: StrokeStyle;
177     readonly strokeWidth?: number;
178     readonly opacity?: number;
179     readonly cursor?: string;
180     readonly lineInteractionWidth?: number;
181   }>,
182   shape: PibblPhysicsShape2D,
183   context: RenderingContext2D,
184 ): void {
185   const record = requireShapeRecord2D(shape);
186   const path = pathForShape(shape);
187   const opacity = style.opacity ?? 1;
188   if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) {
189     throw new RangeError('Physics shape opacity must be finite from zero through one.');
190   }
191   const bounds = boundsOfShape2D(shape);
192   const paintBounds = {x: bounds.min[0], y: bounds.min[1], width: bounds.max[0]-bounds.min[0], height: bounds.max[1]-bounds.min[1]};
193   context.globalAlpha *= opacity;
194   if (style.fill !== undefined) {
195     context.fillStyle = resolveTexturePaint(style.fill, context, paintBounds);
196     context.fill(path);
197   }
198   const strokeWidth = nonnegative(style.strokeWidth ?? 0, 'Physics shape strokeWidth');
199   if (style.stroke !== undefined && strokeWidth > 0) {
200     context.strokeStyle = resolveTexturePaint(style.stroke, context, paintBounds);
201     context.lineWidth = strokeWidth;
202     context.stroke(path);
203   }
204   const interactivePath = props.pointerEvents === 'none' ? new Path2D() : path;
205   useEventTarget(props, {
206     path: interactivePath,
207     fill: style.fill !== undefined && record.area > 0,
208     lineInteractionWidth: style.lineInteractionWidth ?? strokeWidth,
209     cursor: style.cursor,
210     keyboardFocusable: props.keyboardFocusable,
211     keyboardNavigationBounds: props.keyboardNavigationBounds ?? {
212       x: bounds.min[0], y: bounds.min[1],
213       width: bounds.max[0] - bounds.min[0], height: bounds.max[1] - bounds.min[1],
214     },
215   });
216 }
217 
218 function pathForShape(shape: PibblPhysicsShape2D): Path2D {
219   const record = requireShapeRecord2D(shape);
220   const local = new Path2D();
221   for (const leaf of record.leaves) appendLeaf(local, leaf);
222   if (record.localPose.x === 0 && record.localPose.y === 0 && record.localPose.rotationRadians === 0) return local;
223   const result = new Path2D();
224   result.addPath(local, new DOMMatrix()
225     .translate(record.localPose.x, record.localPose.y)
226     .rotate(record.localPose.rotationRadians * 180 / Math.PI));
227   return result;
228 }
229 
230 function appendLeaf(path: Path2D, leaf: ConvexLeaf2D): void {
231   switch (leaf.kind) {
232     case 'circle':
233       path.moveTo(leaf.center[0]! + leaf.radius, leaf.center[1]!);
234       path.arc(leaf.center[0]!, leaf.center[1]!, leaf.radius, 0, Math.PI * 2);
235       path.closePath();
236       return;
237     case 'ellipse':
238       path.moveTo(leaf.center[0]! + leaf.radiusX, leaf.center[1]!);
239       path.ellipse(leaf.center[0]!, leaf.center[1]!, leaf.radiusX, leaf.radiusY, leaf.rotationRadians, 0, Math.PI * 2);
240       path.closePath();
241       return;
242     case 'box':
243       path.rect(leaf.center[0]! - leaf.halfWidth, leaf.center[1]! - leaf.halfHeight, leaf.halfWidth * 2, leaf.halfHeight * 2);
244       return;
245     case 'rounded-box':
246       path.roundRect(
247         leaf.center[0]! - leaf.halfWidth,
248         leaf.center[1]! - leaf.halfHeight,
249         leaf.halfWidth * 2,
250         leaf.halfHeight * 2,
251         Array.from(leaf.radii),
252       );
253       return;
254     case 'capsule':
255       appendCapsule(path, leaf.start, leaf.end, leaf.radius);
256       return;
257     case 'segment':
258       path.moveTo(leaf.start[0]!, leaf.start[1]!);
259       path.lineTo(leaf.end[0]!, leaf.end[1]!);
260       return;
261     case 'polygon':
262       path.moveTo(leaf.vertices[0]!, leaf.vertices[1]!);
263       for (let index = 2; index < leaf.vertices.length; index += 2) path.lineTo(leaf.vertices[index]!, leaf.vertices[index + 1]!);
264       path.closePath();
265   }
266 }
267 
268 function appendCapsule(path: Path2D, start: Float64Array, end: Float64Array, radius: number): void {
269   const dx = end[0]! - start[0]!, dy = end[1]! - start[1]!, length = Math.hypot(dx, dy);
270   if (length === 0) {
271     path.moveTo(start[0]! + radius, start[1]!);
272     path.arc(start[0]!, start[1]!, radius, 0, Math.PI * 2);
273     path.closePath();
274     return;
275   }
276   const normal = Math.atan2(dy, dx) - Math.PI / 2;
277   path.moveTo(start[0]! + Math.cos(normal) * radius, start[1]! + Math.sin(normal) * radius);
278   path.lineTo(end[0]! + Math.cos(normal) * radius, end[1]! + Math.sin(normal) * radius);
279   path.arc(end[0]!, end[1]!, radius, normal, normal + Math.PI);
280   path.lineTo(start[0]! - Math.cos(normal) * radius, start[1]! - Math.sin(normal) * radius);
281   path.arc(start[0]!, start[1]!, radius, normal + Math.PI, normal + Math.PI * 2);
282   path.closePath();
283 }
284 
285 function stripLayout<T extends Readonly<Record<PropertyKey, unknown>>>(style: T): T {
286   const result = { ...style } as Record<PropertyKey, unknown>;
287   for (const key of [
288     'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'padding', 'overflow',
289     'left', 'top', 'alignSelf', 'justifySelf', 'flexBasis', 'flexGrow', 'flexShrink',
290     'gridColumnStart', 'gridColumnSpan', 'gridRowStart', 'gridRowSpan',
291   ]) delete result[key];
292   return result as T;
293 }
294 
295 function copyPoints(value: unknown, path: string): readonly PibblPhysicsVector2[] {
296   if (!Array.isArray(value)) throw new TypeError(`${path} must be an array.`);
297   return Object.freeze(value.map((point, index) => copyPoint(point, `${path}[${index}]`)));
298 }
299 
300 function copyPoint(value: unknown, path: string): PibblPhysicsVector2 {
301   if (!Array.isArray(value) || value.length !== 2 || value.some(entry => typeof entry !== 'number' || !Number.isFinite(entry))) {
302     throw new TypeError(`${path} must be a finite two-dimensional point.`);
303   }
304   return Object.freeze([value[0] as number, value[1] as number] as const);
305 }
306 
307 function positive(value: unknown, path: string): number {
308   if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new RangeError(`${path} must be finite and greater than zero.`);
309   return value;
310 }
311 
312 function nonnegative(value: unknown, path: string): number {
313   if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new RangeError(`${path} must be finite and nonnegative.`);
314   return value;
315 }
316 
317 function requireShape(value: unknown, path: string): PibblPhysicsShape2D {
318   try {
319     requireShapeRecord2D(value as PibblPhysicsShape2D);
320     return value as PibblPhysicsShape2D;
321   } catch {
322     throw new TypeError(`${path} must be a Pibbl physics 2D shape.`);
323   }
324 }
325 

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