@glissade/scene 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,34 @@
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
- import { BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
1
+ import { $ as glow, A as PropInit, B as DrawCommand, C as roundedRectSegs, D as HitArea, E as EvalContext, F as estimatingMeasurer, G as PathSeg, H as FilterValidationError, I as quantize, J as ResourceId, K as Rect$1, L as BlendMode, M as TextMeasurer, N as TextMetricsLite, O as Node, P as breakLines, Q as filtersToCanvasFilter, R as DisplayList, S as VideoProps, T as BindablePropTarget, U as FontSpec, V as FilterSpec, W as Paint, X as StrokeStyle, Y as ShaderRef, Z as createDisplayListBuilder, _ as Rect, a as LayoutEngineMissingError, at as invert, b as TextProps, c as requireLayoutEngine, d as Group, et as validateFilters, f as ImageNode, g as PathProps, h as Path, i as LayoutEngine, it as fromTRS, j as resolveAnchor, k as NodeProps, l as setLayoutEngine, m as LineBox, n as LayoutChildSpec, nt as Mat2x3, ot as matEquals, p as ImageProps, q as Resource, r as LayoutContainerSpec, rt as applyToPoint, s as getLayoutEngine, st as multiply, t as LayoutBox, tt as IDENTITY, u as Circle, v as ShapeProps, w as AnchorSpec, x as Video, y as Text, z as DisplayListBuilder } from "./layoutEngine.js";
2
+ import { BindableSignal, BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
3
3
 
4
- //#region src/assets.d.ts
4
+ //#region src/highlight.d.ts
5
5
 
6
+ interface HighlightProps extends NodeProps {
7
+ /** The Text whose lines get the marker. Place this node as an EARLIER
8
+ * sibling (same parent) so it paints behind the glyphs. */
9
+ text: Text;
10
+ color?: PropInit<string>;
11
+ /** 0→1 sweep across all lines in reading order, at constant speed weighted
12
+ * by line width; default 1 (fully highlighted). Track: '<id>/progress'. */
13
+ progress?: PropInit<number>;
14
+ /** Marker overhang beyond each line's ink box, [x, y] px; default [4, 2]. */
15
+ padding?: [number, number];
16
+ /** Rounded marker ends; default 4 (clamped to the box). */
17
+ cornerRadius?: number;
18
+ }
19
+ declare class Highlight extends Node {
20
+ readonly target: Text;
21
+ readonly color: BindableSignal<string>;
22
+ readonly progress: BindableSignal<number>;
23
+ readonly padding: [number, number];
24
+ readonly cornerRadius: number;
25
+ constructor(props: HighlightProps);
26
+ protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
27
+ }
28
+ /** `children: [highlight(title, { color: '#ffe066' }), title]` — marker behind the text. */
29
+ declare function highlight(text: Text, props?: Omit<HighlightProps, 'text'>): Highlight;
30
+ //#endregion
31
+ //#region src/assets.d.ts
6
32
  /**
7
33
  * Asset contracts (DESIGN.md §3.8): evaluate() never awaits — callers warm
8
34
  * sources first (§2.5 readiness precondition), then emission is pure. The
@@ -37,6 +63,23 @@ declare class ColdAssetError extends Error {
37
63
  constructor(assetId: string, detail: string, mediaT?: number);
38
64
  }
39
65
  //#endregion
66
+ //#region src/shaderEffect.d.ts
67
+ interface ShaderEffectProps extends NodeProps {
68
+ children?: Node[];
69
+ /** WGSL fragment module: `struct Uniforms {...}` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
70
+ wgsl: string;
71
+ /** Initial scalar uniforms; each becomes an animatable signal + track target 'u.<name>'. */
72
+ uniforms?: Record<string, number>;
73
+ }
74
+ declare class ShaderEffect extends Group {
75
+ readonly wgsl: string;
76
+ readonly uniformSignals: ReadonlyMap<string, BindableSignal<number>>;
77
+ constructor(props: ShaderEffectProps);
78
+ /** The live uniform signal (throws on unknown names — typos fail loudly). */
79
+ uniform(name: string): BindableSignal<number>;
80
+ protected groupShader(): ShaderRef;
81
+ }
82
+ //#endregion
40
83
  //#region src/raster2d.d.ts
41
84
  /** The structural path surface buildPath drives — DOM Path2D and @napi-rs Path2D both satisfy it. */
42
85
  interface PathLike {
@@ -60,6 +103,9 @@ interface Ctx2DLike<TPath, TDrawable> {
60
103
  fill(path: TPath): void;
61
104
  stroke(path: TPath): void;
62
105
  fillText(text: string, x: number, y: number): void;
106
+ measureText(text: string): {
107
+ width: number;
108
+ };
63
109
  drawImage(image: TDrawable, x: number, y: number, w?: number, h?: number): void;
64
110
  drawImage(image: TDrawable, sx: number, sy: number, sw: number, sh: number, x: number, y: number, w: number, h: number): void;
65
111
  setLineDash(segments: number[]): void;
@@ -85,15 +131,29 @@ interface Raster2DHost<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
85
131
  context(canvas: TCanvas): Ctx2DLike<TPath, TDrawable>;
86
132
  createCanvas(w: number, h: number): TCanvas;
87
133
  newPath(): TPath;
134
+ /**
135
+ * §3.7 shader pass: run the WGSL effect over the group layer and return a
136
+ * drawable replacement, or null when unavailable. Absent/null → the layer
137
+ * composites unfiltered per caps.shaders (warn by default, error opt-in).
138
+ * Only browser hosts wire this (via @glissade/effects-webgpu); headless
139
+ * backends stay GPU-free by construction.
140
+ */
141
+ applyShader?(layer: TCanvas, shader: ShaderRef, w: number, h: number): TDrawable | null;
88
142
  }
143
+ type ShaderCaps = 'warn' | 'error';
89
144
  declare function fontString(font: FontSpec): string;
90
145
  declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
91
146
  private readonly host;
147
+ /** caps.shaders (§3.7): what happens when a shader can't run here. */
148
+ private readonly shaderCaps;
92
149
  private readonly pool;
93
150
  private readonly pathCache;
151
+ private readonly pathBoundsCache;
94
152
  private readonly images;
95
153
  private readonly videos;
96
- constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>);
154
+ private warnedShaders;
155
+ constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>, /** caps.shaders (§3.7): what happens when a shader can't run here. */
156
+ shaderCaps?: ShaderCaps);
97
157
  /** Register a decoded still (kind 'image' assets). */
