effect-motion 0.2.0 → 0.3.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.
Files changed (103) hide show
  1. package/dist/Camera.d.ts +50 -28
  2. package/dist/Camera.js +71 -19
  3. package/dist/CameraHelpers.d.ts +70 -0
  4. package/dist/CameraHelpers.js +239 -0
  5. package/dist/CanvasExporter.d.ts +12 -0
  6. package/dist/CanvasExporter.js +40 -0
  7. package/dist/Color.d.ts +428 -0
  8. package/dist/Color.js +535 -0
  9. package/dist/EffectMotionError.d.ts +10 -0
  10. package/dist/EffectMotionError.js +6 -0
  11. package/dist/Entity.d.ts +9 -8
  12. package/dist/Entity.js +1 -4
  13. package/dist/Fonts.d.ts +10 -0
  14. package/dist/Fonts.js +16 -0
  15. package/dist/Images.d.ts +33 -0
  16. package/dist/Images.js +24 -0
  17. package/dist/Instance.d.ts +4 -4
  18. package/dist/Motion.d.ts +24 -7
  19. package/dist/Motion.js +54 -9
  20. package/dist/Phaser.d.ts +1 -1
  21. package/dist/Phaser.js +13 -9
  22. package/dist/Physics.d.ts +3 -3
  23. package/dist/Physics.js +4 -4
  24. package/dist/PngExporter.d.ts +6 -0
  25. package/dist/PngExporter.js +85 -0
  26. package/dist/Projection.d.ts +225 -0
  27. package/dist/Projection.js +473 -0
  28. package/dist/Renderer.d.ts +102 -53
  29. package/dist/Renderer.js +369 -78
  30. package/dist/Runner.d.ts +126 -42
  31. package/dist/Runner.js +42 -17
  32. package/dist/Scene.d.ts +88 -59
  33. package/dist/Scene.js +24 -15
  34. package/dist/Shapes.d.ts +11 -0
  35. package/dist/Shapes.js +11 -0
  36. package/dist/demo.d.ts +3 -186
  37. package/dist/demo.js +24 -35
  38. package/dist/index.d.ts +14 -13
  39. package/dist/index.js +14 -13
  40. package/dist/particles/Particle.d.ts +4 -3
  41. package/dist/particles/ParticleField.d.ts +11 -48
  42. package/dist/particles/ParticleField.js +5 -4
  43. package/dist/particles/constructors.d.ts +8 -7
  44. package/dist/particles/constructors.js +2 -2
  45. package/dist/particles/index.d.ts +5 -6
  46. package/dist/particles/index.js +5 -6
  47. package/dist/particles/overLife.d.ts +1 -1
  48. package/dist/particles/overLife.js +1 -1
  49. package/dist/particles/simulate.d.ts +2 -2
  50. package/dist/particles/simulate.js +6 -6
  51. package/dist/particles/step.d.ts +2 -2
  52. package/dist/particles/step.js +5 -3
  53. package/dist/render/dof.d.ts +27 -0
  54. package/dist/render/dof.js +37 -0
  55. package/dist/render/paint.d.ts +30 -0
  56. package/dist/render/paint.js +36 -0
  57. package/dist/render/shapes.d.ts +42 -0
  58. package/dist/render/shapes.js +310 -0
  59. package/dist/shapes/Circle.d.ts +10 -15
  60. package/dist/shapes/Circle.js +2 -2
  61. package/dist/shapes/Ellipse.d.ts +10 -16
  62. package/dist/shapes/Ellipse.js +2 -2
  63. package/dist/shapes/Group.d.ts +5 -39
  64. package/dist/shapes/Group.js +4 -4
  65. package/dist/shapes/Hud.d.ts +38 -0
  66. package/dist/shapes/Hud.js +35 -0
  67. package/dist/shapes/Image.d.ts +45 -0
  68. package/dist/shapes/Image.js +28 -0
  69. package/dist/shapes/Line.d.ts +12 -12
  70. package/dist/shapes/Line.js +11 -6
  71. package/dist/shapes/Path.d.ts +87 -18
  72. package/dist/shapes/Path.js +25 -6
  73. package/dist/shapes/Rect.d.ts +33 -23
  74. package/dist/shapes/Rect.js +14 -2
  75. package/dist/shapes/Shape2D.d.ts +13 -4
  76. package/dist/shapes/Shape2D.js +16 -5
  77. package/dist/shapes/Shapes.d.ts +11 -0
  78. package/dist/shapes/Shapes.js +11 -0
  79. package/dist/shapes/Square.d.ts +10 -15
  80. package/dist/shapes/Square.js +2 -2
  81. package/dist/shapes/Text.d.ts +10 -19
  82. package/dist/shapes/Text.js +2 -2
  83. package/package.json +14 -4
  84. package/dist/particles/render.d.ts +0 -13
  85. package/dist/particles/render.js +0 -33
  86. package/dist/shapes/Layer.d.ts +0 -9
  87. package/dist/shapes/Layer.js +0 -23
  88. package/dist/shapes/index.d.ts +0 -10
  89. package/dist/shapes/index.js +0 -10
  90. package/dist/svg/SvgDomRenderer.d.ts +0 -28
  91. package/dist/svg/SvgDomRenderer.js +0 -50
  92. package/dist/svg/SvgNode.d.ts +0 -13
  93. package/dist/svg/SvgNode.js +0 -18
  94. package/dist/svg/SvgRenderer.d.ts +0 -22
  95. package/dist/svg/SvgRenderer.js +0 -20
  96. package/dist/svg/camera.d.ts +0 -21
  97. package/dist/svg/camera.js +0 -43
  98. package/dist/svg/index.d.ts +0 -6
  99. package/dist/svg/index.js +0 -6
  100. package/dist/svg/layers.d.ts +0 -18
  101. package/dist/svg/layers.js +0 -13
  102. package/dist/svg/shapes.d.ts +0 -1089
  103. package/dist/svg/shapes.js +0 -119
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Perspective projection — pure math shared by every sink, usable without
3
+ * the Effect runtime. No wall-clock, no RNG: projecting the same camera and
4
+ * point twice is bit-for-bit identical, which is what keeps 2.5D scenes
5
+ * deterministic.
6
+ *
7
+ * The model is the After Effects one: a camera with a world position, Euler
8
+ * orientation, and a focal length, looking down world -z at rest. World
9
+ * points are transformed into the camera's frame (the view transform, which
10
+ * is the inverse of the camera's own world transform), then divided by their
11
+ * depth in front of the camera to land on screen.
12
+ *
13
+ * Identity invariant: the default camera (see `defaultFocalLength` and
14
+ * `defaultCameraZ`) projects a world point at `z = 0` to screen `(x, y)` at
15
+ * scale 1 — so a scene that never touches depth renders exactly as the old
16
+ * plain-2D pipeline did. That falls out of placing the resting camera a
17
+ * focal-length back on +z and dividing by focal length at the z=0 plane —
18
+ * and holds for ANY focal length, which is why the default can be
19
+ * width-relative without breaking plain-2D scenes.
20
+ */
21
+ /**
22
+ * Default focal length (px) for a comp of the given width — After Effects'
23
+ * default lens: 50mm on 36mm-wide film, so `zoom = width × 50/36`. Width-
24
+ * relative so perspective strength (how much a given z moves/scales a shape)
25
+ * reads the same at every output resolution.
26
+ */
27
+ export const defaultFocalLength = (width) => (width * 50) / 36;
28
+ /**
29
+ * The resting camera sits this far back on +z, looking toward -z, so that
30
+ * the `z = 0` plane is exactly `focalLength` in front of it — the identity
31
+ * invariant. A camera authored without a `z` uses this.
32
+ */
33
+ export const defaultCameraZ = (focalLength) => focalLength;
34
+ // ── Euler rotation ──────────────────────────────────────────────────────
35
+ // Rotate a vector by the camera's inverse orientation (world → view). The
36
+ // camera's own rotation is applied X→Y→Z; the view transform is its inverse,
37
+ // so we negate the angles and apply Z→Y→X. Small hand-rolled rotations beat
38
+ // a general matrix lib here (ponytail: only these three axes are ever used).
39
+ const rotateInverse = (v, rx, ry, rz) => {
40
+ let { x, y, z } = v;
41
+ // inverse of Rz
42
+ if (rz !== 0) {
43
+ const c = Math.cos(-rz);
44
+ const s = Math.sin(-rz);
45
+ const nx = x * c - y * s;
46
+ const ny = x * s + y * c;
47
+ x = nx;
48
+ y = ny;
49
+ }
50
+ // inverse of Ry
51
+ if (ry !== 0) {
52
+ const c = Math.cos(-ry);
53
+ const s = Math.sin(-ry);
54
+ const nx = x * c + z * s;
55
+ const nz = -x * s + z * c;
56
+ x = nx;
57
+ z = nz;
58
+ }
59
+ // inverse of Rx
60
+ if (rx !== 0) {
61
+ const c = Math.cos(-rx);
62
+ const s = Math.sin(-rx);
63
+ const ny = y * c - z * s;
64
+ const nz = y * s + z * c;
65
+ y = ny;
66
+ z = nz;
67
+ }
68
+ return { x, y, z };
69
+ };
70
+ /**
71
+ * Rotate a vector by an Euler orientation (X→Y→Z), the forward transform
72
+ * used to orient a shape's local plane into world space. Mirror of
73
+ * `rotateInverse`.
74
+ */
75
+ const rotate = (v, rx, ry, rz) => {
76
+ let { x, y, z } = v;
77
+ if (rx !== 0) {
78
+ const c = Math.cos(rx);
79
+ const s = Math.sin(rx);
80
+ const ny = y * c - z * s;
81
+ const nz = y * s + z * c;
82
+ y = ny;
83
+ z = nz;
84
+ }
85
+ if (ry !== 0) {
86
+ const c = Math.cos(ry);
87
+ const s = Math.sin(ry);
88
+ const nx = x * c + z * s;
89
+ const nz = -x * s + z * c;
90
+ x = nx;
91
+ z = nz;
92
+ }
93
+ if (rz !== 0) {
94
+ const c = Math.cos(rz);
95
+ const s = Math.sin(rz);
96
+ const nx = x * c - y * s;
97
+ const ny = x * s + y * c;
98
+ x = nx;
99
+ y = ny;
100
+ }
101
+ return { x, y, z };
102
+ };
103
+ /**
104
+ * The auto-orient Euler angles (yaw + pitch, no roll) aiming a camera at
105
+ * `poi` from its WORLD position (viewport-center pan already composed in —
106
+ * see `resolveCamera`). The view transform flips z before rotating
107
+ * (in-front is +z), which inverts rotation handedness vs. world space;
108
+ * that subtlety is handled here, exactly once, pinned by tests that
109
+ * project the POI and assert it lands on the viewport center.
110
+ */
111
+ export const lookAtOrientation = (position, poi) => {
112
+ const dx = poi.x - position.x;
113
+ const dy = poi.y - position.y;
114
+ const dz = position.z - poi.z; // flipped: in-front is +z
115
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
116
+ if (len === 0) {
117
+ return { rotX: 0, rotY: 0 };
118
+ }
119
+ return { rotY: Math.atan2(dx, dz), rotX: -Math.asin(dy / len) };
120
+ };
121
+ /**
122
+ * The effective camera view: when a point of interest is set, auto-orient
123
+ * toward it with the explicit Euler composing AFTER the aim — the After
124
+ * Effects two-node rule. "After" means the user's rotation applies in the
125
+ * camera's own frame (so a lone `rotZ` rolls about the view axis and the
126
+ * POI stays centered); the exact composed rotation is extracted back to
127
+ * the fixed Rz·Ry·Rx Euler convention the view transform consumes.
128
+ * Camera `x`/`y` are pan-from-viewport-center, so the world position
129
+ * composes `origin` in before aiming. Absent POI is a pass-through
130
+ * (one-node camera, unchanged); a partial POI is a loud defect. The
131
+ * user's rotation fields are never written back — derivation happens
132
+ * here, at view-assembly time.
133
+ */
134
+ export const resolveCamera = (camera, origin) => {
135
+ const { poiX, poiY, poiZ } = camera;
136
+ const present = [poiX, poiY, poiZ].filter((v) => v !== undefined).length;
137
+ if (present === 0) {
138
+ return camera;
139
+ }
140
+ if (present !== 3) {
141
+ throw new Error("Camera: a point of interest requires all of poiX, poiY, poiZ — got a partial POI");
142
+ }
143
+ const world = {
144
+ x: origin.x + camera.x,
145
+ y: origin.y + camera.y,
146
+ z: camera.z,
147
+ };
148
+ const aim = lookAtOrientation(world, {
149
+ x: poiX,
150
+ y: poiY,
151
+ z: poiZ,
152
+ });
153
+ // aim only: return the derived angles exactly (no fp noise from the
154
+ // compose/extract round-trip in the common case)
155
+ if (camera.rotX === 0 && camera.rotY === 0 && camera.rotZ === 0) {
156
+ return { ...camera, rotX: aim.rotX, rotY: aim.rotY };
157
+ }
158
+ // exact composition M = Aim · UserEuler: the user rotation applied in
159
+ // camera-local space, then aimed — additive angles would roll about the
160
+ // WORLD axis and drag the POI off-center. Build M's columns with the
161
+ // same rotate() the pipeline uses, then extract ZYX Euler angles
162
+ // (rotate ≡ Rz·Ry·Rx, canonical right-handed forms).
163
+ const compose = (v) => rotate(rotate(v, camera.rotX, camera.rotY, camera.rotZ), aim.rotX, aim.rotY, 0);
164
+ const c0 = compose({ x: 1, y: 0, z: 0 });
165
+ const c1 = compose({ x: 0, y: 1, z: 0 });
166
+ const c2 = compose({ x: 0, y: 0, z: 1 });
167
+ const rotY = -Math.asin(Math.max(-1, Math.min(1, c0.z)));
168
+ // gimbal (camera pitched straight up/down): yaw and roll degenerate —
169
+ // pick roll = 0 and fold everything into pitch
170
+ const gimbal = Math.abs(c0.z) > 0.999999;
171
+ const rotX = gimbal ? Math.atan2(-c1.x, c1.y) : Math.atan2(c1.z, c2.z);
172
+ const rotZ = gimbal ? 0 : Math.atan2(c0.y, c0.x);
173
+ return { ...camera, rotX, rotY, rotZ };
174
+ };
175
+ /**
176
+ * The four world-space corners of a flat rectangular plane. The rect spans
177
+ * local `[x, x+width] × [y, y+height]` on the z=0 plane; each corner is
178
+ * rotated about the rect's local origin `(x, y)` by the Euler orientation,
179
+ * then translated to `world` (the plane's composed world anchor minus its own
180
+ * local x/y, so rotation pivots on the anchor). Winding: TL, TR, BR, BL.
181
+ */
182
+ export const planeCorners = (rect, orientation, world) => {
183
+ const { rotX, rotY, rotZ } = orientation;
184
+ // local corners relative to the rect's own origin (the rotation pivot)
185
+ const local = [
186
+ { x: 0, y: 0 },
187
+ { x: rect.width, y: 0 },
188
+ { x: rect.width, y: rect.height },
189
+ { x: 0, y: rect.height },
190
+ ];
191
+ const place = (c) => {
192
+ const r = rotate({ x: c.x, y: c.y, z: 0 }, rotX, rotY, rotZ);
193
+ return { x: world.x + r.x, y: world.y + r.y, z: world.z + r.z };
194
+ };
195
+ return [place(local[0]), place(local[1]), place(local[2]), place(local[3])];
196
+ };
197
+ /**
198
+ * A world point in the camera's frame, measured relative to `origin` (the
199
+ * viewport center). The camera's `x`/`y` are a pan *from* the origin, so a
200
+ * resting camera (pan 0) keeps world-x/y = screen-x/y at unit scale. `+z` in
201
+ * the result is in front of the camera: the camera looks down world -z, so a
202
+ * resting camera at z=focalLength sees the z=0 plane at view-z = focalLength.
203
+ */
204
+ export const toView = (camera, p, origin) => {
205
+ const translated = {
206
+ x: p.x - origin.x - camera.x,
207
+ y: p.y - origin.y - camera.y,
208
+ z: camera.z - p.z, // flip so in-front is +z (camera looks toward -world-z)
209
+ };
210
+ return rotateInverse(translated, camera.rotX, camera.rotY, camera.rotZ);
211
+ };
212
+ /**
213
+ * Project a world point to screen. `origin` is the screen point the camera's
214
+ * optical axis passes through — the viewport center — so pan/zoom read as
215
+ * "into the middle of the shot", while a resting camera reproduces plain-2D
216
+ * placement. A point at or behind the camera (view-z <= 0) has no valid
217
+ * projection; `depth` is still returned for sorting, `scale` clamps to 0.
218
+ */
219
+ export const project = (camera, p, origin) => {
220
+ const v = toView(camera, p, origin);
221
+ const depth = v.z;
222
+ const scale = depth > 0 ? camera.focalLength / depth : 0;
223
+ return {
224
+ x: origin.x + v.x * scale,
225
+ y: origin.y + v.y * scale,
226
+ depth,
227
+ scale,
228
+ };
229
+ };
230
+ export const billboardAffine = (proj, anchor) => ({
231
+ a: proj.scale,
232
+ b: 0,
233
+ c: 0,
234
+ d: proj.scale,
235
+ // place the anchor's local coords at the projected screen point
236
+ e: proj.x - anchor.x * proj.scale,
237
+ f: proj.y - anchor.y * proj.scale,
238
+ });
239
+ /**
240
+ * View-space depth (px) of the near clip plane. A tilted plane can be
241
+ * PARTIALLY behind the camera — projecting a corner at depth <= 0 is
242
+ * meaningless (the old code pinned it to the viewport center, folding the
243
+ * polygon), so the polygon is clipped against this plane first. 1px keeps
244
+ * the worst-case projected scale bounded at `focalLength` per unit.
245
+ */
246
+ const NEAR = 1;
247
+ /**
248
+ * Project the world-space corners of a tilted plane to a screen polygon.
249
+ * The polygon is clipped against the near plane in view space
250
+ * (Sutherland–Hodgman, winding preserved) before the per-vertex perspective
251
+ * divide, so a plane crossing the camera renders its visible part instead
252
+ * of folding — a quad straddling the plane yields up to 5 vertices; a plane
253
+ * fully behind yields none (cull). Fully in front, this is plain per-corner
254
+ * projection: a receding plane is a true perspective trapezoid.
255
+ */
256
+ export const projectPlane = (camera, corners, origin) => {
257
+ const view = corners.map((c) => toView(camera, c, origin));
258
+ return clipPolygonNear(view).map((v) => {
259
+ const scale = camera.focalLength / v.z;
260
+ return { x: origin.x + v.x * scale, y: origin.y + v.y * scale };
261
+ });
262
+ };
263
+ /**
264
+ * Sutherland–Hodgman clip of a view-space polygon against the near plane,
265
+ * winding preserved. Shared by tilted planes and closed path subpaths.
266
+ */
267
+ const clipPolygonNear = (view) => {
268
+ const clipped = [];
269
+ for (let i = 0; i < view.length; i++) {
270
+ // biome-ignore lint/style/noNonNullAssertion: i and (i+1)%length are in bounds
271
+ const a = view[i];
272
+ // biome-ignore lint/style/noNonNullAssertion: see above
273
+ const b = view[(i + 1) % view.length];
274
+ const aIn = a.z >= NEAR;
275
+ if (aIn) {
276
+ clipped.push(a);
277
+ }
278
+ if (aIn !== b.z >= NEAR) {
279
+ const t = (NEAR - a.z) / (b.z - a.z);
280
+ clipped.push({
281
+ x: a.x + (b.x - a.x) * t,
282
+ y: a.y + (b.y - a.y) * t,
283
+ z: NEAR,
284
+ });
285
+ }
286
+ }
287
+ return clipped;
288
+ };
289
+ /**
290
+ * Project a world-space segment (a skeletal shape's two endpoints) to
291
+ * screen. Both endpoints go to view space, are clipped against the near
292
+ * plane (lerp to z = NEAR — the 1D case of projectPlane's polygon clip),
293
+ * and are projected individually, so a line spanning depth foreshortens
294
+ * per endpoint. Returns `undefined` when the segment lies entirely behind
295
+ * the near plane (cull). `depth`/`scale` come from the visible midpoint.
296
+ */
297
+ export const projectSegment = (camera, a, b, origin) => {
298
+ let va = toView(camera, a, origin);
299
+ let vb = toView(camera, b, origin);
300
+ if (va.z < NEAR && vb.z < NEAR) {
301
+ return undefined;
302
+ }
303
+ const clip = (inside, outside) => {
304
+ const t = (NEAR - inside.z) / (outside.z - inside.z);
305
+ return {
306
+ x: inside.x + (outside.x - inside.x) * t,
307
+ y: inside.y + (outside.y - inside.y) * t,
308
+ z: NEAR,
309
+ };
310
+ };
311
+ if (va.z < NEAR) {
312
+ va = clip(vb, va);
313
+ }
314
+ else if (vb.z < NEAR) {
315
+ vb = clip(va, vb);
316
+ }
317
+ const toScreen = (v) => {
318
+ const s = camera.focalLength / v.z;
319
+ return { x: origin.x + v.x * s, y: origin.y + v.y * s };
320
+ };
321
+ const depth = (va.z + vb.z) / 2;
322
+ return {
323
+ a: toScreen(va),
324
+ b: toScreen(vb),
325
+ depth,
326
+ scale: camera.focalLength / depth,
327
+ };
328
+ };
329
+ /**
330
+ * Project a skeletal path's world-space subpaths to screen — the N-point
331
+ * generalization of `projectSegment`. Every point goes to view space and is
332
+ * projected individually, so a path spanning depth foreshortens per point.
333
+ * Near-plane clipping is per subpath: a closed subpath clips as a polygon
334
+ * (Sutherland–Hodgman, winding preserved — the tilted-plane treatment); an
335
+ * open subpath clips per span (lerp to z = NEAR) and SPLITS into separate
336
+ * visible pieces when interior points fall behind the plane. Returns
337
+ * `undefined` when nothing survives (cull). `depth`/`scale` come from the
338
+ * mean view depth of all emitted points — one key per path, the same
339
+ * ceiling as a segment's midpoint.
340
+ */
341
+ export const projectPath = (camera, subpaths, origin) => {
342
+ const pieces = [];
343
+ for (const subpath of subpaths) {
344
+ const view = subpath.points.map((p) => toView(camera, p, origin));
345
+ if (subpath.closed) {
346
+ const clipped = clipPolygonNear(view);
347
+ if (clipped.length >= 2) {
348
+ pieces.push({ points: clipped, closed: true });
349
+ }
350
+ continue;
351
+ }
352
+ // open: walk the spans, clipping and splitting at the near plane
353
+ let current = [];
354
+ const clip = (a, b) => {
355
+ const t = (NEAR - a.z) / (b.z - a.z);
356
+ return {
357
+ x: a.x + (b.x - a.x) * t,
358
+ y: a.y + (b.y - a.y) * t,
359
+ z: NEAR,
360
+ };
361
+ };
362
+ const flush = () => {
363
+ if (current.length >= 2) {
364
+ pieces.push({ points: current, closed: false });
365
+ }
366
+ current = [];
367
+ };
368
+ for (let i = 0; i + 1 < view.length; i++) {
369
+ // biome-ignore lint/style/noNonNullAssertion: i and i+1 are in bounds
370
+ const a = view[i];
371
+ // biome-ignore lint/style/noNonNullAssertion: see above
372
+ const b = view[i + 1];
373
+ const aIn = a.z >= NEAR;
374
+ const bIn = b.z >= NEAR;
375
+ if (aIn) {
376
+ if (current.length === 0) {
377
+ current.push(a);
378
+ }
379
+ if (bIn) {
380
+ current.push(b);
381
+ }
382
+ else {
383
+ current.push(clip(a, b));
384
+ flush();
385
+ }
386
+ }
387
+ else if (bIn) {
388
+ current = [clip(b, a), b];
389
+ }
390
+ }
391
+ flush();
392
+ }
393
+ if (pieces.length === 0) {
394
+ return undefined;
395
+ }
396
+ let depthSum = 0;
397
+ let depthCount = 0;
398
+ for (const piece of pieces) {
399
+ for (const v of piece.points) {
400
+ depthSum += v.z;
401
+ depthCount++;
402
+ }
403
+ }
404
+ const depth = depthSum / depthCount;
405
+ const toScreen = (v) => {
406
+ const s = camera.focalLength / v.z;
407
+ return { x: origin.x + v.x * s, y: origin.y + v.y * s };
408
+ };
409
+ return {
410
+ subpaths: pieces.map((piece) => ({
411
+ points: piece.points.map(toScreen),
412
+ closed: piece.closed,
413
+ })),
414
+ depth,
415
+ scale: camera.focalLength / depth,
416
+ };
417
+ };
418
+ /**
419
+ * Clip a screen-space segment to a rectangle (Liang–Barsky). Returns the
420
+ * clipped pair, or `undefined` when the segment lies entirely outside.
421
+ * ThorVG's software rasterizer pays stroke cost proportional to a path's
422
+ * full extent — offscreen included — so segments are clipped to the
423
+ * viewport (plus a stroke margin) before painting; a near-camera line can
424
+ * project tens of thousands of px wide otherwise (measured ~7× the cost
425
+ * of its visible part).
426
+ */
427
+ export const clipSegmentToRect = (a, b, min, max) => {
428
+ const dx = b.x - a.x;
429
+ const dy = b.y - a.y;
430
+ let t0 = 0;
431
+ let t1 = 1;
432
+ const edges = [
433
+ [-dx, a.x - min.x],
434
+ [dx, max.x - a.x],
435
+ [-dy, a.y - min.y],
436
+ [dy, max.y - a.y],
437
+ ];
438
+ for (const [p, q] of edges) {
439
+ if (p === 0) {
440
+ // parallel to this edge: outside it means fully outside
441
+ if (q < 0) {
442
+ return undefined;
443
+ }
444
+ continue;
445
+ }
446
+ const r = q / p;
447
+ if (p < 0) {
448
+ if (r > t1) {
449
+ return undefined;
450
+ }
451
+ if (r > t0) {
452
+ t0 = r;
453
+ }
454
+ }
455
+ else {
456
+ if (r < t0) {
457
+ return undefined;
458
+ }
459
+ if (r < t1) {
460
+ t1 = r;
461
+ }
462
+ }
463
+ }
464
+ const at = (t) => ({ x: a.x + dx * t, y: a.y + dy * t });
465
+ return [t0 === 0 ? a : at(t0), t1 === 1 ? b : at(t1)];
466
+ };
467
+ /**
468
+ * The view-space depth of a world point — the painter's-sort key alone.
469
+ * With no camera rotation this is `camera.z - p.z`; rotation tilts the
470
+ * depth axis, so the full view transform is used. Origin only shifts x/y,
471
+ * never depth, so a zero origin suffices.
472
+ */
473
+ export const depthOf = (camera, p) => toView(camera, p, { x: 0, y: 0 }).z;
@@ -1,69 +1,118 @@
1
- import { Layer } from "effect";
2
- import * as Context from "effect/Context";
1
+ import type { OwnedPaint, RenderSession, ThorvgException, ThorvgWasm } from "@effect-motion/thorvg";
2
+ import * as Tvg from "@effect-motion/thorvg";
3
+ import type { Canvas } from "@effect-motion/thorvg/Canvas";
3
4
  import * as Effect from "effect/Effect";
