@effect-motion/renderer 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import type { EntityRenderer, EntityRenderers } from "./EntityRenderer.js";
2
+ /**
3
+ * The renderer for every built-in entity kind.
4
+ *
5
+ * @remarks
6
+ * Typed as an exhaustive map, so adding an entity to the core library
7
+ * without a renderer here fails the build rather than silently drawing
8
+ * nothing.
9
+ *
10
+ * To change how a kind is drawn, pass a replacement in the `renderers`
11
+ * option of `Renderer.make` or the Node adapter's `make` — it is merged over
12
+ * this map by tag — rather than editing this manifest.
13
+ */
14
+ export declare const builtinRenderers: EntityRenderers;
15
+ /**
16
+ * The manifest widened for registry use. The single variance cast lives
17
+ * here: `EntityRenderers` keys each renderer by its exact entity data (so
18
+ * coverage over the tag union stays a compile-time guarantee above), while
19
+ * a registry is keyed by string — contravariant `build`/`update` parameters
20
+ * make that narrowing inexpressible without the cast.
21
+ *
22
+ * ponytail: the ParticleField entry is the D10 escape hatch. It is the ONE
23
+ * key here that is not a member of the entity union, added explicitly rather
24
+ * than by leaving the registry open. Delete it with the particles rewrite.
25
+ */
26
+ /**
27
+ * {@link builtinRenderers} as the loosely-typed map the renderer's registry
28
+ * actually holds, keyed by entity tag.
29
+ *
30
+ * @remarks
31
+ * Same contents, widened: a heterogeneous registry cannot be read at any one
32
+ * entity type. Custom renderers are merged over this.
33
+ */
34
+ export declare const builtinRegistry: Record<string, EntityRenderer<never>>;
@@ -0,0 +1,584 @@
1
+ import { Line2 as FatLine, ThreeRaw as THREE, Tsl } from "@effect-motion/three";
2
+ import { Effect } from "effect";
3
+ import { Color, Runner } from "effect-motion";
4
+ import { renderOpacity, renderSize } from "effect-motion/particles/overLife";
5
+ import * as Images from "./Images.js";
6
+ import * as Text from "./Text.js";
7
+ /**
8
+ * Built-in entity renderers: the retained (`build`/`update`/`dispose`) port
9
+ * of the ThorVG paint manifest. Flat unlit look — MeshBasicNodeMaterial for
10
+ * fills, Line2NodeMaterial with world-unit widths for strokes (stroke width
11
+ * is a world-space dimension foreshortened per-pixel by perspective; this
12
+ * intentionally replaces ThorVG's one-scale-per-segment approximation).
13
+ */
14
+ const CIRCLE_SEGMENTS = 64;
15
+ // shared unit geometries — shapes scale them, so there are no per-frame
16
+ // geometry rebuilds for circles/ellipses/rects at all. Both are CENTERED on
17
+ // their anchor: `position` is the shape's center, like Manim/Motion Canvas.
18
+ const unitCircle = new THREE.CircleGeometry(1, CIRCLE_SEGMENTS);
19
+ const unitPlane = new THREE.PlaneGeometry(1, 1);
20
+ const setColor = (material, color, shapeOpacity) => {
21
+ const { r, g, b, a } = Color.bytes(color);
22
+ material.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
23
+ material.opacity = (a / 255) * shapeOpacity;
24
+ material.transparent = true;
25
+ };
26
+ const buildFillGroup = () => {
27
+ const material = new THREE.MeshBasicNodeMaterial();
28
+ material.side = THREE.DoubleSide;
29
+ const mesh = new THREE.Mesh(unitPlane, material);
30
+ const group = new THREE.Group();
31
+ group.add(mesh);
32
+ const parts = { group, mesh, outline: null };
33
+ const retained = {
34
+ object: group,
35
+ billboard: true,
36
+ dispose: () => {
37
+ material.dispose();
38
+ if (mesh.geometry !== unitPlane && mesh.geometry !== unitCircle) {
39
+ mesh.geometry.dispose();
40
+ }
41
+ if (parts.outline !== null) {
42
+ disposeFatLine(parts.outline);
43
+ }
44
+ },
45
+ };
46
+ group.userData.parts = parts;
47
+ return { retained, parts };
48
+ };
49
+ const partsOf = (retained) => retained.object.userData.parts;
50
+ /** swap the fill mesh geometry, disposing a previous per-instance one */
51
+ const setFillGeometry = (parts, geometry) => {
52
+ const previous = parts.mesh.geometry;
53
+ if (previous !== geometry) {
54
+ if (previous !== unitPlane && previous !== unitCircle) {
55
+ previous.dispose();
56
+ }
57
+ parts.mesh.geometry = geometry;
58
+ }
59
+ };
60
+ /** stroke outline from a closed local-space polyline, or none */
61
+ const setOutline = (parts, data, points) => {
62
+ if (data.strokeColor === undefined || points === null) {
63
+ if (parts.outline !== null) {
64
+ parts.group.remove(parts.outline);
65
+ disposeFatLine(parts.outline);
66
+ parts.outline = null;
67
+ }
68
+ return;
69
+ }
70
+ if (parts.outline === null) {
71
+ parts.outline = makeFatLine();
72
+ parts.group.add(parts.outline);
73
+ }
74
+ const material = parts.outline.material;
75
+ setColor(material, data.strokeColor, data.opacity);
76
+ material.linewidth = data.strokeWidth ?? 1;
77
+ const positions = [];
78
+ for (const [x, y] of points) {
79
+ positions.push(x, y, 0);
80
+ }
81
+ const first = points[0];
82
+ if (first !== undefined) {
83
+ positions.push(first[0], first[1], 0);
84
+ }
85
+ parts.outline.geometry.dispose();
86
+ parts.outline.geometry = new FatLine.LineGeometry();
87
+ parts.outline.geometry.setPositions(positions);
88
+ parts.outline.computeLineDistances();
89
+ parts.outline.visible = material.opacity > 0;
90
+ };
91
+ const ellipsePoints = (rx, ry) => {
92
+ const points = [];
93
+ for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
94
+ const a = (i / CIRCLE_SEGMENTS) * Math.PI * 2;
95
+ points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
96
+ }
97
+ return points;
98
+ };
99
+ // rect outline points, center-anchored local frame (±w/2, ±h/2). Sharp
100
+ // corners only — rounded rects were removed with the entity-model rewrite.
101
+ const rectPoints = (width, height) => [
102
+ [-width / 2, height / 2],
103
+ [width / 2, height / 2],
104
+ [width / 2, -height / 2],
105
+ [-width / 2, -height / 2],
106
+ ];
107
+ const placeFillGroup = (retained, leaf, ctx, fill, opacity) => {
108
+ const parts = partsOf(retained);
109
+ const material = parts.mesh.material;
110
+ setColor(material, fill, opacity);
111
+ parts.mesh.visible = material.opacity > 0;
112
+ parts.group.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
113
+ return parts;
114
+ };
115
+ const circle = {
116
+ build: (leaf, ctx) => {
117
+ const { retained, parts } = buildFillGroup();
118
+ parts.mesh.geometry = unitCircle;
119
+ circle.update(retained, leaf, ctx);
120
+ return retained;
121
+ },
122
+ update: (retained, leaf, ctx) => {
123
+ const parts = placeFillGroup(retained, leaf, ctx, leaf.data.fillColor, leaf.data.opacity);
124
+ parts.mesh.scale.set(leaf.data.radius, leaf.data.radius, 1);
125
+ setOutline(parts, leaf.data, leaf.data.strokeColor !== undefined
126
+ ? ellipsePoints(leaf.data.radius, leaf.data.radius)
127
+ : null);
128
+ },
129
+ };
130
+ const ellipse = {
131
+ build: (leaf, ctx) => {
132
+ const { retained, parts } = buildFillGroup();
133
+ parts.mesh.geometry = unitCircle;
134
+ ellipse.update(retained, leaf, ctx);
135
+ return retained;
136
+ },
137
+ update: (retained, leaf, ctx) => {
138
+ const parts = placeFillGroup(retained, leaf, ctx, leaf.data.fillColor, leaf.data.opacity);
139
+ parts.mesh.scale.set(leaf.data.radiusX, leaf.data.radiusY, 1);
140
+ setOutline(parts, leaf.data, leaf.data.strokeColor !== undefined
141
+ ? ellipsePoints(leaf.data.radiusX, leaf.data.radiusY)
142
+ : null);
143
+ },
144
+ };
145
+ const rect = {
146
+ build: (leaf, ctx) => {
147
+ const { retained } = buildFillGroup();
148
+ rect.update(retained, leaf, ctx);
149
+ return retained;
150
+ },
151
+ update: (retained, leaf, ctx) => {
152
+ const parts = placeFillGroup(retained, leaf, ctx, leaf.data.fillColor, leaf.data.opacity);
153
+ const data = leaf.data;
154
+ // corner radii are gone with the entity-model rewrite (design D13):
155
+ // rects render sharp
156
+ setFillGeometry(parts, unitPlane);
157
+ parts.mesh.scale.set(data.width, data.height, 1);
158
+ setOutline(parts, data, data.strokeColor !== undefined
159
+ ? rectPoints(data.width, data.height)
160
+ : null);
161
+ const { x: rotX, y: rotY, z: rotZ } = data.rotation;
162
+ const tilted = rotX !== 0 || rotY !== 0 || rotZ !== 0;
163
+ retained.billboard = !tilted;
164
+ if (tilted) {
165
+ // scene Eulers apply X→Y→Z extrinsically (matrix Rz·Ry·Rx), which
166
+ // is three's Euler order "ZYX" verbatim — no conjugation; rotation
167
+ // is about the shape's center (the centered unitPlane's origin)
168
+ parts.group.rotation.order = "ZYX";
169
+ parts.group.rotation.set(rotX, rotY, rotZ);
170
+ }
171
+ else {
172
+ parts.group.rotation.set(0, 0, 0);
173
+ }
174
+ },
175
+ };
176
+ // ── strokes (line / path) ────────────────────────────────────────────────
177
+ const makeFatLine = () => {
178
+ const line = new FatLine.Line2(new FatLine.LineGeometry());
179
+ // swap in the wrapper's alpha-blending material (upstream's transparent
180
+ // path is a broken shared framebuffer copy — see BlendedLine2NodeMaterial)
181
+ line.material.dispose();
182
+ const material = new FatLine.BlendedLine2NodeMaterial();
183
+ material.worldUnits = true;
184
+ line.material = material;
185
+ return line;
186
+ };
187
+ const disposeFatLine = (line) => {
188
+ line.geometry.dispose();
189
+ line.material.dispose();
190
+ };
191
+ const line = {
192
+ build: (leaf, ctx) => {
193
+ const fatLine = makeFatLine();
194
+ const retained = {
195
+ object: fatLine,
196
+ billboard: false,
197
+ dispose: () => disposeFatLine(fatLine),
198
+ };
199
+ line.update(retained, leaf, ctx);
200
+ return retained;
201
+ },
202
+ update: (retained, leaf, ctx) => {
203
+ const fatLine = retained.object;
204
+ const material = fatLine.material;
205
+ setColor(material, leaf.data.strokeColor, leaf.data.opacity);
206
+ material.linewidth = leaf.data.strokeWidth;
207
+ fatLine.visible = material.opacity > 0;
208
+ // `world` is the line's position (ancestor offset composed in). Both
209
+ // `start` and `end` are offsets FROM position, so each endpoint is
210
+ // world + its own offset — a zero/zero line is a point at position.
211
+ const a = ctx.toThree(leaf.world.x + leaf.data.start.x, leaf.world.y + leaf.data.start.y, leaf.world.z + leaf.data.start.z);
212
+ const b = ctx.toThree(leaf.world.x + leaf.data.end.x, leaf.world.y + leaf.data.end.y, leaf.world.z + leaf.data.end.z);
213
+ fatLine.geometry.setPositions([a.x, a.y, a.z, b.x, b.y, b.z]);
214
+ fatLine.computeLineDistances();
215
+ },
216
+ };
217
+ const pathSubpaths = (commands, anchor) => {
218
+ const subpaths = [];
219
+ let current = [];
220
+ let lastMove = { ...anchor };
221
+ const world = (p) => ({
222
+ x: anchor.x + p.x,
223
+ y: anchor.y + p.y,
224
+ z: anchor.z + (p.z ?? 0),
225
+ });
226
+ const flush = (closed) => {
227
+ if (current.length >= 2) {
228
+ subpaths.push({ points: current, closed });
229
+ }
230
+ current = [];
231
+ };
232
+ for (const command of commands) {
233
+ switch (command._tag) {
234
+ case "M": {
235
+ flush(false);
236
+ lastMove = world(command);
237
+ current = [lastMove];
238
+ break;
239
+ }
240
+ case "L": {
241
+ if (current.length === 0) {
242
+ current.push(lastMove);
243
+ }
244
+ current.push(world(command));
245
+ break;
246
+ }
247
+ case "Z": {
248
+ flush(true);
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ flush(false);
254
+ return subpaths;
255
+ };
256
+ // ponytail: rebuilt wholesale whenever the path changes (geometry churn
257
+ // accepted — acceptance paths are static). Closed subpaths fill with the
258
+ // path's fill color, triangulated in x/y with per-vertex z applied after
259
+ // (earcut only uses the input vertices, so mildly non-planar subpaths keep
260
+ // their depths). Holes are not supported — each closed subpath fills
261
+ // independently (winding analysis is a later concern).
262
+ const path = {
263
+ build: (leaf, ctx) => {
264
+ const group = new THREE.Group();
265
+ const retained = {
266
+ object: group,
267
+ billboard: false,
268
+ dispose: () => {
269
+ for (const child of [...group.children]) {
270
+ disposePathChild(child);
271
+ }
272
+ },
273
+ };
274
+ path.update(retained, leaf, ctx);
275
+ return retained;
276
+ },
277
+ update: (retained, leaf, ctx) => {
278
+ const group = retained.object;
279
+ for (const child of [...group.children]) {
280
+ group.remove(child);
281
+ disposePathChild(child);
282
+ }
283
+ const { strokeColor: stroke, fillColor: fill, opacity } = leaf.data;
284
+ const strokeWidth = leaf.data.strokeWidth ?? 1;
285
+ const subpaths = pathSubpaths(leaf.data.commands, leaf.world);
286
+ for (const subpath of subpaths) {
287
+ // fill: closed subpaths only, triangulated in x/y
288
+ if (subpath.closed && subpath.points.length >= 3) {
289
+ const contour = subpath.points.map((p) => new THREE.Vector2(p.x, p.y));
290
+ const triangles = THREE.ShapeUtils.triangulateShape(contour, []);
291
+ const positions = new Float32Array(subpath.points.length * 3);
292
+ for (const [i, p] of subpath.points.entries()) {
293
+ const v = ctx.toThree(p.x, p.y, p.z);
294
+ positions[i * 3] = v.x;
295
+ positions[i * 3 + 1] = v.y;
296
+ positions[i * 3 + 2] = v.z;
297
+ }
298
+ const geometry = new THREE.BufferGeometry();
299
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
300
+ geometry.setIndex(triangles.flat());
301
+ const material = new THREE.MeshBasicNodeMaterial();
302
+ material.side = THREE.DoubleSide;
303
+ setColor(material, fill, opacity);
304
+ const mesh = new THREE.Mesh(geometry, material);
305
+ mesh.visible = material.opacity > 0;
306
+ group.add(mesh);
307
+ }
308
+ // stroke: world-unit fat polyline
309
+ if (stroke !== undefined) {
310
+ const fatLine = makeFatLine();
311
+ const material = fatLine.material;
312
+ setColor(material, stroke, opacity);
313
+ material.linewidth = strokeWidth;
314
+ fatLine.visible = material.opacity > 0;
315
+ const positions = [];
316
+ const push = (p) => {
317
+ const v = ctx.toThree(p.x, p.y, p.z);
318
+ positions.push(v.x, v.y, v.z);
319
+ };
320
+ for (const p of subpath.points) {
321
+ push(p);
322
+ }
323
+ const first = subpath.points[0];
324
+ if (subpath.closed &&
325
+ subpath.points.length > 1 &&
326
+ first !== undefined) {
327
+ push(first);
328
+ }
329
+ fatLine.geometry.setPositions(positions);
330
+ fatLine.computeLineDistances();
331
+ group.add(fatLine);
332
+ }
333
+ }
334
+ },
335
+ };
336
+ const disposePathChild = (child) => {
337
+ if (child instanceof FatLine.Line2) {
338
+ disposeFatLine(child);
339
+ return;
340
+ }
341
+ const mesh = child;
342
+ mesh.geometry.dispose();
343
+ mesh.material.dispose();
344
+ };
345
+ // ── text: SDF glyphs (see Text.ts) ───────────────────────────────────────
346
+ // Layout is async (typesetting + first-sight glyph SDF generation) and
347
+ // registered with ctx.waitFor, so the render path never presents a
348
+ // half-built string. The mesh billboards and scales with perspective like
349
+ // the other billboard shapes.
350
+ const text = {
351
+ build: (leaf, ctx) => {
352
+ const textMesh = Text.makeMesh(ctx.text);
353
+ const retained = {
354
+ object: textMesh.mesh,
355
+ billboard: true,
356
+ dispose: () => textMesh.dispose(),
357
+ };
358
+ retained.object.userData.textMesh = textMesh;
359
+ text.update(retained, leaf, ctx);
360
+ return retained;
361
+ },
362
+ update: (retained, leaf, ctx) => {
363
+ const textMesh = retained.object.userData.textMesh;
364
+ const data = leaf.data;
365
+ const key = [
366
+ data.text,
367
+ data.fontSize,
368
+ data.fontFamily.id,
369
+ data.textAnchor,
370
+ data.baseline,
371
+ ].join("|");
372
+ if (retained.object.userData.textKey !== key) {
373
+ retained.object.userData.textKey = key;
374
+ ctx.waitFor(Text.layout(ctx.text, {
375
+ text: data.text,
376
+ fontId: data.fontFamily.id,
377
+ fontSize: data.fontSize,
378
+ textAnchor: data.textAnchor,
379
+ baseline: data.baseline,
380
+ }).pipe(Effect.map((quads) => textMesh.setQuads(quads))));
381
+ }
382
+ const { r, g, b, a } = Color.bytes(data.fillColor);
383
+ textMesh.setColor(r, g, b, (a / 255) * data.opacity);
384
+ retained.object.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
385
+ },
386
+ };
387
+ // ── images: decoded once per renderer scope, billboard planes ────────────
388
+ // (data.position.x, data.position.y) is the picture's CENTER like Rect; both
389
+ // dimensions set draw at that size, else the natural decoded size; a lone
390
+ // dimension is ignored.
391
+ const image = {
392
+ build: (leaf, ctx) => {
393
+ const material = new THREE.MeshBasicNodeMaterial();
394
+ material.transparent = true;
395
+ material.side = THREE.DoubleSide;
396
+ const mesh = new THREE.Mesh(unitPlane, material);
397
+ const retained = {
398
+ object: mesh,
399
+ billboard: true,
400
+ // textures are store-owned (disposed with the renderer scope)
401
+ dispose: () => material.dispose(),
402
+ };
403
+ image.update(retained, leaf, ctx);
404
+ return retained;
405
+ },
406
+ update: (retained, leaf, ctx) => {
407
+ const mesh = retained.object;
408
+ const material = mesh.material;
409
+ const data = leaf.data;
410
+ const applySize = (natural) => {
411
+ const both = data.width !== undefined && data.height !== undefined;
412
+ mesh.scale.set(both ? data.width : natural.width, both ? data.height : natural.height, 1);
413
+ };
414
+ if (mesh.userData.imageId !== data.image.id) {
415
+ mesh.userData.imageId = data.image.id;
416
+ mesh.visible = false;
417
+ ctx.waitFor(Images.ready(ctx.images, data.image.id).pipe(Effect.flatMap((decoded) => Effect.sync(() => {
418
+ material.map = decoded.texture;
419
+ material.needsUpdate = true;
420
+ mesh.userData.natural = {
421
+ width: decoded.width,
422
+ height: decoded.height,
423
+ };
424
+ applySize(mesh.userData.natural);
425
+ mesh.visible = material.opacity > 0;
426
+ }))));
427
+ }
428
+ else if (mesh.userData.natural !== undefined) {
429
+ applySize(mesh.userData.natural);
430
+ }
431
+ material.opacity = data.opacity;
432
+ if (mesh.userData.natural !== undefined) {
433
+ mesh.visible = data.opacity > 0;
434
+ }
435
+ mesh.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
436
+ },
437
+ };
438
+ const particleField = {
439
+ build: (leaf, ctx) => {
440
+ const capacity = Math.max(1, leaf.data.buffer.length);
441
+ const geometry = new THREE.InstancedBufferGeometry();
442
+ geometry.setAttribute("position", unitCircle.getAttribute("position"));
443
+ const circleIndex = unitCircle.getIndex();
444
+ if (circleIndex !== null) {
445
+ geometry.setIndex(circleIndex);
446
+ }
447
+ geometry.setAttribute("particleOffset", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
448
+ geometry.setAttribute("particleColor", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
449
+ geometry.instanceCount = 0;
450
+ const material = new THREE.MeshBasicNodeMaterial();
451
+ material.transparent = true;
452
+ material.side = THREE.DoubleSide;
453
+ const t = Tsl;
454
+ const offset = t.attribute("particleOffset", "vec3");
455
+ const color = t.attribute("particleColor", "vec4");
456
+ material.positionNode = t.vec3(t.positionGeometry.x.mul(offset.z).add(offset.x), t.positionGeometry.y.mul(offset.z).add(offset.y), 0);
457
+ material.colorNode = t.vec4(color.rgb, color.a);
458
+ const mesh = new THREE.Mesh(geometry, material);
459
+ mesh.frustumCulled = false;
460
+ const retained = {
461
+ object: mesh,
462
+ billboard: true,
463
+ dispose: () => {
464
+ geometry.dispose();
465
+ material.dispose();
466
+ },
467
+ };
468
+ particleField.update(retained, leaf, ctx);
469
+ return retained;
470
+ },
471
+ update: (retained, leaf, ctx) => {
472
+ const mesh = retained.object;
473
+ const geometry = mesh.geometry;
474
+ const data = leaf.data;
475
+ const offsets = geometry.getAttribute("particleOffset");
476
+ const colors = geometry.getAttribute("particleColor");
477
+ let count = 0;
478
+ for (const p of data.buffer) {
479
+ if (!p.alive) {
480
+ continue;
481
+ }
482
+ const radius = renderSize(p, data.sizeOverLife);
483
+ if (radius <= 0) {
484
+ continue;
485
+ }
486
+ const alpha = renderOpacity(p, data.opacityOverLife) *
487
+ data.opacity;
488
+ if (alpha <= 0) {
489
+ continue;
490
+ }
491
+ if (count >= offsets.count) {
492
+ break;
493
+ }
494
+ // local offsets pass through (scene space = three local space);
495
+ // the mesh itself sits at the field's anchor
496
+ offsets.setXYZ(count, p.x, p.y, radius);
497
+ const { r, g, b } = Color.bytes(p.color);
498
+ colors.setXYZW(count, r / 255, g / 255, b / 255, alpha);
499
+ count++;
500
+ }
501
+ offsets.needsUpdate = true;
502
+ colors.needsUpdate = true;
503
+ geometry.instanceCount = count;
504
+ mesh.visible = count > 0;
505
+ mesh.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
506
+ },
507
+ };
508
+ // ── staged gaps: loud, never silent ──────────────────────────────────────
509
+ // containers never reach leaf rendering — the frame walk composes them
510
+ const container = (name) => ({
511
+ build: (leaf) => {
512
+ throw new Error(`${name} is a container and must be composed by the frame walk, not rendered as a leaf — instance "${leaf.id}" (renderer walk bug)`);
513
+ },
514
+ update: () => { },
515
+ });
516
+ /**
517
+ * The exhaustive renderer map for every built-in entity. Typed
518
+ * `EntityRenderers<...>` so a missing built-in fails to type-check — the
519
+ * same coverage-manifest guarantee `builtinPaints` gives the ThorVG path.
520
+ */
521
+ /**
522
+ * A renderer slot for an entity that never reaches the walk. Dying loudly
523
+ * beats a silent no-op: if one of these is ever invoked, the frame contract
524
+ * changed and we want to hear about it.
525
+ */
526
+ const neverRendered = (tag) => ({
527
+ build: () => {
528
+ throw new Error(`Renderer: ${tag} is not renderable`);
529
+ },
530
+ update: () => {
531
+ throw new Error(`Renderer: ${tag} is not renderable`);
532
+ },
533
+ dispose: () => { },
534
+ });
535
+ /**
536
+ * The renderer for every built-in entity kind.
537
+ *
538
+ * @remarks
539
+ * Typed as an exhaustive map, so adding an entity to the core library
540
+ * without a renderer here fails the build rather than silently drawing
541
+ * nothing.
542
+ *
543
+ * To change how a kind is drawn, pass a replacement in the `renderers`
544
+ * option of `Renderer.make` or the Node adapter's `make` — it is merged over
545
+ * this map by tag — rather than editing this manifest.
546
+ */
547
+ export const builtinRenderers = {
548
+ Circle: circle,
549
+ Ellipse: ellipse,
550
+ Rect: rect,
551
+ Line: line,
552
+ Path: path,
553
+ Text: text,
554
+ Group: container("Group"),
555
+ Hud: container("Hud"),
556
+ Image: image,
557
+ // the camera is view state and never painted; it is omitted from the
558
+ // frame's instance map, so this entry is unreachable by construction —
559
+ // it exists only to satisfy exhaustiveness over the tag union
560
+ Camera: neverRendered("Camera"),
561
+ };
562
+ /**
563
+ * The manifest widened for registry use. The single variance cast lives
564
+ * here: `EntityRenderers` keys each renderer by its exact entity data (so
565
+ * coverage over the tag union stays a compile-time guarantee above), while
566
+ * a registry is keyed by string — contravariant `build`/`update` parameters
567
+ * make that narrowing inexpressible without the cast.
568
+ *
569
+ * ponytail: the ParticleField entry is the D10 escape hatch. It is the ONE
570
+ * key here that is not a member of the entity union, added explicitly rather
571
+ * than by leaving the registry open. Delete it with the particles rewrite.
572
+ */
573
+ /**
574
+ * {@link builtinRenderers} as the loosely-typed map the renderer's registry
575
+ * actually holds, keyed by entity tag.
576
+ *
577
+ * @remarks
578
+ * Same contents, widened: a heterogeneous registry cannot be read at any one
579
+ * entity type. Custom renderers are merged over this.
580
+ */
581
+ export const builtinRegistry = {
582
+ ...builtinRenderers,
583
+ [Runner.PARTICLE_FIELD_TAG]: particleField,
584
+ };