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
package/dist/Images.js ADDED
@@ -0,0 +1,24 @@
1
+ import * as Context from "effect/Context";
2
+ /**
3
+ * Scene annotation key declaring the images a scene's Image entities depend
4
+ * on: `scene.annotate(Images.Images, [...])`. The runtime never reads it —
5
+ * decoded pixels cannot affect frame data. Consumers (the player, export
6
+ * tools) read it to load assets into their render session before playback.
7
+ */
8
+ export const Images = Context.Reference("motion/Images", { defaultValue: () => [] });
9
+ /** A scene's declared images — empty for scenes never annotated. */
10
+ export const get = (scene) => Context.get(scene.annotations, Images);
11
+ /**
12
+ * A scene's declared images as a `name -> url` map for the render session's
13
+ * `images` option. Only entries with a `src.url` are included — loading is
14
+ * fetch-by-URL (no filesystem), so `path`-only entries are skipped.
15
+ */
16
+ export const urlMap = (scene) => {
17
+ const out = {};
18
+ for (const image of get(scene)) {
19
+ if (image.src.url !== undefined) {
20
+ out[image.name] = image.src.url;
21
+ }
22
+ }
23
+ return out;
24
+ };
@@ -1,16 +1,16 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Pipeable from "effect/Pipeable";
3
3
  import type * as Schema from "effect/Schema";
4
- import type * as Entity from "./Entity";
4
+ import type * as Entity from "./Entity.js";
5
5
  export declare const TypeId: "~motion/Instance";
6
6
  export interface Instance<Name extends string = string, Data extends Schema.Top = Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> = {}> extends Pipeable.Pipeable {
7
7
  readonly [TypeId]: typeof TypeId;
8
8
  readonly id: string;
9
- readonly entity: Entity.Entity<Name, Data, Traits, any>;
9
+ readonly entity: Entity.Entity<Name, Data, Traits>;
10
10
  }
11
11
  /** the Instance type of a given entity, traits included */
12
- export type Of<E extends Entity.AnyEntity> = E extends Entity.Entity<infer Name, infer Data, infer Traits, any> ? Instance<Name, Data, Traits> : never;
12
+ export type Of<E extends Entity.AnyEntity> = E extends Entity.Entity<infer Name, infer Data, infer Traits> ? Instance<Name, Data, Traits> : never;
13
13
  export declare const isInstance: (u: unknown) => u is Instance;
14
- export declare const make: <Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>, MakeInput>(entity: Entity.Entity<Name, Data, Traits, MakeInput>, id: string) => Instance<Name, Data, Traits>;
14
+ export declare const make: <Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>>(entity: Entity.Entity<Name, Data, Traits>, id: string) => Instance<Name, Data, Traits>;
15
15
  export type InstanceOrEffect<Name extends string = string, Data extends Schema.Top = Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> = {}, E = never, R = never> = Instance<Name, Data, Traits> | Effect.Effect<Instance<Name, Data, Traits>, E, R>;
16
16
  export declare const flatten: <Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>, E = never, R = never>(instance: InstanceOrEffect<Name, Data, Traits, E, R>) => Effect.Effect<Instance<Name, Data, Traits>, E, R>;
package/dist/Motion.d.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  import type * as Duration from "effect/Duration";
2
2
  import * as Effect from "effect/Effect";
3
3
  import type * as Schema from "effect/Schema";
4
- import * as Entity from "./Entity";
5
- import * as Instance from "./Instance";
6
- import * as Runner from "./Runner";
7
- import * as Timing from "./Timing";
8
- export type InterpolableValue = number;
4
+ import * as Color from "./Color.js";
5
+ import * as Entity from "./Entity.js";
6
+ import * as Instance from "./Instance.js";
7
+ import * as Runner from "./Runner.js";
8
+ import * as Timing from "./Timing.js";
9
+ export declare const color: (to: Color.Color, mode: Color.InterpolationMode) => (from: Color.Color, t: number) => Color.Color;
10
+ type ColorInterpolator = (from: Color.Color, t: number) => Color.Color;
11
+ export type InterpolableValue = number | Color.Color;
9
12
  type InterpolableKeys<T> = {
10
- [K in keyof T]: T[K] extends InterpolableValue ? K : never;
13
+ [K in keyof T]-?: NonNullable<T[K]> extends InterpolableValue ? K : never;
11
14
  }[keyof T];
