@glissade/scene 0.1.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.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @glissade/scene
2
+
3
+ The scene graph and the **DisplayList IR**: nodes (Group, Rect, Circle, Text, Image, Video) whose every animatable property is a signal, `createScene`, and the canonical `evaluate(scene, timeline, t) → DisplayList`. Text layout (explicit fonts, Intl.Segmenter line breaking) is deterministic across browser and headless render. Yoga flexbox lives behind the LayoutEngine seam at the separate `@glissade/scene/layout` entry — the base path never pays for wasm — including `width/height: 'auto'` content sizing and `computedSize()`.
4
+
5
+ ```sh
6
+ npm i @glissade/scene @glissade/core
7
+ ```
8
+
9
+ ```ts
10
+ import { createScene, Circle, evaluate } from '@glissade/scene';
11
+
12
+ const scene = createScene({
13
+ size: { w: 640, h: 360 },
14
+ children: [new Circle({ id: 'dot', radius: 40, fill: '#e6a700', position: [320, 180] })],
15
+ });
16
+ const displayList = evaluate(scene, doc, 1.25); // pure, serializable, renderer-agnostic
17
+ ```
18
+
19
+ ## Part of glissade
20
+
21
+ *(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
22
+
23
+ - [Repository & full README](https://github.com/tyevco/glissade)
24
+ - [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
25
+
26
+ Apache-2.0.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as DisplayList, B as StrokeStyle, C as PropInit, D as estimatingMeasurer, E as breakLines, F as Paint, G as fromTRS, H as IDENTITY, I as PathSeg, K as matEquals, L as Rect$1, M as DrawCommand, N as FilterSpec, O as quantize, P as FontSpec, R as Resource, S as NodeProps, T as TextMetricsLite, U as Mat2x3, V as createDisplayListBuilder, W as applyToPoint, _ as Video, a as LayoutEngineMissingError, b as EvalContext, c as setLayoutEngine, d as ImageNode, f as ImageProps, g as TextProps, h as Text, i as LayoutEngine, j as DisplayListBuilder, k as BlendMode, l as Circle, m as ShapeProps, n as LayoutChildSpec, o as getLayoutEngine, p as Rect, q as multiply, r as LayoutContainerSpec, s as requireLayoutEngine, t as LayoutBox, u as Group, v as VideoProps, w as TextMeasurer, x as Node, y as BindablePropTarget, z as ResourceId } from "./layoutEngine.js";
1
+ import { $ as invert, A as breakLines, B as Paint, C as EvalContext, D as PropInit, E as NodeProps, F as DisplayListBuilder, G as StrokeStyle, H as Rect$1, I as DrawCommand, J as validateFilters, K as createDisplayListBuilder, L as FilterSpec, M as quantize, N as BlendMode, O as TextMeasurer, P as DisplayList, Q as fromTRS, R as FilterValidationError, S as BindablePropTarget, T as Node, U as Resource, V as PathSeg, W as ResourceId, X as Mat2x3, Y as IDENTITY, Z as applyToPoint, _ as ShapeProps, a as LayoutEngineMissingError, b as Video, c as requireLayoutEngine, d as Group, et as matEquals, f as ImageNode, g as Rect, h as PathProps, i as LayoutEngine, j as estimatingMeasurer, k as TextMetricsLite, l as setLayoutEngine, m as Path, n as LayoutChildSpec, p as ImageProps, q as filtersToCanvasFilter, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox, tt as multiply, u as Circle, v as Text, w as HitArea, x as VideoProps, y as TextProps, z as FontSpec } from "./layoutEngine.js";
2
2
  import { BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
3
3
 
4
4
  //#region src/assets.d.ts
@@ -37,6 +37,77 @@ declare class ColdAssetError extends Error {
37
37
  constructor(assetId: string, detail: string, mediaT?: number);
38
38
  }
39
39
  //#endregion
40
+ //#region src/raster2d.d.ts
41
+ /** The structural path surface buildPath drives — DOM Path2D and @napi-rs Path2D both satisfy it. */
42
+ interface PathLike {
43
+ moveTo(x: number, y: number): void;
44
+ lineTo(x: number, y: number): void;
45
+ bezierCurveTo(x1: number, y1: number, x2: number, y2: number, x: number, y: number): void;
46
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number): void;
47
+ ellipse(cx: number, cy: number, rx: number, ry: number, rot: number, a0: number, a1: number): void;
48
+ closePath(): void;
49
+ }
50
+ /** The exact 2D-context surface the interpreter uses — nothing more. */
51
+ interface Ctx2DLike<TPath, TDrawable> {
52
+ save(): void;
53
+ restore(): void;
54
+ transform(a: number, b: number, c: number, d: number, e: number, f: number): void;
55
+ resetTransform(): void;
56
+ getTransform(): unknown;
57
+ setTransform(m: unknown): void;
58
+ clearRect(x: number, y: number, w: number, h: number): void;
59
+ clip(path: TPath, rule: 'nonzero' | 'evenodd'): void;
60
+ fill(path: TPath): void;
61
+ stroke(path: TPath): void;
62
+ fillText(text: string, x: number, y: number): void;
63
+ drawImage(image: TDrawable, x: number, y: number, w?: number, h?: number): void;
64
+ drawImage(image: TDrawable, sx: number, sy: number, sw: number, sh: number, x: number, y: number, w: number, h: number): void;
65
+ setLineDash(segments: number[]): void;
66
+ fillStyle: unknown;
67
+ strokeStyle: unknown;
68
+ lineWidth: number;
69
+ lineCap: string;
70
+ lineJoin: string;
71
+ font: string;
72
+ textBaseline: string;
73
+ textAlign: string;
74
+ globalAlpha: number;
75
+ globalCompositeOperation: string;
76
+ filter: string;
77
+ imageSmoothingEnabled: boolean;
78
+ }
79
+ interface CanvasLike {
80
+ width: number;
81
+ height: number;
82
+ }
83
+ /** What a backend supplies: constructors and context access for its canvas flavor. */
84
+ interface Raster2DHost<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
85
+ context(canvas: TCanvas): Ctx2DLike<TPath, TDrawable>;
86
+ createCanvas(w: number, h: number): TCanvas;
87
+ newPath(): TPath;
88
+ }
89
+ declare function fontString(font: FontSpec): string;
90
+ declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
91
+ private readonly host;
92
+ private readonly pool;
93
+ private readonly pathCache;
94
+ private readonly images;
95
+ private readonly videos;
96
+ constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>);
97
+ /** Register a decoded still (kind 'image' assets). */
98
+ setImageAsset(assetId: string, image: TDrawable): void;
99
+ /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
100
+ setVideoAsset(assetId: string, source: VideoFrameSource): void;
101
+ dispose(): void;
102
+ private resolveDrawable;
103
+ private path;
104
+ private buildPath;
105
+ private acquire;
106
+ private release;
107
+ /** The command walk — order and operations identical to the pre-extraction twins. */
108
+ render(target: TCanvas, list: DisplayList): void;
109
+ }
110
+ //#endregion
40
111
  //#region src/scene.d.ts
