@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.
- package/dist/Builtins.d.ts +34 -0
- package/dist/Builtins.js +584 -0
- package/dist/EntityRenderer.d.ts +188 -0
- package/dist/EntityRenderer.js +1 -0
- package/dist/Images.d.ts +68 -0
- package/dist/Images.js +116 -0
- package/dist/RenderException.d.ts +29 -0
- package/dist/RenderException.js +25 -0
- package/dist/Renderer.d.ts +174 -0
- package/dist/Renderer.js +176 -0
- package/dist/Sync.d.ts +181 -0
- package/dist/Sync.js +480 -0
- package/dist/Text.d.ts +132 -0
- package/dist/Text.js +318 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +58 -0
- package/dist/node.d.ts +158 -0
- package/dist/node.js +206 -0
- package/package.json +65 -0
|
@@ -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>>;
|
package/dist/Builtins.js
ADDED
|
@@ -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
|
+
};
|