4
- import type * as Entity from "./Entity";
5
- import type { EntriesFromEntities, Frame } from "./Scene";
6
- /** Renders one entity instance to the renderer's per-entity output. */
7
- export type RenderFunction<Success, Ent extends Entity.AnyEntity, E = never, R = never> = (payload: {
8
- entity: Ent;
9
- id: string;
10
- data: Ent["data"]["Type"];
11
- /**
12
- * rendered output of this instance's children, post-order (empty for
13
- * leaves). In frame data, a `children: string[]` field means child
14
- * instance ids — containers embed these results in their own output.
15
- */
16
- children: ReadonlyArray<Success>;
17
- }) => Effect.Effect<Success, E, R>;
18
- /** the frame's render metadata, handed to sink render functions */
5
+ import type * as Scope from "effect/Scope";
6
+ import * as Color from "./Color.js";
7
+ import type * as Entity from "./Entity.js";
8
+ import * as Projection from "./Projection.js";
9
+ import type { Frame } from "./Scene.js";
10
+ /**
11
+ * The projection handed to each paint function — how the camera places a
12
+ * paintable this frame. `screen` is the projected billboard placement (an
13
+ * affine the paint fn applies via `setTransform`); `depth` is the view-space
14
+ * sort key; `scale` is the perspective scale (<= 0 means the anchor is behind
15
+ * the camera — cull, unless a `quad` or `segment` is present). `quad`, when
16
+ * present, is the projected, near-plane-clipped screen polygon of a tilted
17
+ * plane (3–5 vertices — see Projection.projectPlane); a shape that can tilt
18
+ * paints an exact path from these instead of applying the billboard affine.
19
+ * `segment`, when present, is the exact projected screen endpoints of a
20
+ * skeletal shape (Line) — each endpoint carries its own world depth, so the
21
+ * pair is projected per point (see Projection.projectSegment) and the paint
22
+ * fn draws it directly, skipping the billboard affine. `subpaths`, when
23
+ * present, is the projected screen geometry of a skeletal path (Path): every
24
+ * command point projected individually and near-plane-clipped per subpath
25
+ * (see Projection.projectPath) — the paint fn emits it directly at screen
26
+ * coordinates.
27
+ */
28
+ export interface PaintProjection {
29
+ readonly screen: Projection.Affine;
30
+ readonly depth: number;
31
+ readonly scale: number;
32
+ readonly quad?: ReadonlyArray<Projection.Vec2>;
33
+ readonly segment?: readonly [Projection.Vec2, Projection.Vec2];
34
+ readonly subpaths?: Projection.ProjectedPath["subpaths"];
35
+ }
36
+ /** The frame's render metadata, handed to paint functions. */
19
37
  export interface FrameMeta {
20
38
  readonly frameRate: number;
21
39
  readonly width: number;
22
40
  readonly height: number;
23
- readonly backgroundColor: string;
41
+ readonly backgroundColor: Color.Color;
24
42
  /**
25
- * the active camera's view — `{x, y}` pan in world units, `zoom` a
26
- * uniform scale (1 = identity). Sinks apply it per top-level layer,
27
- * scaled by that layer's `depth`. Absent camera work leaves this at
28
- * identity `{0, 0, 1}` and output is unchanged.
43
+ * the active camera's view — world position `{x, y, z}`, Euler
44
+ * orientation `{rotX, rotY, rotZ}`, and `focalLength` (FOV). The renderer
45
+ * projects every instance through it. The resting camera (see
46
+ * Camera.identity) reproduces plain-2D placement for z=0 content.
29
47
  */
30
48
  readonly camera: {
31
49
  readonly x: number;
32
50
  readonly y: number;
33
- readonly zoom: number;
51
+ readonly z: number;
52
+ readonly rotX: number;
53
+ readonly rotY: number;
54
+ readonly rotZ: number;
55
+ readonly focalLength: number;
34
56
  };
35
57
  }