41
112
  interface Scene {
42
113
  readonly root: Group;
@@ -86,4 +157,4 @@ declare function bindScene(scene: Scene, doc: Timeline): BindingCacheEntry;
86
157
  */
87
158
  declare function evaluate(scene: Scene, doc: Timeline, t: number): DisplayList;
88
159
  //#endregion
89
- export { type BindablePropTarget, type BlendMode, Circle, ColdAssetError, type DisplayList, type DisplayListBuilder, type DrawCommand, DuplicateNodeIdError, type EvalContext, type FilterSpec, type FontSpec, Group, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type Mat2x3, Node, type NodeProps, type Paint, type PathSeg, type PropInit, Rect, type Rect$1 as RectShape, type Resource, type ResourceId, type Scene, type SceneInit, type SceneModule, type ShapeProps, type StrokeStyle, Text, type TextMeasurer, type TextMetricsLite, type TextProps, Video, type VideoFrameSource, type VideoProps, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, fromTRS, getLayoutEngine, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine };
160
+ export { type BindablePropTarget, type BlendMode, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, type DisplayList, type DisplayListBuilder, type DrawCommand, DuplicateNodeIdError, type EvalContext, type FilterSpec, FilterValidationError, type FontSpec, Group, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type Mat2x3, Node, type NodeProps, type Paint, Path, type PathLike, type PathProps, type PathSeg, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type Resource, type ResourceId, type Scene, type SceneInit, type SceneModule, type ShapeProps, type StrokeStyle, Text, type TextMeasurer, type TextMetricsLite, type TextProps, Video, type VideoFrameSource, type VideoProps, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as applyToPoint, a as Circle, b as multiply, c as Rect, d as Node, f as breakLines, g as IDENTITY, h as createDisplayListBuilder, i as setLayoutEngine, l as Text, m as quantize, n as getLayoutEngine, o as Group, p as estimatingMeasurer, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Video, v as fromTRS, y as matEquals } from "./layoutEngine.js";
1
+ import { C as invert, S as fromTRS, T as multiply, _ as createDisplayListBuilder, a as Circle, b as IDENTITY, c as Path, d as Video, f as Node, g as FilterValidationError, h as quantize, i as setLayoutEngine, l as Rect, m as estimatingMeasurer, n as getLayoutEngine, o as Group, p as breakLines, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Text, v as filtersToCanvasFilter, w as matEquals, x as applyToPoint, y as validateFilters } from "./layoutEngine.js";
2
2
  import { bindTimeline, compileTimeline, createPlayhead, evaluateAt } from "@glissade/core";
3
3
  //#region src/assets.ts
4
4
  var ColdAssetError = class extends Error {
@@ -15,6 +15,201 @@ var ColdAssetError = class extends Error {
15
15
  }
16
16
  };
17
17
  //#endregion
18
+ //#region src/raster2d.ts
19
+ /**
20
+ * The shared DisplayList interpreter (§3.4): one command walk over the
21
+ * canvas-2d-shaped API, generic over the host's canvas/path/drawable types.
22
+ * backend-canvas2d (DOM) and backend-skia (@napi-rs) instantiate it with
23
+ * four-line adapters, so the twin rasterizers structurally cannot drift —
24
+ * the golden + SSIM suites verify the refactor preserved every byte.
25
+ */
26
+ function fontString(font) {
27
+ return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
28
+ }
29
+ var Raster2D = class {
30
+ host;
31
+ pool = [];
32
+ pathCache = /* @__PURE__ */ new WeakMap();
33
+ images = /* @__PURE__ */ new Map();
34
+ videos = /* @__PURE__ */ new Map();
35
+ constructor(host) {
36
+ this.host = host;
37
+ }
38
+ /** Register a decoded still (kind 'image' assets). */
39
+ setImageAsset(assetId, image) {
40
+ this.images.set(assetId, image);
41
+ }
42
+ /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
43
+ setVideoAsset(assetId, source) {
44
+ this.videos.set(assetId, source);
45
+ }
46
+ dispose() {
47
+ this.pool.length = 0;
48
+ }
49
+ resolveDrawable(res, id) {
50
+ if (res.kind === "image") {
51
+ const img = this.images.get(res.assetId);
52
+ if (!img) throw new ColdAssetError(res.assetId, "no decoded image registered");
53
+ return img;
54
+ }
55
+ if (res.kind === "videoFrame") {
56
+ const source = this.videos.get(res.assetId);
57
+ if (!source) throw new ColdAssetError(res.assetId, "no VideoFrameSource registered", res.mediaT);
58
+ try {
59
+ return source.getFrameSync(res.mediaT);
60
+ } catch (e) {
61
+ if (e instanceof ColdAssetError) throw new ColdAssetError(res.assetId, e.detail, res.mediaT);
62
+ throw e;
63
+ }
64
+ }
65
+ throw new Error(`resource ${id} is not drawable`);
66
+ }
67
+ path(resources, id) {
68
+ const res = resources[id];
69
+ if (!res || res.kind !== "path") throw new Error(`resource ${id} is not a path`);
70
+ let p = this.pathCache.get(res);
71
+ if (!p) {
72
+ p = this.buildPath(res.segs);
73
+ this.pathCache.set(res, p);
74
+ }
75
+ return p;
76
+ }
77
+ buildPath(segs) {
78
+ const p = this.host.newPath();
79
+ for (const seg of segs) switch (seg[0]) {
80
+ case "M":
81
+ p.moveTo(seg[1], seg[2]);
82
+ break;
83
+ case "L":
84
+ p.lineTo(seg[1], seg[2]);
85
+ break;
86
+ case "C":
87
+ p.bezierCurveTo(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6]);
88
+ break;
89
+ case "Q":
90
+ p.quadraticCurveTo(seg[1], seg[2], seg[3], seg[4]);
91
+ break;
92
+ case "E":
93
+ p.ellipse(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6], seg[7]);
94
+ break;
95
+ case "Z":
96
+ p.closePath();
97
+ break;
98
+ }
99
+ return p;
100
+ }
101
+ acquire(w, h) {
102
+ const pooled = this.pool.pop();
103
+ if (pooled) {
104
+ if (pooled.width !== w) pooled.width = w;
105
+ if (pooled.height !== h) pooled.height = h;
106
+ return pooled;
107
+ }
108
+ return this.host.createCanvas(w, h);
109
+ }
110
+ release(canvas) {
111
+ if (this.pool.length < 8) this.pool.push(canvas);
112
+ }
113
+ /** The command walk — order and operations identical to the pre-extraction twins. */
114
+ render(target, list) {
115
+ const { w, h } = list.size;
116
+ if (target.width !== w) target.width = w;
117
+ if (target.height !== h) target.height = h;
118
+ const base = this.host.context(target);
119
+ base.resetTransform();
120
+ base.clearRect(0, 0, w, h);
121
+ const layers = [{
122
+ ctx: base,
123
+ canvas: null,
124
+ opacity: 1,
125
+ blend: "source-over"
126
+ }];
127
+ const ctxOf = () => layers[layers.length - 1].ctx;
128
+ for (const cmd of list.commands) switch (cmd.op) {
129
+ case "save":
130
+ ctxOf().save();
131
+ break;
132
+ case "restore":
133
+ ctxOf().restore();
134
+ break;
135
+ case "transform":
136
+ ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
137
+ break;
138
+ case "clip":
139
+ ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
140
+ break;
141
+ case "fillPath": {
142
+ const ctx = ctxOf();
143
+ ctx.fillStyle = cmd.paint.color;
144
+ ctx.fill(this.path(list.resources, cmd.path));
145
+ break;
146
+ }
147
+ case "strokePath": {
148
+ const ctx = ctxOf();
149
+ ctx.strokeStyle = cmd.paint.color;
150
+ ctx.lineWidth = cmd.stroke.width;
151
+ ctx.lineCap = cmd.stroke.cap ?? "butt";
152
+ ctx.lineJoin = cmd.stroke.join ?? "miter";
153
+ if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
154
+ ctx.stroke(this.path(list.resources, cmd.path));
155
+ if (cmd.stroke.dash) ctx.setLineDash([]);
156
+ break;
157
+ }
158
+ case "fillText": {
159
+ const ctx = ctxOf();
160
+ ctx.font = fontString(cmd.font);
161
+ ctx.fillStyle = cmd.paint.color;
162
+ ctx.textBaseline = "alphabetic";
163
+ ctx.textAlign = cmd.align ?? "left";
164
+ ctx.fillText(cmd.text, cmd.x, cmd.y);
165
+ break;
166
+ }
167
+ case "drawImage": {
168
+ const res = list.resources[cmd.image];
169
+ if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
170
+ const drawable = this.resolveDrawable(res, cmd.image);
171
+ const ctx = ctxOf();
172
+ if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
173
+ const { x, y, w: dw, h: dh } = cmd.dst;
174
+ if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
175
+ else ctx.drawImage(drawable, x, y, dw, dh);
176
+ break;
177
+ }
178
+ case "pushGroup": {
179
+ const parent = ctxOf();
180
+ const layerCanvas = this.acquire(w, h);
181
+ const layerCtx = this.host.context(layerCanvas);
182
+ layerCtx.resetTransform();
183
+ layerCtx.clearRect(0, 0, w, h);
184
+ layerCtx.setTransform(parent.getTransform());
185
+ layers.push({
186
+ ctx: layerCtx,
187
+ canvas: layerCanvas,
188
+ opacity: cmd.opacity,
189
+ blend: cmd.blend,
190
+ filter: filtersToCanvasFilter(cmd.filters)
191
+ });
192
+ break;
193
+ }
194
+ case "popGroup": {
195
+ const layer = layers.pop();
196
+ if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
197
+ const parent = ctxOf();
198
+ parent.save();
199
+ parent.resetTransform();
200
+ parent.globalAlpha = layer.opacity;
201
+ if (layer.filter !== void 0 && layer.filter !== "none") parent.filter = layer.filter;
202
+ parent.globalCompositeOperation = layer.blend;
203
+ parent.drawImage(layer.canvas, 0, 0);
204
+ parent.restore();
205
+ this.release(layer.canvas);
206
+ break;
207
+ }
208
+ }
209
+ if (layers.length !== 1) throw new Error("unbalanced pushGroup/popGroup in DisplayList");
210
+ }
211
+ };
212
+ //#endregion
18
213
  //#region src/scene.ts