12
15
  export type InterpolableOnly<T> = Pick<T, InterpolableKeys<T>>;
16
+ type InterpolableOrInterpolator<T> = {
17
+ [K in keyof T]-?: NonNullable<T[K]> extends number ? number : NonNullable<T[K]> extends Color.Color ? Color.Color | ColorInterpolator : never;
18
+ };
13
19
  /** target props, or an updater computing them from the current data */
14
- export type Target<Data extends Schema.Top> = Partial<InterpolableOnly<Data["Type"]>> | ((data: Data["Type"]) => Partial<InterpolableOnly<Data["Type"]>>);
20
+ export type Target<Data extends Schema.Top> = Partial<InterpolableOrInterpolator<Data["Type"]>> | ((data: Data["Type"]) => Partial<InterpolableOrInterpolator<Data["Type"]>>);
15
21
  export declare const resolveTarget: <Data extends Schema.Top>(target: Target<Data>, current: Data["Type"]) => Record<string, number>;
16
22
  export declare const startValues: (current: unknown, target: Record<string, number>, explicitFrom: Record<string, number>) => Record<string, number>;
17
23
  /**
@@ -23,6 +29,17 @@ export declare const startValues: (current: unknown, target: Record<string, numb
23
29
  * instance, so animations chain.
24
30
  */
25
31
  export declare const tweenTo: (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>>(to: Target<Data>, duration: Duration.Input, timing?: Timing.TimingInput) => <E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>) & (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>, E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>, to: Target<Data>, duration: Duration.Input, timing?: Timing.TimingInput) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>);