36
- export interface EntityRenderer<Name extends string, Success, Ent extends Entity.AnyEntity, E, R> {
37
- readonly name: Name;
38
- readonly render: RenderFunction<Success, Ent, E, R>;
58
+ /**
59
+ * Paints one entity instance onto the shared ThorVG scene. It issues ThorVG
60
+ * C-API calls (make a shape, append geometry, style it, apply the projection,
61
+ * add it to `scene`) — there is no intermediate description value. A container
62
+ * (Group / root) paints nothing itself; its position has already composed into
63
+ * its children's world coordinates by the time this is called.
64
+ */
65
+ export type PaintFunction<Ent extends Entity.AnyEntity> = (payload: {
66
+ readonly entity: Ent;
67
+ readonly id: string;
68
+ readonly data: Ent["data"]["Type"];
69
+ readonly projection: PaintProjection;
70
+ readonly canvas: Canvas;
71
+ readonly scene: OwnedPaint;
72
+ readonly meta: FrameMeta;
73
+ }) => Effect.Effect<void, ThorvgException, ThorvgWasm | RenderSession | Scope.Scope>;
74
+ /**
75
+ * A registry of paint functions keyed by entity name. `PaintFunctions<E>` is
76
+ * the exhaustive map over an entity union — a built-in with no paint function
77
+ * is a type error at the render call, not a runtime surprise (the old
78
+ * "coverage manifest" guarantee, kept without a Context registry).
79
+ */
80
+ export type PaintFunctions<Entities extends Entity.AnyEntity> = {
81
+ readonly [K in Entities as K["name"]]: PaintFunction<K>;
82
+ };
83
+ /** RGBA8888 framebuffer plus its dimensions, straight from the SW canvas. */
84
+ export interface Framebuffer {
85
+ readonly rgba: Uint8Array;
86
+ /** physical pixel size of the rgba buffer (logical size × dpr) */
87
+ readonly width: number;
88
+ readonly height: number;
89
+ /**
90
+ * logical scene size — the resolution the buffer should be displayed at
91
+ * (CSS pixels). Equals width/height when rendered at dpr 1.
92
+ */
93
+ readonly logicalWidth: number;
94
+ readonly logicalHeight: number;
39
95
  }
40
- type RendererName<Tag extends string, Ent extends Entity.AnyEntity> = `${Tag}/${Ent["name"]}`;
41
96
  /**
42
- * Build a renderer family.
97
+ * Render one frame to an RGBA framebuffer, shared by both output adapters.
43
98
  *
44
- * `make<EntityOutput>()(tag, { render })` creates a frame renderer that
45
- * resolves one `EntityRenderer` per entity type from context (keyed
46
- * `<tag>/<entity name>`) and combines the per-entity outputs with
47
- * `config.render`. Provide per-entity renderers with
48
- * `makeEntityRendererLayer` and the frame renderer with `layer`.
99
+ * Uses the RenderSession's canvas (resized in place to the frame's physical
100
+ * size, cleared of the previous frame), adds a root scene, folds the frame
101
+ * onto it via `Renderer.render`, then update/draw/sync and reads the SW
102
+ * framebuffer. The scene and every painted shape are scoped per frame; the
103
+ * canvas belongs to the session (a player mount, an export run) and is
104
+ * deleted when the session closes.
105
+ *
106
+ * The background is painted as a filled rect (not a canvas clear color) so it
107
+ * survives into the buffer the same way the SVG sink emitted a background
108
+ * rect.
49
109
  */
50
- export declare const make: <RenderEntitySuccess, Config = void>() => <const Tag extends string, RenderSuccess>(tag: Tag, config: {
51
- render: <Entities>(entities: Iterable<{
52
- id: string;
53
- render: Effect.Effect<RenderEntitySuccess>;
54
- entry: EntriesFromEntities<Entities>;
55
- }>, config: Config, meta: FrameMeta) => Effect.Effect<RenderSuccess>;
56
- }) => {
57
- Context: Context.Service<{
58
- render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
59
- }, {
60
- render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
61
- }>;
62
- layer: Layer.Layer<{
63
- render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
64
- }, never, never>;
65
- makeEntityRendererContext: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent) => Context.Service<EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>, EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>>;
66
- makeEntityRendererService: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>;
67
- makeEntityRendererLayer: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => Layer.Layer<EntityRenderer<RendererName<Tag, Ent>, RenderEntitySuccess, Ent, E, R>>;
68
- };
69
- export {};
110
+ export declare const render: (frame: Frame, options?: {
111
+ /**
112
+ * device-pixel-ratio multiplier for high-dpi displays. The buffer is
113
+ * rasterized at `logical × dpr` while paint functions keep working in
114
+ * logical scene coordinates (the root scene is scaled). Callers display
115
+ * the buffer at the logical size. Default 1 (node/export paths).
116
+ */
117
+ readonly dpr?: number;
118
+ }) => Effect.Effect<Framebuffer, ThorvgException, Tvg.ThorvgWasm | Tvg.RenderSession | Scope.Scope>;