19
214
  /**
20
215
  * Scene assembly + the canonical evaluate(scene, timeline, t) (DESIGN.md §2.5,
@@ -27,12 +222,13 @@ var DuplicateNodeIdError = class extends Error {
27
222
  this.name = "DuplicateNodeIdError";
28
223
  }
29
224
  };
30
- function indexNodes(root, into) {
225
+ function indexNodes(root, into, measurerSource) {
226
+ root.measurerSource = measurerSource;
31
227
  if (root.id !== void 0) {
32
228
  if (into.has(root.id)) throw new DuplicateNodeIdError(root.id);
33
229
  into.set(root.id, root);
34
230
  }
35
- if (root instanceof Group) for (const child of root.children) indexNodes(child, into);
231
+ if (root instanceof Group) for (const child of root.children) indexNodes(child, into, measurerSource);
36
232
  }
37
233
  function createScene(init) {
38
234
  const root = new Group({
@@ -40,9 +236,9 @@ function createScene(init) {
40
236
  children: init.children
41
237
  });
42
238
  const nodes = /* @__PURE__ */ new Map();
43
- indexNodes(root, nodes);
44
239
  const playhead = createPlayhead();
45
240
  let measurer = estimatingMeasurer;
241
+ indexNodes(root, nodes, () => measurer);
46
242
  return {
47
243
  root,
48
244
  nodes,
@@ -99,4 +295,4 @@ function evaluate(scene, doc, t) {
99
295
  });