32
+ /**
33
+ * Parametric animator: each frame applies `fn` with the eased parameter
34
+ * and the current data, then ticks. The final frame receives exactly
35
+ * `t = 1` for any timing with f(1) = 1; a zero-length duration still
36
+ * takes one frame. This is the primitive under coordinated multi-field
37
+ * motion — arcs, orbits, counters — where independent per-field tweens
38
+ * cannot express the coupling. Determinism: `fn` sees only `(t, data)`.
39
+ * Dual: `drive(instance, duration, timing, fn)` or
40
+ * `instance.pipe(drive(duration, timing, fn))`.
41
+ */
42
+ export declare const drive: (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>>(duration: Duration.Input, timing: Timing.TimingInput, fn: (t: number, data: Data["Type"]) => Data["Type"]) => <E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>) & (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>>, E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>, duration: Duration.Input, timing: Timing.TimingInput, fn: (t: number, data: Data["Type"]) => Data["Type"]) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>);
26
43
  /**
27
44
  * Like `tweenTo`, but with an explicit start: interpolates the keys of
28
45
  * `to` from `from` (keys missing in `from` start at the current data).
package/dist/Motion.js CHANGED
@@ -1,14 +1,21 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Effectable from "effect/Effectable";
3
3
  import { dual } from "effect/Function";
4
- import * as Entity from "./Entity";
5
- import * as Instance from "./Instance";
6
- import * as Runner from "./Runner";
7
- import * as Scene from "./Scene";
8
- import * as Time from "./Time";
9
- import * as Timing from "./Timing";
4
+ import * as Color from "./Color.js";
5
+ import * as Entity from "./Entity.js";
6
+ import * as Instance from "./Instance.js";
7
+ import * as Runner from "./Runner.js";
8
+ import * as Scene from "./Scene.js";
9
+ import * as Time from "./Time.js";
10
+ import * as Timing from "./Timing.js";
11
+ export const color = (to, mode) => {
12
+ return (from, t) => Color.mix(from, to, t, mode);
13
+ };
10
14
  // extrapolating on purpose: eased t goes outside [0, 1] for back/elastic
11
15
  const lerpNumber = (from, to, t) => from + (to - from) * t;
16
+ const lerpColor = (from, to, t, mode) => {
17
+ return Color.mix(from, to, t, mode);
18
+ };
12
19
  /**
13
20
  * The interpolation engine: from `from` to `to` over `duration`, calling
14
21
  * `fn` with the eased value once per frame (each step ends in a
@@ -25,7 +32,18 @@ const interpolate = Effect.fnUntraced(function* (from, to, duration, fn, timing
25
32
  const t = timingFn(i / frames);
26
33
  const value = {};
27
34
  for (const key of keys) {
28
- value[key] = lerpNumber(from[key], to[key], t);
35
+ const fromValue = from[key];
36
+ const toValue = to[key];
37
+ if (typeof toValue === "number") {
38
+ value[key] = lerpNumber(from[key], to[key], t);
39
+ }
40
+ else if (typeof toValue === "function") {
41
+ const interpolator = toValue;
42
+ value[key] = interpolator(fromValue, t);
43
+ }
44
+ else {
45
+ value[key] = lerpColor(fromValue, toValue, t, "lab");
46
+ }
29
47
  }
30
48
  yield* fn(value);
31
49
  yield* Scene.tick;
@@ -42,8 +60,13 @@ export const resolveTarget = (target, current) => (typeof target === "function"
42
60
  export const startValues = (current, target, explicitFrom) => {
43
61
  const start = {};
44
62
  for (const key of Object.keys(target)) {
45
- start[key] =
46
- explicitFrom[key] ?? current[key];
63
+ const from = explicitFrom[key] ?? current[key];
64
+ // an optional field that was never set has no start value — lerping
65
+ // from undefined would silently produce NaN frames; die loudly instead
66
+ if (from === undefined) {
67
+ throw new Error(`tween: "${key}" has no current value to start from — pass an explicit from`);
68
+ }
69
+ start[key] = from;
47
70
  }
48
71
  return start;
49
72
  };
@@ -69,6 +92,28 @@ const firstArgIsInstance = (args) => Instance.isInstance(args[0]);
69
92
  * instance, so animations chain.
70
93
  */
71
94
  export const tweenTo = dual(firstArgIsInstance, (instance, to, duration, timing) => animate(instance, undefined, to, duration, timing));
95
+ /**
96
+ * Parametric animator: each frame applies `fn` with the eased parameter
97
+ * and the current data, then ticks. The final frame receives exactly
98
+ * `t = 1` for any timing with f(1) = 1; a zero-length duration still
99
+ * takes one frame. This is the primitive under coordinated multi-field
100
+ * motion — arcs, orbits, counters — where independent per-field tweens
101
+ * cannot express the coupling. Determinism: `fn` sees only `(t, data)`.
102
+ * Dual: `drive(instance, duration, timing, fn)` or
103
+ * `instance.pipe(drive(duration, timing, fn))`.
104
+ */
105
+ export const drive = dual(firstArgIsInstance, Effect.fnUntraced(function* (instanceOrEffect, duration, timing, fn) {
106
+ const instance = yield* Instance.flatten(instanceOrEffect);
107
+ const runner = yield* Runner.Runner;
108
+ const timingFn = Timing.resolve(timing);
109
+ const frames = Math.max(1, Time.toFrames(duration, runner.settings.frameRate));
110
+ for (let i = 1; i <= frames; i++) {
111
+ const t = timingFn(i / frames);
112
+ yield* Scene.update(instance, (data) => fn(t, data));
113
+ yield* Scene.tick;
114
+ }
115
+ return instance;
116
+ }));
72
117
  /**
73
118
  * Like `tweenTo`, but with an explicit start: interpolates the keys of
74
119
  * `to` from `from` (keys missing in `from` start at the current data).
package/dist/Phaser.d.ts CHANGED
@@ -44,7 +44,7 @@ export declare class Phaser extends Phaser_base {
44
44
  * during a sequential handoff or before the scene starts. The slot is
45
45
  * released by a finalizer on success, failure, and interrupt alike.
46
46
  */
