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