98
158
  setImageAsset(assetId: string, image: TDrawable): void;
99
159
  /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
@@ -101,6 +161,7 @@ declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
101
161
  dispose(): void;
102
162
  private resolveDrawable;
103
163
  private path;
164
+ private pathBounds;
104
165
  private buildPath;
105
166
  private acquire;
106
167
  private release;
@@ -157,4 +218,4 @@ declare function bindScene(scene: Scene, doc: Timeline): BindingCacheEntry;
157
218
  */
158
219
  declare function evaluate(scene: Scene, doc: Timeline, t: number): DisplayList;
159
220
  //#endregion
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 };
221
+ export { type AnchorSpec, 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, Highlight, type HighlightProps, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type LineBox, 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 ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, 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, glow, highlight, invert, matEquals, multiply, quantize, requireLayoutEngine, resolveAnchor, roundedRectSegs, setLayoutEngine, validateFilters };
package/dist/index.js CHANGED
@@ -1,5 +1,84 @@
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
- import { bindTimeline, compileTimeline, createPlayhead, evaluateAt } from "@glissade/core";
1
+ import { C as IDENTITY, D as matEquals, E as invert, O as multiply, S as validateFilters, T as fromTRS, _ as quantize, a as Circle, b as filtersToCanvasFilter, c as Path, d as Video, f as roundedRectSegs, g as estimatingMeasurer, h as breakLines, i as setLayoutEngine, l as Rect, m as resolveAnchor, n as getLayoutEngine, o as Group, p as Node, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Text, v as FilterValidationError, w as applyToPoint, x as glow, y as createDisplayListBuilder } from "./layoutEngine.js";
2
+ import { bindTimeline, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, signal } from "@glissade/core";
3
+ //#region src/highlight.ts
4
+ /**
5
+ * Marker-style text highlight: per-line rounded rects behind a Text node's
6
+ * laid-out lines, swept by ONE 0→1 progress track in reading order. Lines
7
+ * come from Text.lineBoxes() each frame, so the marker re-flows with wrap
8
+ * width, font, and text animation — and the line count is dynamic because
9
+ * the rects are draw() output, not child nodes. Pure data, both backends,
10
+ * golden-coverable. For karaoke, key '<id>/progress' from narrate's
11
+ * word timestamps.
12
+ */
13
+ var Highlight = class extends Node {
14
+ target;
15
+ color;
16
+ progress;
17
+ padding;
18
+ cornerRadius;
19
+ constructor(props) {
20
+ super(props);
21
+ this.target = props.text;
22
+ this.color = init(signal("#ffe066"), props.color);
23
+ this.progress = init(signal(1), props.progress);
24
+ this.padding = props.padding ?? [4, 2];
25
+ this.cornerRadius = props.cornerRadius ?? 4;
26
+ this.registerTarget("progress", this.progress);
27
+ this.registerTarget("color", this.color);
28
+ }
29
+ draw(out, ctx) {
30
+ const progress = Math.min(1, Math.max(0, this.progress()));
31
+ if (progress <= 0) return;
32
+ const [px, py] = this.padding;
33
+ const boxes = this.target.lineBoxes(ctx.measurer).map((b) => ({
34
+ x: b.x - px,
35
+ y: b.y - py,
36
+ w: b.w + 2 * px,
37
+ h: b.h + 2 * py
38
+ }));
39
+ const total = boxes.reduce((sum, b) => sum + b.w, 0);
40
+ if (total <= 0) return;
41
+ const tm = this.target.localMatrix();
42
+ if (!matEquals(tm, IDENTITY)) out.push({
43
+ op: "transform",
44
+ m: tm
45
+ });
46
+ const color = this.color();
47
+ let remaining = progress * total;
48
+ for (const b of boxes) {
49
+ const fill = Math.min(b.w, remaining);
50
+ remaining -= fill;
51
+ if (fill <= 0) break;
52
+ const r = Math.min(this.cornerRadius, fill / 2, b.h / 2);
53
+ const path = out.resource({
54
+ kind: "path",
55
+ segs: roundedRectSegs(b.x, b.y, fill, b.h, r)
56
+ });
57
+ out.push({
58
+ op: "fillPath",
59
+ path,
60
+ paint: {
61
+ kind: "color",
62
+ color
63
+ }
64
+ });
65
+ if (remaining <= 0) break;
66
+ }
67
+ }
68
+ };
69
+ /** `children: [highlight(title, { color: '#ffe066' }), title]` — marker behind the text. */
70
+ function highlight(text, props = {}) {
71
+ return new Highlight({
72
+ ...props,
73
+ text
74
+ });
75
+ }
76
+ function init(sig, v) {
77
+ if (typeof v === "function") sig.bindSource(v);
78
+ else if (v !== void 0) sig.set(v);
79
+ return sig;
80
+ }
81
+ //#endregion
3
82
  //#region src/assets.ts
4
83
  var ColdAssetError = class extends Error {
5
84
  assetId;
@@ -15,6 +94,45 @@ var ColdAssetError = class extends Error {
15
94
  }
16
95
  };
17
96
  //#endregion
97
+ //#region src/shaderEffect.ts
98
+ /**
99
+ * ShaderEffect (§3.7): a group whose rasterized subtree runs through a WGSL
100
+ * pass. THIS FILE IS PURE DATA — the GPU runner lives in the browser-only
101
+ * @glissade/effects-webgpu package; headless backends degrade per
102
+ * caps.shaders (passthrough + warning by default). Uniforms are per-name
103
+ * number signals registered as '<id>/u.<name>' track targets, so shader
104
+ * params animate exactly like any other property.
105
+ */
106
+ var ShaderEffect = class extends Group {
107
+ wgsl;
108
+ uniformSignals;
109
+ constructor(props) {
110
+ super(props);
111
+ this.wgsl = props.wgsl;
112
+ const map = /* @__PURE__ */ new Map();
113
+ for (const [name, value] of Object.entries(props.uniforms ?? {})) {
114
+ const sig = signal(value);
115
+ map.set(name, sig);
116
+ this.registerTarget(`u.${name}`, sig);
117
+ }
118
+ this.uniformSignals = map;
119
+ }
120
+ /** The live uniform signal (throws on unknown names — typos fail loudly). */
121
+ uniform(name) {
122
+ const sig = this.uniformSignals.get(name);
123
+ if (!sig) throw new Error(`ShaderEffect has no uniform '${name}' (have: ${[...this.uniformSignals.keys()].join(", ")})`);
124
+ return sig;
125
+ }
126
+ groupShader() {
127
+ const uniforms = {};
128
+ for (const [name, sig] of this.uniformSignals) uniforms[name] = sig();
129
+ return {
130
+ wgsl: this.wgsl,
131
+ uniforms
132
+ };
133
+ }
134
+ };
135
+ //#endregion
18
136
  //#region src/raster2d.ts