47
- export declare const run: <A, E, R>(phaser: Phaser["Service"], scene: Effect.Effect<A, E, R>) => Effect.Effect<import("effect/Fiber").Fiber<A, E>, never, Exclude<R, Phaser>>;
47
+ export declare const run: <A, E = never, R = never>(phaser: Phaser["Service"], scene: Effect.Effect<A, E, R>) => Effect.Effect<import("effect/Fiber").Fiber<A, E>, never, Exclude<R, Phaser>>;
48
48
  /**
49
49
  * Run `effect`, then arrive at the phase boundary.
50
50
  *
package/dist/Phaser.js CHANGED
@@ -14,7 +14,7 @@ export class Phaser extends Context.Service()("motion/Phaser", {
14
14
  let arrived = 0;
15
15
  let phaseLatch = Latch.makeUnsafe();
16
16
  let state = "idle";
17
- let waiter = null;
17
+ const waiters = new Set();
18
18
  // The single invariant. Every event that touches `arrived` or
19
19
  // `parties` re-runs this.
20
20
  const checkQuiescence = () => {
@@ -40,10 +40,12 @@ export class Phaser extends Context.Service()("motion/Phaser", {
40
40
  return;
41
41
  }
42
42
  case "running": {
43
- const resume = waiter;
44
- waiter = null;
45
43
  state = "idle";
46
- resume?.();
44
+ const copy = new Set(waiters);
45
+ waiters.clear();
46
+ for (const waiter of copy) {
47
+ waiter();
48
+ }
47
49
  return;
48
50
  }
49
51
  }
@@ -69,11 +71,13 @@ export class Phaser extends Context.Service()("motion/Phaser", {
69
71
  })));
70
72
  }));
71
73
  const awaitAdvance = Effect.callback((resume) => {
72
- if (waiter !== null) {
73
- resume(Effect.die(new Error("Phaser: concurrent awaitAdvance — single controller only")));
74
- return;
74
+ const waiter = () => resume(Effect.succeed(phase));
75
+ waiters.add(waiter);
76
+ if (waiters.size > 1) {
77
+ return Effect.sync(() => {
78
+ waiters.delete(waiter);
79
+ });
75
80
  }
76
- waiter = () => resume(Effect.succeed(phase));
77
81
  if (arrived === parties) {
78
82
  // quiescent: arm an advance so parties run the next phase
79
83
  state = "pending";
@@ -87,7 +91,7 @@ export class Phaser extends Context.Service()("motion/Phaser", {
87
91
  }
88
92
  return Effect.sync(() => {
89
93
  // interrupted while suspended
90
- waiter = null;
94
+ waiters.delete(waiter);
91
95
  state = "idle";
92
96
  });
93
97
  });
package/dist/Physics.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import type * as Schema from "effect/Schema";
3
- import * as Entity from "./Entity";
4
- import * as Instance from "./Instance";
5
- import * as Runner from "./Runner";
3
+ import * as Entity from "./Entity.js";
4
+ import * as Instance from "./Instance.js";
5
+ import * as Runner from "./Runner.js";
6
6
  /**
7
7
  * Physics-based motion: durationless animations that carry momentum and
8
8
  * end on their own. A spring is a damped harmonic oscillator simulated
package/dist/Physics.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import { dual } from "effect/Function";
3
- import * as Entity from "./Entity";
4
- import * as Instance from "./Instance";
5
- import * as Runner from "./Runner";
6
- import * as Scene from "./Scene";
3
+ import * as Entity from "./Entity.js";
4
+ import * as Instance from "./Instance.js";
5
+ import * as Runner from "./Runner.js";
6
+ import * as Scene from "./Scene.js";
7
7
  /** fast, gently damped general-purpose spring */