100
296
  }
101
297
  //#endregion
102
- export { Circle, ColdAssetError, DuplicateNodeIdError, Group, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Rect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, fromTRS, getLayoutEngine, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine };
298
+ export { Circle, ColdAssetError, DuplicateNodeIdError, FilterValidationError, Group, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Path, Raster2D, Rect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
package/dist/layout.d.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { C as PropInit, S as NodeProps, a as LayoutEngineMissingError, b as EvalContext, c as setLayoutEngine, i as LayoutEngine, j as DisplayListBuilder, n as LayoutChildSpec, o as getLayoutEngine, r as LayoutContainerSpec, t as LayoutBox, u as Group, x as Node } from "./layoutEngine.js";
1
+ import { C as EvalContext, D as PropInit, E as NodeProps, F as DisplayListBuilder, O as TextMeasurer, T as Node, a as LayoutEngineMissingError, d as Group, i as LayoutEngine, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox } from "./layoutEngine.js";
2
2
  import { BindableSignal } from "@glissade/core";
3
3
 
4
4
  //#region src/layout.d.ts
5
5
 
6
6
  interface LayoutProps extends NodeProps {
7
7
  children?: Node[];
8
- width?: PropInit<number>;
9
- height?: PropInit<number>;
8
+ /** 'auto': the axis sizes itself from content (Yoga content sizing). */
9
+ width?: PropInit<number> | 'auto';
10
+ height?: PropInit<number> | 'auto';
10
11
  direction?: 'row' | 'column';
11
12
  gap?: PropInit<number>;
12
13
  padding?: PropInit<number>;
@@ -33,8 +34,21 @@ declare class Layout extends Group {
33
34
  readonly direction: 'row' | 'column';
34
35
  readonly justify: LayoutContainerSpec['justify'];
35
36
  readonly align: LayoutContainerSpec['align'];
37
+ /** Content-sized axes ('auto'): the size signal is ignored, Yoga computes it. */
38
+ readonly autoWidth: boolean;
39
+ readonly autoHeight: boolean;
36
40
  constructor(props?: LayoutProps);
37
- intrinsicSize(): {
41
+ intrinsicSize(measurer: TextMeasurer): {
42
+ w: number;
43
+ h: number;
44
+ };
45
+ /**
46
+ * The resolved container size — content-driven on 'auto' axes. Pure pull:
47
+ * reads the same signals the flow reads, so a sibling bound to it
48
+ * (e.g. panelBg height = () => panel.computedSize().h) tracks every input.
49
+ * The measurer defaults to the scene-injected one (estimating pre-scene).
50
+ */
51
+ computedSize(measurer?: TextMeasurer): {
38
52
  w: number;
39
53
  h: number;
40
54
  };
@@ -43,4 +57,4 @@ declare class Layout extends Group {
43
57
  /** Load Yoga (wasm) and register it as the active LayoutEngine. Idempotent. */
44
58
  declare function loadYogaLayoutEngine(): Promise<LayoutEngine>;
45
59
  //#endregion
46
- export { Layout, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, LayoutProps, getLayoutEngine, loadYogaLayoutEngine, setLayoutEngine };
60
+ export { Layout, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, LayoutProps, type LayoutResult, getLayoutEngine, loadYogaLayoutEngine, setLayoutEngine };
package/dist/layout.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as setLayoutEngine, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
1
+ import { i as setLayoutEngine, m as estimatingMeasurer, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
2
2
  import { signal } from "@glissade/core";
3
3
  //#region src/layout.ts
4
4
  /**
@@ -29,12 +29,21 @@ var Layout = class extends Group {
29
29
  direction;
30
30
  justify;
31
31
  align;
32
+ /** Content-sized axes ('auto'): the size signal is ignored, Yoga computes it. */
33
+ autoWidth;
34
+ autoHeight;
32
35
  #memoKey = "";
33
- #memoBoxes = [];
36
+ #memoResult = {
37
+ width: 0,
38
+ height: 0,
39
+ boxes: []
40
+ };
34
41
  constructor(props = {}) {
35
42
  super(props);
36
- this.width = initProp(signal(0), props.width);
37
- this.height = initProp(signal(0), props.height);
43
+ this.autoWidth = props.width === "auto";
44
+ this.autoHeight = props.height === "auto";
45
+ this.width = initProp(signal(0), this.autoWidth ? void 0 : props.width);
46
+ this.height = initProp(signal(0), this.autoHeight ? void 0 : props.height);
38
47
  this.gap = initProp(signal(0), props.gap);
39
48
  this.padding = initProp(signal(0), props.padding);
40
49
  this.direction = props.direction ?? "row";
@@ -45,16 +54,27 @@ var Layout = class extends Group {
45
54
  this.registerTarget("gap", this.gap);
46
55
  this.registerTarget("padding", this.padding);
47
56
  }
48
- intrinsicSize() {
49
- return {
57
+ intrinsicSize(measurer) {
58
+ if (!this.autoWidth && !this.autoHeight) return {
50
59
  w: this.width(),
51
60
  h: this.height()
52
61
  };
62
+ return this.#compute(measurer).size;
53
63
  }
54
- draw(out, ctx) {
64
+ /**
65
+ * The resolved container size — content-driven on 'auto' axes. Pure pull:
66
+ * reads the same signals the flow reads, so a sibling bound to it
67
+ * (e.g. panelBg height = () => panel.computedSize().h) tracks every input.
68
+ * The measurer defaults to the scene-injected one (estimating pre-scene).
69
+ */
70
+ computedSize(measurer) {
71
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
72
+ return this.#compute(m).size;
73
+ }
74
+ #compute(measurer) {
55
75
  const container = {
56
- width: this.width(),
57
- height: this.height(),
76
+ width: this.autoWidth ? "auto" : this.width(),
77
+ height: this.autoHeight ? "auto" : this.height(),
58
78
  direction: this.direction,
59
79
  gap: this.gap(),
60
80
  padding: this.padding(),
@@ -64,7 +84,7 @@ var Layout = class extends Group {
64
84
  const flowable = [];
65
85
  const absolute = [];
66
86
  this.children.forEach((child, index) => {
67
- const size = child.intrinsicSize(ctx.measurer);
87
+ const size = child.intrinsicSize(measurer);
68
88
  if (size) flowable.push({
69
89
  node: child,
70
90
  spec: {
@@ -77,13 +97,26 @@ var Layout = class extends Group {
77
97
  });
78
98
  const key = JSON.stringify([container, flowable.map((f) => f.spec)]);
79
99
  if (key !== this.#memoKey) {
80
- this.#memoBoxes = requireLayoutEngine().compute(container, flowable.map((f) => f.spec));
100
+ this.#memoResult = requireLayoutEngine().compute(container, flowable.map((f) => f.spec));
81
101
  this.#memoKey = key;
82
102
  }
83
- const boxes = this.#memoBoxes;
103
+ const size = {
104
+ w: this.autoWidth ? this.#memoResult.width : container.width,
105
+ h: this.autoHeight ? this.#memoResult.height : container.height
106
+ };
107
+ return {
108
+ result: this.#memoResult,
109
+ size,
110
+ flowable,
111
+ absolute
112
+ };
113
+ }
114
+ draw(out, ctx) {
115
+ const { result, size, flowable, absolute } = this.#compute(ctx.measurer);
116
+ const boxes = result.boxes;
84
117
  const order = [...flowable].sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.index - b.index);
85
- const ox = -container.width / 2;
86
- const oy = -container.height / 2;
118
+ const ox = -size.w / 2;
119
+ const oy = -size.h / 2;
87
120
  for (const child of absolute) child.emit(out, ctx);
88
121
  for (const entry of order) {
89
122
  const box = boxes[flowable.indexOf(entry)];
@@ -130,8 +163,8 @@ async function loadYogaLayoutEngine() {
130
163
  const engine = { compute(container, children) {
131
164
  const root = yoga.Node.create();
132
165
  try {
133
- root.setWidth(container.width);
134
- root.setHeight(container.height);
166
+ if (container.width !== "auto") root.setWidth(container.width);
167
+ if (container.height !== "auto") root.setHeight(container.height);
135
168
  root.setFlexDirection(container.direction === "row" ? FlexDirection.Row : FlexDirection.Column);
136
169
  root.setGap(Gutter.All, container.gap);
137
170
  root.setPadding(Edge.All, container.padding);
@@ -145,18 +178,22 @@ async function loadYogaLayoutEngine() {
145
178
  if (children[i].margin !== void 0) child.setMargin(Edge.All, children[i].margin);
146
179
  root.insertChild(child, i);
147
180
  }
148
- root.calculateLayout(container.width, container.height, Direction.LTR);
149
- const boxes = [];
181
+ root.calculateLayout(container.width === "auto" ? void 0 : container.width, container.height === "auto" ? void 0 : container.height, Direction.LTR);
182
+ const result = {
183
+ width: root.getComputedWidth(),
184
+ height: root.getComputedHeight(),
185
+ boxes: []
186
+ };
150
187
  for (let i = 0; i < children.length; i++) {
151
188
  const child = root.getChild(i);
152
- boxes.push({
189
+ result.boxes.push({
153
190
  x: child.getComputedLeft(),
154
191
  y: child.getComputedTop(),
155
192
  width: child.getComputedWidth(),
156
193
  height: child.getComputedHeight()
157
194
  });
158
195
  }
159
- return boxes;
196
+ return result;
160
197
  } finally {
161
198
  root.freeRecursive();
162
199
  }
@@ -1,4 +1,4 @@
1
- import { BindableSignal, ReadonlySignal, Vec2, Vec2Signal } from "@glissade/core";
1
+ import { BindableSignal, PathValue, ReadonlySignal, Vec2, Vec2Signal } from "@glissade/core";
2
2
 
3
3
  //#region src/matrix.d.ts
4
4
 
@@ -7,6 +7,8 @@ declare const IDENTITY: Mat2x3;
7
7
  declare function multiply(m1: Mat2x3, m2: Mat2x3): Mat2x3;
8
8
  /** Compose translate × rotate × scale (rotation in degrees). */
9
9
  declare function fromTRS(position: Vec2, rotationDeg: number, scale: Vec2): Mat2x3;
10
+ /** Inverse affine: [A | t]⁻¹ = [A⁻¹ | −A⁻¹t]; null when degenerate (det 0). */
11
+ declare function invert(m: Mat2x3): Mat2x3 | null;
10
12
  declare function applyToPoint(m: Mat2x3, p: Vec2): Vec2;
11
13
  declare function matEquals(a: Mat2x3, b: Mat2x3): boolean;
12
14
  //#endregion
@@ -50,11 +52,43 @@ interface FontSpec {
50
52
  weight?: number;
51
53
  style?: 'normal' | 'italic';
52
54
  }
53
- /** Enumerated at M2 (§3.4); reserved in the IR now. */
54
- interface FilterSpec {
55
- kind: string;
56
- [k: string]: unknown;
55
+ /**
56
+ * Group filters (§3.4): a CLOSED union — validated data, never a CSS
57
+ * passthrough string — limited to effects both rasterizers implement
58
+ * faithfully. Cross-backend parity is perceptual (SSIM), not byte-exact:
59
+ * filters are where rasterizers diverge most. Per-backend output stays
60
+ * deterministic on the pinned toolchain (golden-tested on Skia).
61
+ */
62
+ type FilterSpec = {
63
+ kind: 'blur'; /** Gaussian stdDeviation, px; ≥ 0. */
64
+ radius: number;
65
+ } | {
66
+ kind: 'drop-shadow';
67
+ dx: number;
68
+ dy: number; /** ≥ 0 */
69
+ blur: number;
70
+ color: string;
71
+ } | {
72
+ kind: 'brightness'; /** 1 = identity; ≥ 0. */
73
+ amount: number;
74
+ } | {
75
+ kind: 'contrast'; /** 1 = identity; ≥ 0. */
76
+ amount: number;
77
+ } | {
78
+ kind: 'saturate'; /** 1 = identity; ≥ 0. */
79
+ amount: number;
80
+ };
81
+ declare class FilterValidationError extends Error {
82
+ constructor(message: string);
57
83
  }
84
+ /** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
85
+ declare function validateFilters(filters: readonly FilterSpec[]): void;
86
+ /**
87
+ * Compile the validated union to the canvas 2D `ctx.filter` syntax — both
88
+ * backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
89
+ * ONLY place the CSS-like syntax appears; documents never carry it.
90
+ */
91
+ declare function filtersToCanvasFilter(filters: readonly FilterSpec[]): string;
58
92
  interface Rect$1 {
59
93
  x: number;
60
94
  y: number;
@@ -167,11 +201,26 @@ interface NodeProps {
167
201
  opacity?: PropInit<number>;
168
202
  blend?: PropInit<BlendMode>;
169
203
  zIndex?: PropInit<number>;
204
+ /** Group filters (§3.4): the subtree composites as a unit through them. */
205
+ filters?: PropInit<FilterSpec[]>;
170
206
  }
171
207
  interface BindablePropTarget {
172
208
  bindSource(fn: () => unknown): void;
173
209
  unbindSource(): void;
174
210
  }
211
+ /** Node-local hit-shape override (v2 §C.3) — fat targets for thin strokes. */
212
+ type HitArea = {
213
+ kind: 'rect';
214
+ x: number;
215
+ y: number;
216
+ w: number;
217
+ h: number;
218
+ } | {
219
+ kind: 'circle';
220
+ x: number;
221
+ y: number;
222
+ r: number;
223
+ };
175
224
  declare abstract class Node {
176
225
  readonly id: string | undefined;
177
226
  readonly position: Vec2Signal;
@@ -182,6 +231,18 @@ declare abstract class Node {
182
231
  readonly zIndex: BindableSignal<number>;
183
232
  readonly filters: BindableSignal<FilterSpec[]>;
184
233
  parent: Node | null;
234
+ /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
235
+ interactive: boolean;
236
+ /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
237
+ interactiveChildren: boolean;
238
+ /** v2 §C.3: explicit hit-shape override in node-local coordinates. */
239
+ hitArea: HitArea | undefined;
240
+ /**
241
+ * Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
242
+ * derived-size bindings (e.g. a background tracking Layout.computedSize)
243
+ * measure with the same rasterizer the flow uses.
244
+ */
245
+ measurerSource: (() => TextMeasurer) | null;
185
246
  readonly localMatrix: ReadonlySignal<Mat2x3>;
186
247
  readonly worldMatrix: ReadonlySignal<Mat2x3>;
187
248
  /** Track-target paths → bindable signals; subclasses register their own props. */
@@ -208,7 +269,7 @@ declare abstract class Node {
208
269
  x: number;
209
270
  y: number;
210
271
  };
211
- /** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
272
+ /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
212
273
  protected requiresGroup(): boolean;
213
274
  emit(out: DisplayListBuilder, ctx: EvalContext): void;
214
275
  }
@@ -262,6 +323,36 @@ declare class Circle extends Shape {
262
323
  };
263
324
  protected pathSegs(): PathSeg[];
264
325
  }
326
+ interface PathProps extends ShapeProps {
327
+ /** The geometry (§2.2 'path' value): bezier contours in vertex form, animatable via a track on '<id>/d'. */
328
+ data?: PropInit<PathValue>;
329
+ }
330
+ /**
331
+ * Arbitrary bezier geometry — the Lottie-import landing spot and the target
332
+ * of native path morphs. Coordinates are node-local (the node origin is
333
+ * wherever the author put 0,0); flow placement uses the control-point bounds.
334
+ */
335
+ declare class Path extends Shape {
336
+ readonly data: BindableSignal<PathValue>;
337
+ constructor(props?: PathProps);
338
+ /** Control-point bounding box (conservative: contains the true curve). */
339
+ bounds(): {
340
+ minX: number;
341
+ minY: number;
342
+ maxX: number;
343
+ maxY: number;
344
+ };
345
+ intrinsicSize(): {
346
+ w: number;
347
+ h: number;
348
+ };
349
+ /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
350
+ flowOffset(): {
351
+ x: number;
352
+ y: number;
353
+ };
354
+ protected pathSegs(): PathSeg[];
355
+ }
265
356
  interface ImageProps extends NodeProps {
266
357
  /** Asset id from the Timeline manifest (§2.3). */
267
358
  assetId: string;
@@ -373,17 +464,25 @@ interface LayoutChildSpec {
373
464
  margin?: number;
374
465
  }
375
466
  interface LayoutContainerSpec {
376
- width: number;
377
- height: number;
467
+ /** 'auto': size the axis from content (Yoga computes it). */
468
+ width: number | 'auto';
469
+ height: number | 'auto';
378
470
  direction: 'row' | 'column';
379
471
  gap: number;
380
472
  padding: number;
381
473
  justify: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
382
474
  align: 'start' | 'center' | 'end' | 'stretch';
383
475
  }
476
+ interface LayoutResult {
477
+ /** Resolved container size — equals the spec on fixed axes, content-driven on 'auto'. */
478
+ width: number;
479
+ height: number;
480
+ /** Child boxes (top-left relative to the container's top-left). */
481
+ boxes: LayoutBox[];
482
+ }
384
483
  interface LayoutEngine {
385
- /** Pure: child boxes (top-left relative to the container's top-left). */
386
- compute(container: LayoutContainerSpec, children: LayoutChildSpec[]): LayoutBox[];
484
+ /** Pure: resolved container size + child boxes. */
485
+ compute(container: LayoutContainerSpec, children: LayoutChildSpec[]): LayoutResult;
387
486
  }
388
487
  declare class LayoutEngineMissingError extends Error {
389
488
  constructor();
@@ -392,4 +491,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
392
491
  declare function getLayoutEngine(): LayoutEngine | null;
393
492
  declare function requireLayoutEngine(): LayoutEngine;
394
493
  //#endregion
395
- export { DisplayList as A, StrokeStyle as B, PropInit as C, estimatingMeasurer as D, breakLines as E, Paint as F, fromTRS as G, IDENTITY as H, PathSeg as I, matEquals as K, Rect$1 as L, DrawCommand as M, FilterSpec as N, quantize as O, FontSpec as P, Resource as R, NodeProps as S, TextMetricsLite as T, Mat2x3 as U, createDisplayListBuilder as V, applyToPoint as W, Video as _, LayoutEngineMissingError as a, EvalContext as b, setLayoutEngine as c, ImageNode as d, ImageProps as f, TextProps as g, Text as h, LayoutEngine as i, DisplayListBuilder as j, BlendMode as k, Circle as l, ShapeProps as m, LayoutChildSpec as n, getLayoutEngine as o, Rect as p, multiply as q, LayoutContainerSpec as r, requireLayoutEngine as s, LayoutBox as t, Group as u, VideoProps as v, TextMeasurer as w, Node as x, BindablePropTarget as y, ResourceId as z };
494
+ export { invert as $, breakLines as A, Paint as B, EvalContext as C, PropInit as D, NodeProps as E, DisplayListBuilder as F, StrokeStyle as G, Rect$1 as H, DrawCommand as I, validateFilters as J, createDisplayListBuilder as K, FilterSpec as L, quantize as M, BlendMode as N, TextMeasurer as O, DisplayList as P, fromTRS as Q, FilterValidationError as R, BindablePropTarget as S, Node as T, Resource as U, PathSeg as V, ResourceId as W, Mat2x3 as X, IDENTITY as Y, applyToPoint as Z, ShapeProps as _, LayoutEngineMissingError as a, Video as b, requireLayoutEngine as c, Group as d, matEquals as et, ImageNode as f, Rect as g, PathProps as h, LayoutEngine as i, estimatingMeasurer as j, TextMetricsLite as k, setLayoutEngine as l, Path as m, LayoutChildSpec as n, LayoutResult as o, ImageProps as p, filtersToCanvasFilter as q, LayoutContainerSpec as r, getLayoutEngine as s, LayoutBox as t, multiply as tt, Circle as u, Text as v, HitArea as w, VideoProps as x, TextProps as y, FontSpec as z };
@@ -36,6 +36,24 @@ function fromTRS(position, rotationDeg, scale) {
36
36
  z(position[1])
37
37
  ];
38
38
  }
39
+ /** Inverse affine: [A | t]⁻¹ = [A⁻¹ | −A⁻¹t]; null when degenerate (det 0). */
40
+ function invert(m) {
41
+ const [a, b, c, d, e, f] = m;
42
+ const det = a * d - b * c;
43
+ if (det === 0) return null;
44
+ const ia = d / det;
45
+ const ib = -b / det;
46
+ const ic = -c / det;
47
+ const id = a / det;
48
+ return [
49
+ z(ia),
50
+ z(ib),
51
+ z(ic),
52
+ z(id),
53
+ z(-(ia * e + ic * f)),
54
+ z(-(ib * e + id * f))
55
+ ];
56
+ }
39
57
  function applyToPoint(m, p) {
40
58
  return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
41
59
  }
@@ -44,12 +62,57 @@ function matEquals(a, b) {
44
62
  }
45
63
  //#endregion
46
64
  //#region src/displayList.ts
65
+ var FilterValidationError = class extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "FilterValidationError";
69
+ }
70
+ };
71
+ const FILTER_KINDS = new Set([
72
+ "blur",
73
+ "drop-shadow",
74
+ "brightness",
75
+ "contrast",
76
+ "saturate"
77
+ ]);
78
+ /** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
79
+ function validateFilters(filters) {
80
+ for (const f of filters) {
81
+ if (!FILTER_KINDS.has(f.kind)) throw new FilterValidationError(`unknown filter kind '${String(f.kind)}' (have: ${[...FILTER_KINDS].join(", ")})`);
82
+ if (f.kind === "blur" && !(Number.isFinite(f.radius) && f.radius >= 0)) throw new FilterValidationError(`blur radius must be ≥ 0, got ${String(f.radius)}`);
83
+ if (f.kind === "drop-shadow") {
84
+ if (![
85
+ f.dx,
86
+ f.dy,
87
+ f.blur
88
+ ].every(Number.isFinite) || f.blur < 0) throw new FilterValidationError("drop-shadow needs finite dx/dy and blur ≥ 0");
89
+ if (typeof f.color !== "string" || f.color.length === 0) throw new FilterValidationError("drop-shadow needs a color string");
90
+ }
91
+ if ((f.kind === "brightness" || f.kind === "contrast" || f.kind === "saturate") && !(Number.isFinite(f.amount) && f.amount >= 0)) throw new FilterValidationError(`${f.kind} amount must be ≥ 0, got ${String(f.amount)}`);
92
+ }
93
+ }
94
+ /**
95
+ * Compile the validated union to the canvas 2D `ctx.filter` syntax — both
96
+ * backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
97
+ * ONLY place the CSS-like syntax appears; documents never carry it.
98
+ */
99
+ function filtersToCanvasFilter(filters) {
100
+ if (filters.length === 0) return "none";
101
+ return filters.map((f) => {
102
+ switch (f.kind) {
103
+ case "blur": return `blur(${f.radius}px)`;
104
+ case "drop-shadow": return `drop-shadow(${f.dx}px ${f.dy}px ${f.blur}px ${f.color})`;
105
+ default: return `${f.kind}(${f.amount})`;
106
+ }
107
+ }).join(" ");
108
+ }
47
109
  function createDisplayListBuilder(size) {
48
110
  const commands = [];
49
111
  const resources = [];
50
112
  const interned = /* @__PURE__ */ new Map();
51
113
  return {
52
114
  push: (cmd) => {
115
+ if (cmd.op === "pushGroup" && cmd.filters.length > 0) validateFilters(cmd.filters);
53
116
  commands.push(cmd);
54
117
  },
55
118
  resource: (res) => {
@@ -149,6 +212,18 @@ var Node = class {
149
212
  zIndex;
150
213
  filters;
151
214
  parent = null;
215
+ /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
216
+ interactive = false;
217
+ /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
218
+ interactiveChildren = true;
219
+ /** v2 §C.3: explicit hit-shape override in node-local coordinates. */
220
+ hitArea;
221
+ /**
222
+ * Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
223
+ * derived-size bindings (e.g. a background tracking Layout.computedSize)
224
+ * measure with the same rasterizer the flow uses.
225
+ */
226
+ measurerSource = null;
152
227
  localMatrix;
153
228
  worldMatrix;
154
229
  /** Track-target paths → bindable signals; subclasses register their own props. */
@@ -161,7 +236,7 @@ var Node = class {
161
236
  this.opacity = initScalar(signal(1), props.opacity);
162
237
  this.blend = initScalar(signal("source-over"), props.blend);
163
238
  this.zIndex = initScalar(signal(0), props.zIndex);
164
- this.filters = initScalar(signal([]), void 0);
239
+ this.filters = initScalar(signal([]), props.filters);
165
240
  this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
166
241
  this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
167
242
  this.registerTarget("position", this.position);
@@ -203,9 +278,9 @@ var Node = class {
203
278
  y: -size.h / 2
204
279
  };
205
280
  }
206
- /** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
281
+ /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
207
282
  requiresGroup() {
208
- return this.opacity() < 1 || this.blend() !== "source-over";
283
+ return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
209
284
  }
210
285
  emit(out, ctx) {
211
286
  const opacity = this.opacity();
@@ -449,6 +524,102 @@ var Circle = class extends Shape {
449
524
  ], ["Z"]];
450
525
  }
451
526
  };
527
+ /**
528
+ * Arbitrary bezier geometry — the Lottie-import landing spot and the target
529
+ * of native path morphs. Coordinates are node-local (the node origin is
530
+ * wherever the author put 0,0); flow placement uses the control-point bounds.
531
+ */
532
+ var Path = class extends Shape {
533
+ data;
534
+ constructor(props = {}) {
535
+ super(props);
536
+ this.data = initProp(signal([]), props.data);
537
+ this.registerTarget("d", this.data);
538
+ }
539
+ /** Control-point bounding box (conservative: contains the true curve). */
540
+ bounds() {
541
+ let minX = Infinity;
542
+ let minY = Infinity;
543
+ let maxX = -Infinity;
544
+ let maxY = -Infinity;
545
+ for (const c of this.data()) for (let i = 0; i < c.v.length; i++) {
546
+ const vx = c.v[i][0];
547
+ const vy = c.v[i][1];
548
+ const candidates = [
549
+ [vx, vy],
550
+ [vx + c.in[i][0], vy + c.in[i][1]],
551
+ [vx + c.out[i][0], vy + c.out[i][1]]
552
+ ];
553
+ for (const p of candidates) {
554
+ if (p[0] < minX) minX = p[0];
555
+ if (p[1] < minY) minY = p[1];
556
+ if (p[0] > maxX) maxX = p[0];
557
+ if (p[1] > maxY) maxY = p[1];
558
+ }
559
+ }
560
+ if (minX > maxX) return {
561
+ minX: 0,
562
+ minY: 0,
563
+ maxX: 0,
564
+ maxY: 0
565
+ };
566
+ return {
567
+ minX,
568
+ minY,
569
+ maxX,
570
+ maxY
571
+ };
572
+ }
573
+ intrinsicSize() {
574
+ const b = this.bounds();
575
+ return {
576
+ w: b.maxX - b.minX,
577
+ h: b.maxY - b.minY
578
+ };
579
+ }
580
+ /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
581
+ flowOffset() {
582
+ const b = this.bounds();
583
+ return {
584
+ x: b.minX,
585
+ y: b.minY
586
+ };
587
+ }
588
+ pathSegs() {
589
+ const segs = [];
590
+ for (const c of this.data()) {
591
+ const n = c.v.length;
592
+ if (n === 0) continue;
593
+ segs.push([
594
+ "M",
595
+ c.v[0][0],
596
+ c.v[0][1]
597
+ ]);
598
+ for (let i = 0; i < n - 1; i++) segs.push([
599
+ "C",
600
+ c.v[i][0] + c.out[i][0],
601
+ c.v[i][1] + c.out[i][1],
602
+ c.v[i + 1][0] + c.in[i + 1][0],
603
+ c.v[i + 1][1] + c.in[i + 1][1],
604
+ c.v[i + 1][0],
605
+ c.v[i + 1][1]
606
+ ]);
607
+ if (c.closed && n > 1) {
608
+ segs.push([
609
+ "C",
610
+ c.v[n - 1][0] + c.out[n - 1][0],
611
+ c.v[n - 1][1] + c.out[n - 1][1],
612
+ c.v[0][0] + c.in[0][0],
613
+ c.v[0][1] + c.in[0][1],
614
+ c.v[0][0],
615
+ c.v[0][1]
616
+ ]);
617
+ segs.push(["Z"]);
618
+ }
619
+ }
620
+ return segs;
621
+ }
622
+ };
452
623
  var ImageNode = class extends Node {
453
624
  assetId;
454
625
  width;
@@ -652,4 +823,4 @@ function requireLayoutEngine() {
652
823
  return engine;
653
824
  }
654
825
  //#endregion
655
- export { applyToPoint as _, Circle as a, multiply as b, Rect as c, Node as d, breakLines as f, IDENTITY as g, createDisplayListBuilder as h, setLayoutEngine as i, Text as l, quantize as m, getLayoutEngine as n, Group as o, estimatingMeasurer as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Video as u, fromTRS as v, matEquals as y };
826
+ export { invert as C, fromTRS as S, multiply as T, createDisplayListBuilder as _, Circle as a, IDENTITY as b, Path as c, Video as d, Node as f, FilterValidationError as g, quantize as h, setLayoutEngine as i, Rect as l, estimatingMeasurer as m, getLayoutEngine as n, Group as o, breakLines as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Text as u, filtersToCanvasFilter as v, matEquals as w, applyToPoint as x, validateFilters as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "yoga-layout": "^3.2.1",
23
- "@glissade/core": "0.1.0"
23
+ "@glissade/core": "0.3.0"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",