19
137
  /**
20
138
  * The shared DisplayList interpreter (§3.4): one command walk over the
@@ -26,14 +144,81 @@ var ColdAssetError = class extends Error {
26
144
  function fontString(font) {
27
145
  return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
28
146
  }
147
+ /**
148
+ * How far a filter chain can paint beyond its input (device px). Each stage
149
+ * feeds the next, so outsets ADD. Gaussian reach: Skia truncates at 3σ and
150
+ * the CSS blur/shadow radii are ≥ σ, so 3× the radius over-covers either
151
+ * convention. Color-only filters map transparent → transparent: zero outset.
152
+ */
153
+ function filterOutset(filters) {
154
+ let total = 0;
155
+ for (const f of filters ?? []) if (f.kind === "blur") total += 3 * f.radius;
156
+ else if (f.kind === "drop-shadow") total += Math.max(Math.abs(f.dx), Math.abs(f.dy)) + 3 * f.blur;
157
+ return total;
158
+ }
159
+ function growBounds(b, x, y) {
160
+ if (!b) return {
161
+ minX: x,
162
+ minY: y,
163
+ maxX: x,
164
+ maxY: y
165
+ };
166
+ if (x < b.minX) b.minX = x;
167
+ if (y < b.minY) b.minY = y;
168
+ if (x > b.maxX) b.maxX = x;
169
+ if (y > b.maxY) b.maxY = y;
170
+ return b;
171
+ }
172
+ /** Local-space rect (already outset) → device-space box under m. */
173
+ function accumulateRect(layer, m, x0, y0, x1, y1) {
174
+ for (const [x, y] of [
175
+ [x0, y0],
176
+ [x1, y0],
177
+ [x0, y1],
178
+ [x1, y1]
179
+ ]) layer.bounds = growBounds(layer.bounds, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
180
+ }
181
+ /** Control-point box of a path — curves and rotated ellipses stay inside it. */
182
+ function segsBounds(segs) {
183
+ let b = null;
184
+ const pt = (x, y) => {
185
+ b = growBounds(b, x, y);
186
+ };
187
+ for (const seg of segs) switch (seg[0]) {
188
+ case "M":
189
+ case "L":
190
+ pt(seg[1], seg[2]);
191
+ break;
192
+ case "C":
193
+ pt(seg[1], seg[2]);
194
+ pt(seg[3], seg[4]);
195
+ pt(seg[5], seg[6]);
196
+ break;
197
+ case "Q":
198
+ pt(seg[1], seg[2]);
199
+ pt(seg[3], seg[4]);
200
+ break;
201
+ case "E": {
202
+ const r = Math.max(seg[3], seg[4]);
203
+ pt(seg[1] - r, seg[2] - r);
204
+ pt(seg[1] + r, seg[2] + r);
205
+ break;
206
+ }
207
+ }
208
+ return b;
209
+ }
29
210
  var Raster2D = class {
30
211
  host;
212
+ shaderCaps;
31
213
  pool = [];
32
214
  pathCache = /* @__PURE__ */ new WeakMap();
215
+ pathBoundsCache = /* @__PURE__ */ new WeakMap();
33
216
  images = /* @__PURE__ */ new Map();
34
217
  videos = /* @__PURE__ */ new Map();
35
- constructor(host) {
218
+ warnedShaders = false;
219
+ constructor(host, shaderCaps = "warn") {
36
220
  this.host = host;
221
+ this.shaderCaps = shaderCaps;
37
222
  }
38
223
  /** Register a decoded still (kind 'image' assets). */
39
224
  setImageAsset(assetId, image) {
@@ -74,6 +259,12 @@ var Raster2D = class {
74
259
  }
75
260
  return p;
76
261
  }
262
+ pathBounds(resources, id) {
263
+ const res = resources[id];
264
+ if (!res || res.kind !== "path") return null;
265
+ if (!this.pathBoundsCache.has(res)) this.pathBoundsCache.set(res, segsBounds(res.segs));
266
+ return this.pathBoundsCache.get(res) ?? null;
267
+ }
77
268
  buildPath(segs) {
78
269
  const p = this.host.newPath();
79
270
  for (const seg of segs) switch (seg[0]) {
@@ -122,17 +313,25 @@ var Raster2D = class {
122
313
  ctx: base,
123
314
  canvas: null,
124
315
  opacity: 1,
125
- blend: "source-over"
316
+ blend: "source-over",
317
+ bounds: null,
318
+ unbounded: false
126
319
  }];
127
320
  const ctxOf = () => layers[layers.length - 1].ctx;
321
+ const top = () => layers[layers.length - 1];
322
+ let mat = IDENTITY;
323
+ const matStack = [];
128
324
  for (const cmd of list.commands) switch (cmd.op) {
129
325
  case "save":
326
+ matStack.push(mat);
130
327
  ctxOf().save();
131
328
  break;
132
329
  case "restore":
330
+ mat = matStack.pop() ?? mat;
133
331
  ctxOf().restore();
134
332
  break;
135
333
  case "transform":
334
+ mat = multiply(mat, cmd.m);
136
335
  ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
137
336
  break;
138
337
  case "clip":
@@ -142,6 +341,8 @@ var Raster2D = class {
142
341
  const ctx = ctxOf();
143
342
  ctx.fillStyle = cmd.paint.color;
144
343
  ctx.fill(this.path(list.resources, cmd.path));
344
+ const b = this.pathBounds(list.resources, cmd.path);
345
+ if (b) accumulateRect(top(), mat, b.minX, b.minY, b.maxX, b.maxY);
145
346
  break;
146
347
  }
147
348
  case "strokePath": {
@@ -153,6 +354,11 @@ var Raster2D = class {
153
354
  if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
154
355
  ctx.stroke(this.path(list.resources, cmd.path));
155
356
  if (cmd.stroke.dash) ctx.setLineDash([]);
357
+ const b = this.pathBounds(list.resources, cmd.path);
358
+ if (b) {
359
+ const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
360
+ accumulateRect(top(), mat, b.minX - o, b.minY - o, b.maxX + o, b.maxY + o);
361
+ }
156
362
  break;
157
363
  }
158
364
  case "fillText": {
@@ -162,6 +368,15 @@ var Raster2D = class {
162
368
  ctx.textBaseline = "alphabetic";
163
369
  ctx.textAlign = cmd.align ?? "left";
164
370
  ctx.fillText(cmd.text, cmd.x, cmd.y);
371
+ try {
372
+ const width = ctx.measureText(cmd.text).width;
373
+ const align = cmd.align ?? "left";
374
+ const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
375
+ const m = cmd.font.size;
376
+ accumulateRect(top(), mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m);
377
+ } catch {
378
+ top().unbounded = true;
379
+ }
165
380
  break;
166
381
  }
167
382
  case "drawImage": {
@@ -173,6 +388,7 @@ var Raster2D = class {
173
388
  const { x, y, w: dw, h: dh } = cmd.dst;
174
389
  if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
175
390
  else ctx.drawImage(drawable, x, y, dw, dh);
391
+ accumulateRect(top(), mat, x, y, x + dw, y + dh);
176
392
  break;
177
393
  }
178
394
  case "pushGroup": {
@@ -187,7 +403,11 @@ var Raster2D = class {
187
403
  canvas: layerCanvas,
188
404
  opacity: cmd.opacity,
189
405
  blend: cmd.blend,
190
- filter: filtersToCanvasFilter(cmd.filters)
406
+ filter: filtersToCanvasFilter(cmd.filters),
407
+ filters: cmd.filters,
408
+ ...cmd.shader !== void 0 ? { shader: cmd.shader } : {},
409
+ bounds: null,
410
+ unbounded: false
191
411
  });
192
412
  break;
193
413
  }
@@ -195,12 +415,60 @@ var Raster2D = class {
195
415
  const layer = layers.pop();
196
416
  if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
197
417
  const parent = ctxOf();
418
+ let drawable = layer.canvas;
419
+ let shaderReplaced = false;
420
+ if (layer.shader !== void 0) {
421
+ const replaced = this.host.applyShader?.(layer.canvas, layer.shader, w, h) ?? null;
422
+ if (replaced !== null) {
423
+ drawable = replaced;
424
+ shaderReplaced = true;
425
+ layer.bounds = {
426
+ minX: 0,
427
+ minY: 0,
428
+ maxX: w,
429
+ maxY: h
430
+ };
431
+ layer.unbounded = false;
432
+ } else if (this.shaderCaps === "error") throw new Error("a ShaderEffect reached a backend without a shader runner (§3.7) — load @glissade/effects-webgpu in the browser, or accept passthrough with caps.shaders: warn");
433
+ else if (!this.warnedShaders) {
434
+ this.warnedShaders = true;
435
+ emitDevWarning("ShaderEffect pass skipped: no shader runner here (headless or webgpu-less browser) — subtree composites unfiltered (§3.7 caps.shaders)");
436
+ }
437
+ }
438
+ const hasFilter = layer.filter !== void 0 && layer.filter !== "none";
439
+ const outset = hasFilter ? filterOutset(layer.filters) : 0;
440
+ const parentLayer = top();
441
+ if (layer.unbounded || layer.blend !== "source-over") parentLayer.unbounded = true;
442
+ else if (layer.bounds) accumulateRect(parentLayer, IDENTITY, layer.bounds.minX - outset, layer.bounds.minY - outset, layer.bounds.maxX + outset, layer.bounds.maxY + outset);
443
+ const clippable = hasFilter && !shaderReplaced && !layer.unbounded && layer.blend === "source-over";
444
+ if (clippable && layer.bounds === null) {
445
+ this.release(layer.canvas);
446
+ break;
447
+ }
198
448
  parent.save();
199
449
  parent.resetTransform();
450
+ if (clippable && layer.bounds) {
451
+ const x0 = Math.max(0, Math.floor(layer.bounds.minX - outset));
452
+ const y0 = Math.max(0, Math.floor(layer.bounds.minY - outset));
453
+ const x1 = Math.min(w, Math.ceil(layer.bounds.maxX + outset));
454
+ const y1 = Math.min(h, Math.ceil(layer.bounds.maxY + outset));
455
+ if (x0 >= x1 || y0 >= y1) {
456
+ parent.restore();
457
+ this.release(layer.canvas);
458
+ break;
459
+ }
460
+ const clip = this.host.newPath();
461
+ clip.moveTo(x0, y0);
462
+ clip.lineTo(x1, y0);
463
+ clip.lineTo(x1, y1);
464
+ clip.lineTo(x0, y1);
465
+ clip.closePath();
466
+ parent.clip(clip, "nonzero");
467
+ }
200
468
  parent.globalAlpha = layer.opacity;
201
- if (layer.filter !== void 0 && layer.filter !== "none") parent.filter = layer.filter;
469
+ if (hasFilter) parent.filter = layer.filter;
202
470
  parent.globalCompositeOperation = layer.blend;
203
- parent.drawImage(layer.canvas, 0, 0);
471
+ parent.drawImage(drawable, 0, 0);
204
472
  parent.restore();
205
473
  this.release(layer.canvas);
206
474
  break;
@@ -295,4 +563,4 @@ function evaluate(scene, doc, t) {
295
563
  });
296
564
  }
297
565
  //#endregion
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 };
566
+ export { Circle, ColdAssetError, DuplicateNodeIdError, FilterValidationError, Group, Highlight, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Path, Raster2D, Rect, ShaderEffect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, glow, highlight, invert, matEquals, multiply, quantize, requireLayoutEngine, resolveAnchor, roundedRectSegs, setLayoutEngine, validateFilters };
package/dist/layout.d.ts CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { A as PropInit, E as EvalContext, M as TextMeasurer, O as Node, a as LayoutEngineMissingError, d as Group, i as LayoutEngine, k as NodeProps, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox, z as DisplayListBuilder } from "./layoutEngine.js";
2
2
  import { BindableSignal } from "@glissade/core";
3
3
 
4
4
  //#region src/layout.d.ts
package/dist/layout.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as setLayoutEngine, m as estimatingMeasurer, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
1
+ import { g as estimatingMeasurer, i as setLayoutEngine, 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
  /**
@@ -89,12 +89,31 @@ declare function validateFilters(filters: readonly FilterSpec[]): void;
89
89
  * ONLY place the CSS-like syntax appears; documents never carry it.
90
90
  */
91
91
  declare function filtersToCanvasFilter(filters: readonly FilterSpec[]): string;
92
+ /**
93
+ * Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
94
+ * deterministic on both backends (it is just filters). intensity stacks more
95
+ * layers; pair with a signal binding to follow an animated fill.
96
+ */
97
+ declare function glow(color: string, radius?: number, intensity?: number): FilterSpec[];
92
98
  interface Rect$1 {
93
99
  x: number;
94
100
  y: number;
95
101
  w: number;
96
102
  h: number;
97
103
  }
104
+ /**
105
+ * Shader effect pass (§3.7): runs over the group's rasterized texture.
106
+ * EXPLICITLY outside the determinism guarantee — GPU/driver per-pixel
107
+ * variance breaks distributed reproducibility; export with shaders is
108
+ * best-effort, single machine. Uniform VALUES are resolved at emit time
109
+ * (they ride on signals), so the IR stays a plain serializable snapshot.
110
+ */
111
+ interface ShaderRef {
112
+ /** WGSL fragment module: declare `struct Uniforms` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
113
+ wgsl: string;
114
+ /** Scalar uniforms, packed as f32 in SORTED KEY ORDER into the Uniforms struct. */
115
+ uniforms: Record<string, number>;
116
+ }
98
117
  type DrawCommand = {
99
118
  op: 'save';
100
119
  } | {
@@ -134,6 +153,7 @@ type DrawCommand = {
134
153
  opacity: number;
135
154
  blend: BlendMode;
136
155
  filters: FilterSpec[];
156
+ shader?: ShaderRef;
137
157
  cacheKey?: string;
138
158
  } | {
139
159
  op: 'popGroup';
@@ -183,6 +203,15 @@ declare const estimatingMeasurer: TextMeasurer;
183
203
  declare function breakLines(text: string, font: FontSpec, maxWidth: number | undefined, measurer: TextMeasurer): string[];
184
204
  //#endregion
185
205
  //#region src/node.d.ts
206
+ /**
207
+ * Where `position` pins to on the node's intrinsic box, as fractions of its
208
+ * size — and the rotation/scale pivot (the Lottie anchor model). Default
209
+ * 'center' preserves every pre-anchor scene byte-for-byte. With a non-center
210
+ * anchor, grow direction falls out: anchor 'left' + a width track sweeps
211
+ * rightward, anchor [0, 1] grows a bar upward.
212
+ */
213
+ type AnchorSpec = 'center' | 'top-left' | 'top' | 'top-right' | 'left' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | readonly [number, number];
214
+ declare function resolveAnchor(spec: AnchorSpec): Vec2;
186
215
  interface EvalContext {
187
216
  /** The playhead value at evaluate() entry — the only time channel (§3.1). */
188
217
  readonly time: number;
@@ -203,6 +232,8 @@ interface NodeProps {
203
232
  zIndex?: PropInit<number>;
204
233
  /** Group filters (§3.4): the subtree composites as a unit through them. */
205
234
  filters?: PropInit<FilterSpec[]>;
235
+ /** Placement point + transform pivot on the intrinsic box; default 'center'. */
236
+ anchor?: AnchorSpec;
206
237
  }
207
238
  interface BindablePropTarget {
208
239
  bindSource(fn: () => unknown): void;
@@ -222,6 +253,7 @@ type HitArea = {
222
253
  r: number;
223
254
  };
224
255
  declare abstract class Node {
256
+ #private;
225
257
  readonly id: string | undefined;
226
258
  readonly position: Vec2Signal;
227
259
  readonly rotation: BindableSignal<number>;
@@ -230,6 +262,10 @@ declare abstract class Node {
230
262
  readonly blend: BindableSignal<BlendMode>;
231
263
  readonly zIndex: BindableSignal<number>;
232
264
  readonly filters: BindableSignal<FilterSpec[]>;
265
+ /** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
266
+ readonly anchor: Vec2;
267
+ /** True only when the author SET an anchor — unset keeps the legacy origin. */
268
+ readonly hasAnchor: boolean;
233
269
  parent: Node | null;
234
270
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
235
271
  interactive: boolean;
@@ -261,20 +297,45 @@ declare abstract class Node {
261
297
  h: number;
262
298
  } | null;
263
299
  /**
264
- * Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
265
- * can place any anchor correctly. Default: center-anchored (every shape).
266
- * Text overrides it draws from a left/center/right baseline origin.
300
+ * Vector from the DRAW origin to the intrinsic box's top-left, in the
301
+ * geometry space draw() emits into (anchor-independent the anchor shift
302
+ * lives in localMatrix). Hit testing boxes nodes with this. Default:
303
+ * center-anchored geometry (every shape). Text overrides — it draws from a
304
+ * left/center/right baseline origin; Path from author-positioned bounds.
267
305
  */
268
- flowOffset(measurer: TextMeasurer): {
306
+ drawOffset(measurer?: TextMeasurer): {
269
307
  x: number;
270
308
  y: number;
271
309
  };
310
+ /**
311
+ * Vector from the node ORIGIN (the point `position` places) to the box's
312
+ * top-left, so Layout can place any node. With an anchor this is exactly
313
+ * (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
314
+ */
315
+ flowOffset(measurer?: TextMeasurer): {
316
+ x: number;
317
+ y: number;
318
+ };
319
+ /**
320
+ * Translation composed after TRS in localMatrix: moves the drawn box so the
321
+ * anchor point lands on the origin. shift = −(drawOffset + anchor·size).
322
+ * No anchor set → zero shift, the legacy origin (shape center / Text
323
+ * baseline / Path author coords) — every pre-anchor scene is byte-stable.
324
+ * An EXPLICIT anchor pins position to that fraction of the box, even
325
+ * 'center' (which differs from the legacy origin only for Text and Path).
326
+ * Nodes without an intrinsic box (Group) warn once and ignore it.
327
+ */
328
+ protected anchorShift(measurer?: TextMeasurer): Vec2;
272
329
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
273
330
  protected requiresGroup(): boolean;
331
+ /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
332
+ protected groupShader(): ShaderRef | undefined;
274
333
  emit(out: DisplayListBuilder, ctx: EvalContext): void;
275
334
  }
276
335
  //#endregion
277
336
  //#region src/nodes.d.ts
337
+ /** Rounded-rect path segments — Rect's outline, shared with Highlight. */
338
+ declare function roundedRectSegs(x: number, y: number, w: number, h: number, r: number): PathSeg[];
278
339
  declare class Group extends Node {
279
340
  readonly children: Node[];
280
341
  constructor(props?: NodeProps & {
@@ -347,7 +408,7 @@ declare class Path extends Shape {
347
408
  h: number;
348
409
  };
349
410
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
350
- flowOffset(): {
411
+ drawOffset(): {
351
412
  x: number;
352
413
  y: number;
353
414
  };
@@ -408,6 +469,14 @@ declare class Video extends Node {
408
469
  mediaTime(t: number): number | null;
409
470
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
410
471
  }
472
+ /** One laid-out line's ink box, in the Text node's draw space. */
473
+ interface LineBox {
474
+ text: string;
475
+ x: number;
476
+ y: number;
477
+ w: number;
478
+ h: number;
479
+ }
411
480
  interface TextProps extends NodeProps {
412
481
  text?: PropInit<string>;
413
482
  fill?: PropInit<string>;
@@ -436,10 +505,27 @@ declare class Text extends Node {
436
505
  h: number;
437
506
  };
438
507
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
439
- flowOffset(measurer: TextMeasurer): {
508
+ drawOffset(measurer?: TextMeasurer): {
440
509
  x: number;
441
510
  y: number;
442
511
  };
512
+ /**
513
+ * The wrapped box {w, h}, measured with the scene's active measurer — the
514
+ * same numbers Layout flows with, public so bindings never hand-calculate
515
+ * text dimensions (e.g. underline width = () => title.measuredSize().w).
516
+ */
517
+ measuredSize(measurer?: TextMeasurer): {
518
+ w: number;
519
+ h: number;
520
+ };
521
+ /**
522
+ * Per-line ink boxes in this node's DRAW space (origin = first baseline at
523
+ * the align edge), from the same breakLines pass that draws. Pull-based:
524
+ * re-measures when text/font/width animate. Blank lines (from '\n\n')
525
+ * produce no box. The substrate for highlights, underlines, per-line
526
+ * reveals, selections.
527
+ */
528
+ lineBoxes(measurer?: TextMeasurer): LineBox[];
443
529
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
444
530
  }
445
531
  //#endregion
@@ -491,4 +577,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
491
577
  declare function getLayoutEngine(): LayoutEngine | null;
492
578
  declare function requireLayoutEngine(): LayoutEngine;
493
579
  //#endregion
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 };
580
+ export { glow as $, PropInit as A, DrawCommand as B, roundedRectSegs as C, HitArea as D, EvalContext as E, estimatingMeasurer as F, PathSeg as G, FilterValidationError as H, quantize as I, ResourceId as J, Rect$1 as K, BlendMode as L, TextMeasurer as M, TextMetricsLite as N, Node as O, breakLines as P, filtersToCanvasFilter as Q, DisplayList as R, VideoProps as S, BindablePropTarget as T, FontSpec as U, FilterSpec as V, Paint as W, StrokeStyle as X, ShaderRef as Y, createDisplayListBuilder as Z, Rect as _, LayoutEngineMissingError as a, invert as at, TextProps as b, requireLayoutEngine as c, Group as d, validateFilters as et, ImageNode as f, PathProps as g, Path as h, LayoutEngine as i, fromTRS as it, resolveAnchor as j, NodeProps as k, setLayoutEngine as l, LineBox as m, LayoutChildSpec as n, Mat2x3 as nt, LayoutResult as o, matEquals as ot, ImageProps as p, Resource as q, LayoutContainerSpec as r, applyToPoint as rt, getLayoutEngine as s, multiply as st, LayoutBox as t, IDENTITY as tt, Circle as u, ShapeProps as v, AnchorSpec as w, Video as x, Text as y, DisplayListBuilder as z };
@@ -1,4 +1,4 @@
1
- import { TARGET_PATH, computed, signal, vec2Signal } from "@glissade/core";
1
+ import { TARGET_PATH, computed, emitDevWarning, signal, vec2Signal } from "@glissade/core";
2
2
  //#region src/matrix.ts
3
3
  const IDENTITY = [
4
4
  1,
@@ -106,6 +106,22 @@ function filtersToCanvasFilter(filters) {
106
106
  }
107
107
  }).join(" ");
108
108
  }
109
+ /**
110
+ * Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
111
+ * deterministic on both backends (it is just filters). intensity stacks more
112
+ * layers; pair with a signal binding to follow an animated fill.
113
+ */
114
+ function glow(color, radius = 16, intensity = 2) {
115
+ const layers = [];
116
+ for (let i = 0; i < Math.max(1, intensity); i++) layers.push({
117
+ kind: "drop-shadow",
118
+ dx: 0,
119
+ dy: 0,
120
+ blur: radius * (1 + i * 1.5),
121
+ color
122
+ });
123
+ return layers;
124
+ }
109
125
  function createDisplayListBuilder(size) {
110
126
  const commands = [];
111
127
  const resources = [];
@@ -192,6 +208,25 @@ function breakLines(text, font, maxWidth, measurer) {
192
208
  * signal; transforms are computed matrix signals; emit() is pure — it reads
193
209
  * signals and ctx only, and produces IR commands, never canvas calls.
194
210
  */
211
+ const ANCHOR_PRESETS = {
212
+ "center": [.5, .5],
213
+ "top-left": [0, 0],
214
+ "top": [.5, 0],
215
+ "top-right": [1, 0],
216
+ "left": [0, .5],
217
+ "right": [1, .5],
218
+ "bottom-left": [0, 1],
219
+ "bottom": [.5, 1],
220
+ "bottom-right": [1, 1]
221
+ };
222
+ function resolveAnchor(spec) {
223
+ if (typeof spec === "string") {
224
+ const preset = ANCHOR_PRESETS[spec];
225
+ if (!preset) throw new Error(`unknown anchor '${spec}' (use a preset like 'top-left' or a [ax, ay] pair)`);
226
+ return preset;
227
+ }
228
+ return [spec[0], spec[1]];
229
+ }
195
230
  function initScalar(sig, init) {
196
231
  if (typeof init === "function") sig.bindSource(init);
197
232
  else if (init !== void 0) sig.set(init);
@@ -211,7 +246,12 @@ var Node = class {
211
246
  blend;
212
247
  zIndex;
213
248
  filters;
249
+ /** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
250
+ anchor;
251
+ /** True only when the author SET an anchor — unset keeps the legacy origin. */
252
+ hasAnchor;
214
253
  parent = null;
254
+ #warnedAnchor = false;
215
255
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
216
256
  interactive = false;
217
257
  /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
@@ -237,7 +277,20 @@ var Node = class {
237
277
  this.blend = initScalar(signal("source-over"), props.blend);
238
278
  this.zIndex = initScalar(signal(0), props.zIndex);
239
279
  this.filters = initScalar(signal([]), props.filters);
240
- this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
280
+ this.hasAnchor = props.anchor !== void 0;
281
+ this.anchor = resolveAnchor(props.anchor ?? "center");
282
+ this.localMatrix = computed(() => {
283
+ const trs = fromTRS(this.position(), this.rotation(), this.scale());
284
+ const [sx, sy] = this.anchorShift();
285
+ return sx === 0 && sy === 0 ? trs : multiply(trs, [
286
+ 1,
287
+ 0,
288
+ 0,
289
+ 1,
290
+ sx,
291
+ sy
292
+ ]);
293
+ }, { equals: matEquals });
241
294
  this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
242
295
  this.registerTarget("position", this.position);
243
296
  this.registerTarget("position.x", this.position.x);
@@ -264,12 +317,15 @@ var Node = class {
264
317
  return null;
265
318
  }
266
319
  /**
267
- * Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
268
- * can place any anchor correctly. Default: center-anchored (every shape).
269
- * Text overrides it draws from a left/center/right baseline origin.
320
+ * Vector from the DRAW origin to the intrinsic box's top-left, in the
321
+ * geometry space draw() emits into (anchor-independent the anchor shift
322
+ * lives in localMatrix). Hit testing boxes nodes with this. Default:
323
+ * center-anchored geometry (every shape). Text overrides — it draws from a
324
+ * left/center/right baseline origin; Path from author-positioned bounds.
270
325
  */
271
- flowOffset(measurer) {
272
- const size = this.intrinsicSize(measurer) ?? {
326
+ drawOffset(measurer) {
327
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
328
+ const size = this.intrinsicSize(m) ?? {
273
329
  w: 0,
274
330
  h: 0
275
331
  };
@@ -278,16 +334,59 @@ var Node = class {
278
334
  y: -size.h / 2
279
335
  };
280
336
  }
337
+ /**
338
+ * Vector from the node ORIGIN (the point `position` places) to the box's
339
+ * top-left, so Layout can place any node. With an anchor this is exactly
340
+ * (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
341
+ */
342
+ flowOffset(measurer) {
343
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
344
+ const d = this.drawOffset(m);
345
+ const [sx, sy] = this.anchorShift(m);
346
+ return {
347
+ x: d.x + sx,
348
+ y: d.y + sy
349
+ };
350
+ }
351
+ /**
352
+ * Translation composed after TRS in localMatrix: moves the drawn box so the
353
+ * anchor point lands on the origin. shift = −(drawOffset + anchor·size).
354
+ * No anchor set → zero shift, the legacy origin (shape center / Text
355
+ * baseline / Path author coords) — every pre-anchor scene is byte-stable.
356
+ * An EXPLICIT anchor pins position to that fraction of the box, even
357
+ * 'center' (which differs from the legacy origin only for Text and Path).
358
+ * Nodes without an intrinsic box (Group) warn once and ignore it.
359
+ */
360
+ anchorShift(measurer) {
361
+ if (!this.hasAnchor) return [0, 0];
362
+ const [ax, ay] = this.anchor;
363
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
364
+ const size = this.intrinsicSize(m);
365
+ if (!size) {
366
+ if (!this.#warnedAnchor) {
367
+ this.#warnedAnchor = true;
368
+ emitDevWarning(`anchor set on a node without an intrinsic box${this.id ? ` ('${this.id}')` : ""} — ignored (give it a sized node, or position children explicitly)`);
369
+ }
370
+ return [0, 0];
371
+ }
372
+ const d = this.drawOffset(m);
373
+ const sx = -(d.x + ax * size.w);
374
+ const sy = -(d.y + ay * size.h);
375
+ return [sx === 0 ? 0 : sx, sy === 0 ? 0 : sy];
376
+ }
281
377
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
282
378
  requiresGroup() {
283
379
  return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
284
380
  }
381
+ /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
382
+ groupShader() {}
285
383
  emit(out, ctx) {
286
384
  const opacity = this.opacity();
287
385
  if (opacity <= 0) return;
288
386
  const local = this.localMatrix();
289
387
  const isIdentity = matEquals(local, IDENTITY);
290
- const group = this.requiresGroup();
388
+ const shader = this.groupShader();
389
+ const group = this.requiresGroup() || shader !== void 0;
291
390
  out.push({ op: "save" });
292
391
  if (!isIdentity) out.push({
293
392
  op: "transform",
@@ -297,7 +396,8 @@ var Node = class {
297
396
  op: "pushGroup",
298
397
  opacity,
299
398
  blend: this.blend(),
300
- filters: this.filters()
399
+ filters: this.filters(),
400
+ ...shader !== void 0 ? { shader } : {}
301
401
  });
302
402
  this.draw(out, ctx);
303
403
  if (group) out.push({ op: "popGroup" });
@@ -310,6 +410,101 @@ var Node = class {
310
410
  * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
311
411
  * Path/Image/Video/Layout arrive with their milestones.
312
412
  */
413
+ /** Rounded-rect path segments — Rect's outline, shared with Highlight. */
414
+ function roundedRectSegs(x, y, w, h, r) {
415
+ if (r <= 0) return [
416
+ [
417
+ "M",
418
+ x,
419
+ y
420
+ ],
421
+ [
422
+ "L",
423
+ x + w,
424
+ y
425
+ ],
426
+ [
427
+ "L",
428
+ x + w,
429
+ y + h
430
+ ],
431
+ [
432
+ "L",
433
+ x,
434
+ y + h
435
+ ],
436
+ ["Z"]
437
+ ];
438
+ const HALF = Math.PI / 2;
439
+ return [
440
+ [
441
+ "M",
442
+ x + r,
443
+ y
444
+ ],
445
+ [
446
+ "L",
447
+ x + w - r,
448
+ y
449
+ ],
450
+ [
451
+ "E",
452
+ x + w - r,
453
+ y + r,
454
+ r,
455
+ r,
456
+ 0,
457
+ -HALF,
458
+ 0
459
+ ],
460
+ [
461
+ "L",
462
+ x + w,
463
+ y + h - r
464
+ ],
465
+ [
466
+ "E",
467
+ x + w - r,
468
+ y + h - r,
469
+ r,
470
+ r,
471
+ 0,
472
+ 0,
473
+ HALF
474
+ ],
475
+ [
476
+ "L",
477
+ x + r,
478
+ y + h
479
+ ],
480
+ [
481
+ "E",
482
+ x + r,
483
+ y + h - r,
484
+ r,
485
+ r,
486
+ 0,
487
+ HALF,
488
+ Math.PI
489
+ ],
490
+ [
491
+ "L",
492
+ x,
493
+ y + r
494
+ ],
495
+ [
496
+ "E",
497
+ x + r,
498
+ y + r,
499
+ r,
500
+ r,
501
+ 0,
502
+ Math.PI,
503
+ Math.PI + HALF
504
+ ],
505
+ ["Z"]
506
+ ];
507
+ }
313
508
  var Group = class extends Node {
314
509
  children;
315
510
  constructor(props = {}) {
@@ -399,101 +594,8 @@ var Rect = class extends Shape {
399
594
  pathSegs() {
400
595
  const w = this.width();
401
596
  const h = this.height();
402
- const x = -w / 2;
403
- const y = -h / 2;
404
597
  const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
405
- if (r <= 0) return [
406
- [
407
- "M",
408
- x,
409
- y
410
- ],
411
- [
412
- "L",
413
- x + w,
414
- y
415
- ],
416
- [
417
- "L",
418
- x + w,
419
- y + h
420
- ],
421
- [
422
- "L",
423
- x,
424
- y + h
425
- ],
426
- ["Z"]
427
- ];
428
- const HALF = Math.PI / 2;
429
- return [
430
- [
431
- "M",
432
- x + r,
433
- y
434
- ],
435
- [
436
- "L",
437
- x + w - r,
438
- y
439
- ],
440
- [
441
- "E",
442
- x + w - r,
443
- y + r,
444
- r,
445
- r,
446
- 0,
447
- -HALF,
448
- 0
449
- ],
450
- [
451
- "L",
452
- x + w,
453
- y + h - r
454
- ],
455
- [
456
- "E",
457
- x + w - r,
458
- y + h - r,
459
- r,
460
- r,
461
- 0,
462
- 0,
463
- HALF
464
- ],
465
- [
466
- "L",
467
- x + r,
468
- y + h
469
- ],
470
- [
471
- "E",
472
- x + r,
473
- y + h - r,
474
- r,
475
- r,
476
- 0,
477
- HALF,
478
- Math.PI
479
- ],
480
- [
481
- "L",
482
- x,
483
- y + r
484
- ],
485
- [
486
- "E",
487
- x + r,
488
- y + r,
489
- r,
490
- r,
491
- 0,
492
- Math.PI,
493
- Math.PI + HALF
494
- ],
495
- ["Z"]
496
- ];
598
+ return roundedRectSegs(-w / 2, -h / 2, w, h, r);
497
599
  }
498
600
  };
499
601
  var Circle = class extends Shape {
@@ -578,7 +680,7 @@ var Path = class extends Shape {
578
680
  };
579
681
  }
580
682
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
581
- flowOffset() {
683
+ drawOffset() {
582
684
  const b = this.bounds();
583
685
  return {
584
686
  x: b.minX,
@@ -761,20 +863,65 @@ var Text = class extends Node {
761
863
  };
762
864
  }
763
865
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
764
- flowOffset(measurer) {
765
- const size = this.intrinsicSize(measurer);
866
+ drawOffset(measurer) {
867
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
868
+ const size = this.intrinsicSize(m);
766
869
  const font = {
767
870
  family: this.fontFamily,
768
871
  size: this.fontSize(),
769
872
  weight: this.fontWeight
770
873
  };
771
- const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, measurer)[0] ?? "";
772
- const ascent = measurer.measureText(firstLine, font).ascent;
874
+ const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m)[0] ?? "";
875
+ const ascent = m.measureText(firstLine, font).ascent;
773
876
  return {
774
877
  x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
775
878
  y: -ascent
776
879
  };
777
880
  }
881
+ /**
882
+ * The wrapped box {w, h}, measured with the scene's active measurer — the
883
+ * same numbers Layout flows with, public so bindings never hand-calculate
884
+ * text dimensions (e.g. underline width = () => title.measuredSize().w).
885
+ */
886
+ measuredSize(measurer) {
887
+ return this.intrinsicSize(measurer ?? this.measurerSource?.() ?? estimatingMeasurer);
888
+ }
889
+ /**
890
+ * Per-line ink boxes in this node's DRAW space (origin = first baseline at
891
+ * the align edge), from the same breakLines pass that draws. Pull-based:
892
+ * re-measures when text/font/width animate. Blank lines (from '\n\n')
893
+ * produce no box. The substrate for highlights, underlines, per-line
894
+ * reveals, selections.
895
+ */
896
+ lineBoxes(measurer) {
897
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
898
+ const text = this.text();
899
+ if (!text) return [];
900
+ const font = {
901
+ family: this.fontFamily,
902
+ size: this.fontSize(),
903
+ weight: this.fontWeight
904
+ };
905
+ const maxWidth = this.width();
906
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
907
+ const step = quantize(font.size * this.lineHeight);
908
+ const boxes = [];
909
+ for (let i = 0; i < lines.length; i++) {
910
+ const line = lines[i];
911
+ if (!line) continue;
912
+ const met = m.measureText(line, font);
913
+ const w = quantize(met.width);
914
+ const x = this.align === "left" ? 0 : this.align === "center" ? -w / 2 : -w;
915
+ boxes.push({
916
+ text: line,
917
+ x,
918
+ y: i * step - met.ascent,
919
+ w,
920
+ h: met.ascent + met.descent
921
+ });
922
+ }
923
+ return boxes;
924
+ }
778
925
  draw(out, ctx) {
779
926
  const text = this.text();
780
927
  if (!text) return;
@@ -823,4 +970,4 @@ function requireLayoutEngine() {
823
970
  return engine;
824
971
  }
825
972
  //#endregion
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 };
973
+ export { IDENTITY as C, matEquals as D, invert as E, multiply as O, validateFilters as S, fromTRS as T, quantize as _, Circle as a, filtersToCanvasFilter as b, Path as c, Video as d, roundedRectSegs as f, estimatingMeasurer as g, breakLines as h, setLayoutEngine as i, Rect as l, resolveAnchor as m, getLayoutEngine as n, Group as o, Node as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Text as u, FilterValidationError as v, applyToPoint as w, glow as x, createDisplayListBuilder as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.3.0"
23
+ "@glissade/core": "0.4.1"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",