8
8
  export const defaultSpring = {
9
9
  mass: 0.05,
@@ -0,0 +1,6 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { FileSystem } from "effect/FileSystem";
3
+ import { EffectMotionError } from "./EffectMotionError.js";
4
+ import type * as Renderer from "./Renderer.js";
5
+ export declare const toBuffer: (fb: Renderer.Framebuffer) => Effect.Effect<Uint8Array<ArrayBuffer>, EffectMotionError, never>;
6
+ export declare const toFile: (fb: Renderer.Framebuffer, outPath: string) => Effect.Effect<void, EffectMotionError | import("effect/PlatformError").PlatformError, FileSystem>;
@@ -0,0 +1,85 @@
1
+ import { deflateSync } from "node:zlib";
2
+ import * as Effect from "effect/Effect";
3
+ import { FileSystem } from "effect/FileSystem";
4
+ import { EffectMotionError } from "./EffectMotionError.js";
5
+ const SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
6
+ const crcTable = (() => {
7
+ const t = new Uint32Array(256);
8
+ for (let n = 0; n < 256; n++) {
9
+ let c = n;
10
+ for (let k = 0; k < 8; k++) {
11
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
12
+ }
13
+ t[n] = c >>> 0;
14
+ }
15
+ return t;
16
+ })();
17
+ const crc32 = (bytes) => {
18
+ let c = 0xffffffff;
19
+ for (let i = 0; i < bytes.length; i++) {
20
+ c = crcTable[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
21
+ }
22
+ return (c ^ 0xffffffff) >>> 0;
23
+ };
24
+ const chunk = (type, data) => {
25
+ const typeBytes = new Uint8Array([
26
+ type.charCodeAt(0),
27
+ type.charCodeAt(1),
28
+ type.charCodeAt(2),
29
+ type.charCodeAt(3),
30
+ ]);
31
+ const body = new Uint8Array(typeBytes.length + data.length);
32
+ body.set(typeBytes, 0);
33
+ body.set(data, typeBytes.length);
34
+ const out = new Uint8Array(4 + body.length + 4);
35
+ const view = new DataView(out.buffer);
36
+ view.setUint32(0, data.length, false);
37
+ out.set(body, 4);
38
+ view.setUint32(4 + body.length, crc32(body), false);
39
+ return out;
40
+ };
41
+ /** Encode a raw RGBA8888 buffer (`width * height * 4` bytes) as a PNG. */
42
+ const encodePng = Effect.fnUntraced(function* (rgba, width, height) {
43
+ if (rgba.length !== width * height * 4) {
44
+ return yield* Effect.fail(EffectMotionError.of(`Could not encode PNG: buffer is ${rgba.length} bytes, expected ${width * height * 4} (${width}x${height} RGBA)`));
45
+ }
46
+ // prepend a filter byte (0 = none) to each scanline
47
+ const stride = width * 4;
48
+ const raw = new Uint8Array((stride + 1) * height);
49
+ for (let y = 0; y < height; y++) {
50
+ raw[y * (stride + 1)] = 0;
51
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
52
+ }
53
+ const ihdr = new Uint8Array(13);
54
+ const ihdrView = new DataView(ihdr.buffer);
55
+ ihdrView.setUint32(0, width, false);
56
+ ihdrView.setUint32(4, height, false);
57
+ ihdr[8] = 8; // bit depth
58
+ ihdr[9] = 6; // color type: RGBA
59
+ // [10] compression, [11] filter, [12] interlace all 0
60
+ const idat = new Uint8Array(deflateSync(raw));
61
+ const chunks = [
62
+ SIGNATURE,
63
+ chunk("IHDR", ihdr),
64
+ chunk("IDAT", idat),
65
+ chunk("IEND", new Uint8Array(0)),
66
+ ];
67
+ const total = chunks.reduce((n, c) => n + c.length, 0);
68
+ const png = new Uint8Array(total);
69
+ let offset = 0;
70
+ for (const c of chunks) {
71
+ png.set(c, offset);
72
+ offset += c.length;
73
+ }
74
+ return png;
75
+ });
76
+ export const toBuffer = (fb) => {
77
+ return encodePng(fb.rgba, fb.width, fb.height);
78
+ };
79
+ export const toFile = (fb, outPath) => {
80
+ return Effect.gen(function* () {
81
+ const fs = yield* FileSystem;
82
+ const png = yield* toBuffer(fb);
83
+ yield* fs.writeFile(outPath, png);
84
+ });
85
+ };
@@ -0,0 +1,225 @@
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
+ export interface Vec3 {
22
+ readonly x: number;
23
+ readonly y: number;
24
+ readonly z: number;
25
+ }
26
+ export interface Vec2 {
27
+ readonly x: number;
28
+ readonly y: number;
29
+ }
30
+ /** The camera view, as it arrives on the frame from the runner. */
31
+ export interface CameraView {
32
+ readonly x: number;
33
+ readonly y: number;
34
+ readonly z: number;
35
+ readonly rotX: number;
36
+ readonly rotY: number;
37
+ readonly rotZ: number;
38
+ readonly focalLength: number;
39
+ /**
40
+ * view-space distance to the sharp plane (depth of field). Runner-filled
41
+ * to the resting camera distance, so the z=0 plane is in focus untouched.
42
+ */
43
+ readonly focusDistance: number;
44
+ /** depth-of-field blur strength; 0 (the default) = pinhole, DoF off */
45
+ readonly aperture: number;
46
+ }
47
+ /**
48
+ * A projected point: screen position, the view-space depth used as the
49
+ * painter's-sort key (larger = farther from the camera), and the uniform
50
+ * `scale` a billboard at this point receives (focalLength / depth).
51
+ */
52
+ export interface Projected {
53
+ readonly x: number;
54
+ readonly y: number;
55
+ readonly depth: number;
56
+ readonly scale: number;
57
+ }
58
+ /**
59
+ * Default focal length (px) for a comp of the given width — After Effects'
60
+ * default lens: 50mm on 36mm-wide film, so `zoom = width × 50/36`. Width-
61
+ * relative so perspective strength (how much a given z moves/scales a shape)
62
+ * reads the same at every output resolution.
63
+ */
64
+ export declare const defaultFocalLength: (width: number) => number;
65
+ /**
66
+ * The resting camera sits this far back on +z, looking toward -z, so that
67
+ * the `z = 0` plane is exactly `focalLength` in front of it — the identity
68
+ * invariant. A camera authored without a `z` uses this.
69
+ */
70
+ export declare const defaultCameraZ: (focalLength: number) => number;
71
+ /** Optional point of interest carried beside a camera view (world coords). */
72
+ export interface PointOfInterest {
73
+ readonly poiX?: number;
74
+ readonly poiY?: number;
75
+ readonly poiZ?: number;
76
+ }
77
+ /**
78
+ * The auto-orient Euler angles (yaw + pitch, no roll) aiming a camera at
79
+ * `poi` from its WORLD position (viewport-center pan already composed in —
80
+ * see `resolveCamera`). The view transform flips z before rotating
81
+ * (in-front is +z), which inverts rotation handedness vs. world space;
82
+ * that subtlety is handled here, exactly once, pinned by tests that
83
+ * project the POI and assert it lands on the viewport center.
84
+ */
85
+ export declare const lookAtOrientation: (position: Vec3, poi: Vec3) => {
86
+ rotX: number;
87
+ rotY: number;
88
+ };
89
+ /**
90
+ * The effective camera view: when a point of interest is set, auto-orient
91
+ * toward it with the explicit Euler composing AFTER the aim — the After
92
+ * Effects two-node rule. "After" means the user's rotation applies in the
93
+ * camera's own frame (so a lone `rotZ` rolls about the view axis and the
94
+ * POI stays centered); the exact composed rotation is extracted back to
95
+ * the fixed Rz·Ry·Rx Euler convention the view transform consumes.
96
+ * Camera `x`/`y` are pan-from-viewport-center, so the world position
97
+ * composes `origin` in before aiming. Absent POI is a pass-through
98
+ * (one-node camera, unchanged); a partial POI is a loud defect. The
99
+ * user's rotation fields are never written back — derivation happens
100
+ * here, at view-assembly time.
101
+ */
102
+ export declare const resolveCamera: (camera: CameraView & PointOfInterest, origin: Vec2) => CameraView;
103
+ /**
104
+ * The four world-space corners of a flat rectangular plane. The rect spans
105
+ * local `[x, x+width] × [y, y+height]` on the z=0 plane; each corner is
106
+ * rotated about the rect's local origin `(x, y)` by the Euler orientation,
107
+ * then translated to `world` (the plane's composed world anchor minus its own
108
+ * local x/y, so rotation pivots on the anchor). Winding: TL, TR, BR, BL.
109
+ */
110
+ export declare const planeCorners: (rect: {
111
+ x: number;
112
+ y: number;
113
+ width: number;
114
+ height: number;
115
+ }, orientation: {
116
+ rotX: number;
117
+ rotY: number;
118
+ rotZ: number;
119
+ }, world: Vec3) => [Vec3, Vec3, Vec3, Vec3];
120
+ /**
121
+ * A world point in the camera's frame, measured relative to `origin` (the
122
+ * viewport center). The camera's `x`/`y` are a pan *from* the origin, so a
123
+ * resting camera (pan 0) keeps world-x/y = screen-x/y at unit scale. `+z` in
124
+ * the result is in front of the camera: the camera looks down world -z, so a
125
+ * resting camera at z=focalLength sees the z=0 plane at view-z = focalLength.
126
+ */
127
+ export declare const toView: (camera: CameraView, p: Vec3, origin: Vec2) => Vec3;
128
+ /**
129
+ * Project a world point to screen. `origin` is the screen point the camera's
130
+ * optical axis passes through — the viewport center — so pan/zoom read as
131
+ * "into the middle of the shot", while a resting camera reproduces plain-2D
132
+ * placement. A point at or behind the camera (view-z <= 0) has no valid
133
+ * projection; `depth` is still returned for sorting, `scale` clamps to 0.
134
+ */
135
+ export declare const project: (camera: CameraView, p: Vec3, origin: Vec2) => Projected;
136
+ /**
137
+ * The affine placement for a camera-facing billboard whose local origin is
138
+ * `anchor`: translate the anchor's projected screen point and uniformly
139
+ * scale by its perspective scale. Returned as SVG matrix components
140
+ * `(a b c d e f)` — a and d carry the scale, e/f the translation — so a shape
141
+ * authored in local space lands correctly without knowing about the camera.
142
+ */
143
+ export interface Affine {
144
+ readonly a: number;
145
+ readonly b: number;
146
+ readonly c: number;
147
+ readonly d: number;
148
+ readonly e: number;
149
+ readonly f: number;
150
+ }
151
+ export declare const billboardAffine: (proj: Projected, anchor: Vec2) => Affine;
152
+ /**
153
+ * Project the world-space corners of a tilted plane to a screen polygon.
154
+ * The polygon is clipped against the near plane in view space
155
+ * (Sutherland–Hodgman, winding preserved) before the per-vertex perspective
156
+ * divide, so a plane crossing the camera renders its visible part instead
157
+ * of folding — a quad straddling the plane yields up to 5 vertices; a plane
158
+ * fully behind yields none (cull). Fully in front, this is plain per-corner
159
+ * projection: a receding plane is a true perspective trapezoid.
160
+ */
161
+ export declare const projectPlane: (camera: CameraView, corners: ReadonlyArray<Vec3>, origin: Vec2) => Array<Vec2>;
162
+ /** A projected segment: exact screen endpoints plus its single sort key. */
163
+ export interface ProjectedSegment {
164
+ readonly a: Vec2;
165
+ readonly b: Vec2;
166
+ /** midpoint view-space depth of the visible (clipped) segment */
167
+ readonly depth: number;
168
+ /** perspective scale at that midpoint (focalLength / depth) */
169
+ readonly scale: number;
170
+ }
171
+ /**
172
+ * Project a world-space segment (a skeletal shape's two endpoints) to
173
+ * screen. Both endpoints go to view space, are clipped against the near
174
+ * plane (lerp to z = NEAR — the 1D case of projectPlane's polygon clip),
175
+ * and are projected individually, so a line spanning depth foreshortens
176
+ * per endpoint. Returns `undefined` when the segment lies entirely behind
177
+ * the near plane (cull). `depth`/`scale` come from the visible midpoint.
178
+ */
179
+ export declare const projectSegment: (camera: CameraView, a: Vec3, b: Vec3, origin: Vec2) => ProjectedSegment | undefined;
180
+ /** A subpath of a skeletal path: world-space points, closed or open. */
181
+ export interface Subpath3 {
182
+ readonly points: ReadonlyArray<Vec3>;
183
+ readonly closed: boolean;
184
+ }
185
+ /** A projected path: screen subpaths plus the path's single sort key. */
186
+ export interface ProjectedPath {
187
+ readonly subpaths: ReadonlyArray<{
188
+ readonly points: ReadonlyArray<Vec2>;
189
+ readonly closed: boolean;
190
+ }>;
191
+ /** mean view-space depth of the near-visible points */
192
+ readonly depth: number;
193
+ /** perspective scale at that depth (focalLength / depth) */
194
+ readonly scale: number;
195
+ }
196
+ /**
197
+ * Project a skeletal path's world-space subpaths to screen — the N-point
198
+ * generalization of `projectSegment`. Every point goes to view space and is
199
+ * projected individually, so a path spanning depth foreshortens per point.
200
+ * Near-plane clipping is per subpath: a closed subpath clips as a polygon
201
+ * (Sutherland–Hodgman, winding preserved — the tilted-plane treatment); an
202
+ * open subpath clips per span (lerp to z = NEAR) and SPLITS into separate
203
+ * visible pieces when interior points fall behind the plane. Returns
204
+ * `undefined` when nothing survives (cull). `depth`/`scale` come from the
205
+ * mean view depth of all emitted points — one key per path, the same
206
+ * ceiling as a segment's midpoint.
207
+ */
208
+ export declare const projectPath: (camera: CameraView, subpaths: ReadonlyArray<Subpath3>, origin: Vec2) => ProjectedPath | undefined;
209
+ /**
210
+ * Clip a screen-space segment to a rectangle (Liang–Barsky). Returns the
211
+ * clipped pair, or `undefined` when the segment lies entirely outside.
212
+ * ThorVG's software rasterizer pays stroke cost proportional to a path's
213
+ * full extent — offscreen included — so segments are clipped to the
214
+ * viewport (plus a stroke margin) before painting; a near-camera line can
215
+ * project tens of thousands of px wide otherwise (measured ~7× the cost
216
+ * of its visible part).
217
+ */
218
+ export declare const clipSegmentToRect: (a: Vec2, b: Vec2, min: Vec2, max: Vec2) => readonly [Vec2, Vec2] | undefined;
219
+ /**
220
+ * The view-space depth of a world point — the painter's-sort key alone.
221
+ * With no camera rotation this is `camera.z - p.z`; rotation tilts the
222
+ * depth axis, so the full view transform is used. Origin only shifts x/y,
223
+ * never depth, so a zero origin suffices.
224
+ */
225
+ export declare const depthOf: (camera: CameraView, p: Vec3) => number;