@glissade/scene 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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 applyToPoint, A as breakLines, B as Paint, C as EvalContext, D as PropInit, E as NodeProps, F as DisplayListBuilder, G as ShaderRef, H as Rect$1, I as DrawCommand, J as filtersToCanvasFilter, K as StrokeStyle, L as FilterSpec, M as quantize, N as BlendMode, O as TextMeasurer, P as DisplayList, Q as Mat2x3, R as FilterValidationError, S as BindablePropTarget, T as Node, U as Resource, V as PathSeg, W as ResourceId, X as validateFilters, Y as glow, Z as IDENTITY, _ as ShapeProps, a as LayoutEngineMissingError, b as Video, c as requireLayoutEngine, d as Group, et as fromTRS, 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, nt as matEquals, p as ImageProps, q as createDisplayListBuilder, r as LayoutContainerSpec, rt as multiply, s as getLayoutEngine, t as LayoutBox, tt as invert, u as Circle, v as Text, w as HitArea, x as VideoProps, y as TextProps, z as FontSpec } from "./layoutEngine.js";
2
+ import { BindableSignal, BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
3
3
 
4
4
  //#region src/assets.d.ts
5
5
 
@@ -37,6 +37,24 @@ declare class ColdAssetError extends Error {
37
37
  constructor(assetId: string, detail: string, mediaT?: number);
38
38
  }
39
39
  //#endregion
40
+ //#region src/shaderEffect.d.ts
41
+
42
+ interface ShaderEffectProps extends NodeProps {
43
+ children?: Node[];
44
+ /** WGSL fragment module: `struct Uniforms {...}` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
45
+ wgsl: string;
46
+ /** Initial scalar uniforms; each becomes an animatable signal + track target 'u.<name>'. */
47
+ uniforms?: Record<string, number>;
48
+ }
49
+ declare class ShaderEffect extends Group {
50
+ readonly wgsl: string;
51
+ readonly uniformSignals: ReadonlyMap<string, BindableSignal<number>>;
52
+ constructor(props: ShaderEffectProps);
53
+ /** The live uniform signal (throws on unknown names — typos fail loudly). */
54
+ uniform(name: string): BindableSignal<number>;
55
+ protected groupShader(): ShaderRef;
56
+ }
57
+ //#endregion
40
58
  //#region src/raster2d.d.ts
41
59
  /** The structural path surface buildPath drives — DOM Path2D and @napi-rs Path2D both satisfy it. */
42
60
  interface PathLike {
@@ -60,6 +78,9 @@ interface Ctx2DLike<TPath, TDrawable> {
60
78
  fill(path: TPath): void;
61
79
  stroke(path: TPath): void;
62
80
  fillText(text: string, x: number, y: number): void;
81
+ measureText(text: string): {
82
+ width: number;
83
+ };
63
84
  drawImage(image: TDrawable, x: number, y: number, w?: number, h?: number): void;
64
85
  drawImage(image: TDrawable, sx: number, sy: number, sw: number, sh: number, x: number, y: number, w: number, h: number): void;
65
86
  setLineDash(segments: number[]): void;
@@ -85,15 +106,29 @@ interface Raster2DHost<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
85
106
  context(canvas: TCanvas): Ctx2DLike<TPath, TDrawable>;
86
107
  createCanvas(w: number, h: number): TCanvas;
87
108
  newPath(): TPath;
109
+ /**
110
+ * §3.7 shader pass: run the WGSL effect over the group layer and return a
111
+ * drawable replacement, or null when unavailable. Absent/null → the layer
112
+ * composites unfiltered per caps.shaders (warn by default, error opt-in).
113
+ * Only browser hosts wire this (via @glissade/effects-webgpu); headless
114
+ * backends stay GPU-free by construction.
115
+ */
116
+ applyShader?(layer: TCanvas, shader: ShaderRef, w: number, h: number): TDrawable | null;
88
117
  }
118
+ type ShaderCaps = 'warn' | 'error';
89
119
  declare function fontString(font: FontSpec): string;
90
120
  declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
91
121
  private readonly host;
122
+ /** caps.shaders (§3.7): what happens when a shader can't run here. */
123
+ private readonly shaderCaps;
92
124
  private readonly pool;
93
125
  private readonly pathCache;
126
+ private readonly pathBoundsCache;
94
127
  private readonly images;
95
128
  private readonly videos;
96
- constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>);
129
+ private warnedShaders;
130
+ constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>, /** caps.shaders (§3.7): what happens when a shader can't run here. */
131
+ shaderCaps?: ShaderCaps);
97
132
  /** Register a decoded still (kind 'image' assets). */
98
133
  setImageAsset(assetId: string, image: TDrawable): void;
99
134
  /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
@@ -101,6 +136,7 @@ declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
101
136
  dispose(): void;
102
137
  private resolveDrawable;
103
138
  private path;
139
+ private pathBounds;
104
140
  private buildPath;
105
141
  private acquire;
106
142
  private release;
@@ -157,4 +193,4 @@ declare function bindScene(scene: Scene, doc: Timeline): BindingCacheEntry;
157
193
  */
158
194
  declare function evaluate(scene: Scene, doc: Timeline, t: number): DisplayList;
159
195
  //#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 };
196
+ 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 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, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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 fromTRS, E as multiply, S as applyToPoint, T as matEquals, _ as createDisplayListBuilder, a as Circle, b as validateFilters, 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 invert, x as IDENTITY, y as glow } from "./layoutEngine.js";
2
+ import { bindTimeline, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, signal } from "@glissade/core";
3
3
  //#region src/assets.ts
4
4
  var ColdAssetError = class extends Error {
5
5
  assetId;
@@ -15,6 +15,45 @@ var ColdAssetError = class extends Error {
15
15
  }
16
16
  };
17
17
  //#endregion
18
+ //#region src/shaderEffect.ts
19
+ /**
20
+ * ShaderEffect (§3.7): a group whose rasterized subtree runs through a WGSL
21
+ * pass. THIS FILE IS PURE DATA — the GPU runner lives in the browser-only
22
+ * @glissade/effects-webgpu package; headless backends degrade per
23
+ * caps.shaders (passthrough + warning by default). Uniforms are per-name
24
+ * number signals registered as '<id>/u.<name>' track targets, so shader
25
+ * params animate exactly like any other property.
26
+ */
27
+ var ShaderEffect = class extends Group {
28
+ wgsl;
29
+ uniformSignals;
30
+ constructor(props) {
31
+ super(props);
32
+ this.wgsl = props.wgsl;
33
+ const map = /* @__PURE__ */ new Map();
34
+ for (const [name, value] of Object.entries(props.uniforms ?? {})) {
35
+ const sig = signal(value);
36
+ map.set(name, sig);
37
+ this.registerTarget(`u.${name}`, sig);
38
+ }
39
+ this.uniformSignals = map;
40
+ }
41
+ /** The live uniform signal (throws on unknown names — typos fail loudly). */
42
+ uniform(name) {
43
+ const sig = this.uniformSignals.get(name);
44
+ if (!sig) throw new Error(`ShaderEffect has no uniform '${name}' (have: ${[...this.uniformSignals.keys()].join(", ")})`);
45
+ return sig;
46
+ }
47
+ groupShader() {
48
+ const uniforms = {};
49
+ for (const [name, sig] of this.uniformSignals) uniforms[name] = sig();
50
+ return {
51
+ wgsl: this.wgsl,
52
+ uniforms
53
+ };
54
+ }
55
+ };
56
+ //#endregion
18
57
  //#region src/raster2d.ts
19
58
  /**
20
59
  * The shared DisplayList interpreter (§3.4): one command walk over the
@@ -26,14 +65,81 @@ var ColdAssetError = class extends Error {
26
65
  function fontString(font) {
27
66
  return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
28
67
  }
68
+ /**
69
+ * How far a filter chain can paint beyond its input (device px). Each stage
70
+ * feeds the next, so outsets ADD. Gaussian reach: Skia truncates at 3σ and
71
+ * the CSS blur/shadow radii are ≥ σ, so 3× the radius over-covers either
72
+ * convention. Color-only filters map transparent → transparent: zero outset.
73
+ */
74
+ function filterOutset(filters) {
75
+ let total = 0;
76
+ for (const f of filters ?? []) if (f.kind === "blur") total += 3 * f.radius;
77
+ else if (f.kind === "drop-shadow") total += Math.max(Math.abs(f.dx), Math.abs(f.dy)) + 3 * f.blur;
78
+ return total;
79
+ }
80
+ function growBounds(b, x, y) {
81
+ if (!b) return {
82
+ minX: x,
83
+ minY: y,
84
+ maxX: x,
85
+ maxY: y
86
+ };
87
+ if (x < b.minX) b.minX = x;
88
+ if (y < b.minY) b.minY = y;
89
+ if (x > b.maxX) b.maxX = x;
90
+ if (y > b.maxY) b.maxY = y;
91
+ return b;
92
+ }
93
+ /** Local-space rect (already outset) → device-space box under m. */
94
+ function accumulateRect(layer, m, x0, y0, x1, y1) {
95
+ for (const [x, y] of [
96
+ [x0, y0],
97
+ [x1, y0],
98
+ [x0, y1],
99
+ [x1, y1]
100
+ ]) layer.bounds = growBounds(layer.bounds, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
101
+ }
102
+ /** Control-point box of a path — curves and rotated ellipses stay inside it. */
103
+ function segsBounds(segs) {
104
+ let b = null;
105
+ const pt = (x, y) => {
106
+ b = growBounds(b, x, y);
107
+ };
108
+ for (const seg of segs) switch (seg[0]) {
109
+ case "M":
110
+ case "L":
111
+ pt(seg[1], seg[2]);
112
+ break;
113
+ case "C":
114
+ pt(seg[1], seg[2]);
115
+ pt(seg[3], seg[4]);
116
+ pt(seg[5], seg[6]);
117
+ break;
118
+ case "Q":
119
+ pt(seg[1], seg[2]);
120
+ pt(seg[3], seg[4]);
121
+ break;
122
+ case "E": {
123
+ const r = Math.max(seg[3], seg[4]);
124
+ pt(seg[1] - r, seg[2] - r);
125
+ pt(seg[1] + r, seg[2] + r);
126
+ break;
127
+ }
128
+ }
129
+ return b;
130
+ }
29
131
  var Raster2D = class {
30
132
  host;
133
+ shaderCaps;
31
134
  pool = [];
32
135
  pathCache = /* @__PURE__ */ new WeakMap();
136
+ pathBoundsCache = /* @__PURE__ */ new WeakMap();
33
137
  images = /* @__PURE__ */ new Map();
34
138
  videos = /* @__PURE__ */ new Map();
35
- constructor(host) {
139
+ warnedShaders = false;
140
+ constructor(host, shaderCaps = "warn") {
36
141
  this.host = host;
142
+ this.shaderCaps = shaderCaps;
37
143
  }
38
144
  /** Register a decoded still (kind 'image' assets). */
39
145
  setImageAsset(assetId, image) {
@@ -74,6 +180,12 @@ var Raster2D = class {
74
180
  }
75
181
  return p;
76
182
  }
183
+ pathBounds(resources, id) {
184
+ const res = resources[id];
185
+ if (!res || res.kind !== "path") return null;
186
+ if (!this.pathBoundsCache.has(res)) this.pathBoundsCache.set(res, segsBounds(res.segs));
187
+ return this.pathBoundsCache.get(res) ?? null;
188
+ }
77
189
  buildPath(segs) {
78
190
  const p = this.host.newPath();
79
191
  for (const seg of segs) switch (seg[0]) {
@@ -122,17 +234,25 @@ var Raster2D = class {
122
234
  ctx: base,
123
235
  canvas: null,
124
236
  opacity: 1,
125
- blend: "source-over"
237
+ blend: "source-over",
238
+ bounds: null,
239
+ unbounded: false
126
240
  }];
127
241
  const ctxOf = () => layers[layers.length - 1].ctx;
242
+ const top = () => layers[layers.length - 1];
243
+ let mat = IDENTITY;
244
+ const matStack = [];
128
245
  for (const cmd of list.commands) switch (cmd.op) {
129
246
  case "save":
247
+ matStack.push(mat);
130
248
  ctxOf().save();
131
249
  break;
132
250
  case "restore":
251
+ mat = matStack.pop() ?? mat;
133
252
  ctxOf().restore();
134
253
  break;
135
254
  case "transform":
255
+ mat = multiply(mat, cmd.m);
136
256
  ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
137
257
  break;
138
258
  case "clip":
@@ -142,6 +262,8 @@ var Raster2D = class {
142
262
  const ctx = ctxOf();
143
263
  ctx.fillStyle = cmd.paint.color;
144
264
  ctx.fill(this.path(list.resources, cmd.path));
265
+ const b = this.pathBounds(list.resources, cmd.path);
266
+ if (b) accumulateRect(top(), mat, b.minX, b.minY, b.maxX, b.maxY);
145
267
  break;
146
268
  }
147
269
  case "strokePath": {
@@ -153,6 +275,11 @@ var Raster2D = class {
153
275
  if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
154
276
  ctx.stroke(this.path(list.resources, cmd.path));
155
277
  if (cmd.stroke.dash) ctx.setLineDash([]);
278
+ const b = this.pathBounds(list.resources, cmd.path);
279
+ if (b) {
280
+ const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
281
+ accumulateRect(top(), mat, b.minX - o, b.minY - o, b.maxX + o, b.maxY + o);
282
+ }
156
283
  break;
157
284
  }
158
285
  case "fillText": {
@@ -162,6 +289,15 @@ var Raster2D = class {
162
289
  ctx.textBaseline = "alphabetic";
163
290
  ctx.textAlign = cmd.align ?? "left";
164
291
  ctx.fillText(cmd.text, cmd.x, cmd.y);
292
+ try {
293
+ const width = ctx.measureText(cmd.text).width;
294
+ const align = cmd.align ?? "left";
295
+ const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
296
+ const m = cmd.font.size;
297
+ accumulateRect(top(), mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m);
298
+ } catch {
299
+ top().unbounded = true;
300
+ }
165
301
  break;
166
302
  }
167
303
  case "drawImage": {
@@ -173,6 +309,7 @@ var Raster2D = class {
173
309
  const { x, y, w: dw, h: dh } = cmd.dst;
174
310
  if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
175
311
  else ctx.drawImage(drawable, x, y, dw, dh);
312
+ accumulateRect(top(), mat, x, y, x + dw, y + dh);
176
313
  break;
177
314
  }
178
315
  case "pushGroup": {
@@ -187,7 +324,11 @@ var Raster2D = class {
187
324
  canvas: layerCanvas,
188
325
  opacity: cmd.opacity,
189
326
  blend: cmd.blend,
190
- filter: filtersToCanvasFilter(cmd.filters)
327
+ filter: filtersToCanvasFilter(cmd.filters),
328
+ filters: cmd.filters,
329
+ ...cmd.shader !== void 0 ? { shader: cmd.shader } : {},
330
+ bounds: null,
331
+ unbounded: false
191
332
  });
192
333
  break;
193
334
  }
@@ -195,12 +336,60 @@ var Raster2D = class {
195
336
  const layer = layers.pop();
196
337
  if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
197
338
  const parent = ctxOf();
339
+ let drawable = layer.canvas;
340
+ let shaderReplaced = false;
341
+ if (layer.shader !== void 0) {
342
+ const replaced = this.host.applyShader?.(layer.canvas, layer.shader, w, h) ?? null;
343
+ if (replaced !== null) {
344
+ drawable = replaced;
345
+ shaderReplaced = true;
346
+ layer.bounds = {
347
+ minX: 0,
348
+ minY: 0,
349
+ maxX: w,
350
+ maxY: h
351
+ };
352
+ layer.unbounded = false;
353
+ } 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");
354
+ else if (!this.warnedShaders) {
355
+ this.warnedShaders = true;
356
+ emitDevWarning("ShaderEffect pass skipped: no shader runner here (headless or webgpu-less browser) — subtree composites unfiltered (§3.7 caps.shaders)");
357
+ }
358
+ }
359
+ const hasFilter = layer.filter !== void 0 && layer.filter !== "none";
360
+ const outset = hasFilter ? filterOutset(layer.filters) : 0;
361
+ const parentLayer = top();
362
+ if (layer.unbounded || layer.blend !== "source-over") parentLayer.unbounded = true;
363
+ else if (layer.bounds) accumulateRect(parentLayer, IDENTITY, layer.bounds.minX - outset, layer.bounds.minY - outset, layer.bounds.maxX + outset, layer.bounds.maxY + outset);
364
+ const clippable = hasFilter && !shaderReplaced && !layer.unbounded && layer.blend === "source-over";
365
+ if (clippable && layer.bounds === null) {
366
+ this.release(layer.canvas);
367
+ break;
368
+ }
198
369
  parent.save();
199
370
  parent.resetTransform();
371
+ if (clippable && layer.bounds) {
372
+ const x0 = Math.max(0, Math.floor(layer.bounds.minX - outset));
373
+ const y0 = Math.max(0, Math.floor(layer.bounds.minY - outset));
374
+ const x1 = Math.min(w, Math.ceil(layer.bounds.maxX + outset));
375
+ const y1 = Math.min(h, Math.ceil(layer.bounds.maxY + outset));
376
+ if (x0 >= x1 || y0 >= y1) {
377
+ parent.restore();
378
+ this.release(layer.canvas);
379
+ break;
380
+ }
381
+ const clip = this.host.newPath();
382
+ clip.moveTo(x0, y0);
383
+ clip.lineTo(x1, y0);
384
+ clip.lineTo(x1, y1);
385
+ clip.lineTo(x0, y1);
386
+ clip.closePath();
387
+ parent.clip(clip, "nonzero");
388
+ }
200
389
  parent.globalAlpha = layer.opacity;
201
- if (layer.filter !== void 0 && layer.filter !== "none") parent.filter = layer.filter;
390
+ if (hasFilter) parent.filter = layer.filter;
202
391
  parent.globalCompositeOperation = layer.blend;
203
- parent.drawImage(layer.canvas, 0, 0);
392
+ parent.drawImage(drawable, 0, 0);
204
393
  parent.restore();
205
394
  this.release(layer.canvas);
206
395
  break;
@@ -295,4 +484,4 @@ function evaluate(scene, doc, t) {
295
484
  });
296
485
  }
297
486
  //#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 };
487
+ export { Circle, ColdAssetError, DuplicateNodeIdError, FilterValidationError, Group, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Path, Raster2D, Rect, ShaderEffect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, glow, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
@@ -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';
@@ -271,6 +291,8 @@ declare abstract class Node {
271
291
  };
272
292
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
273
293
  protected requiresGroup(): boolean;
294
+ /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
295
+ protected groupShader(): ShaderRef | undefined;
274
296
  emit(out: DisplayListBuilder, ctx: EvalContext): void;
275
297
  }
276
298
  //#endregion
@@ -491,4 +513,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
491
513
  declare function getLayoutEngine(): LayoutEngine | null;
492
514
  declare function requireLayoutEngine(): LayoutEngine;
493
515
  //#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 };
516
+ export { applyToPoint as $, breakLines as A, Paint as B, EvalContext as C, PropInit as D, NodeProps as E, DisplayListBuilder as F, ShaderRef as G, Rect$1 as H, DrawCommand as I, filtersToCanvasFilter as J, StrokeStyle as K, FilterSpec as L, quantize as M, BlendMode as N, TextMeasurer as O, DisplayList as P, Mat2x3 as Q, FilterValidationError as R, BindablePropTarget as S, Node as T, Resource as U, PathSeg as V, ResourceId as W, validateFilters as X, glow as Y, IDENTITY as Z, ShapeProps as _, LayoutEngineMissingError as a, Video as b, requireLayoutEngine as c, Group as d, fromTRS 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, matEquals as nt, LayoutResult as o, ImageProps as p, createDisplayListBuilder as q, LayoutContainerSpec as r, multiply as rt, getLayoutEngine as s, LayoutBox as t, invert as tt, Circle as u, Text as v, HitArea as w, VideoProps as x, TextProps as y, FontSpec as z };
@@ -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 = [];
@@ -282,12 +298,15 @@ var Node = class {
282
298
  requiresGroup() {
283
299
  return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
284
300
  }
301
+ /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
302
+ groupShader() {}
285
303
  emit(out, ctx) {
286
304
  const opacity = this.opacity();
287
305
  if (opacity <= 0) return;
288
306
  const local = this.localMatrix();
289
307
  const isIdentity = matEquals(local, IDENTITY);
290
- const group = this.requiresGroup();
308
+ const shader = this.groupShader();
309
+ const group = this.requiresGroup() || shader !== void 0;
291
310
  out.push({ op: "save" });
292
311
  if (!isIdentity) out.push({
293
312
  op: "transform",
@@ -297,7 +316,8 @@ var Node = class {
297
316
  op: "pushGroup",
298
317
  opacity,
299
318
  blend: this.blend(),
300
- filters: this.filters()
319
+ filters: this.filters(),
320
+ ...shader !== void 0 ? { shader } : {}
301
321
  });
302
322
  this.draw(out, ctx);
303
323
  if (group) out.push({ op: "popGroup" });
@@ -823,4 +843,4 @@ function requireLayoutEngine() {
823
843
  return engine;
824
844
  }
825
845
  //#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 };
846
+ export { fromTRS as C, multiply as E, applyToPoint as S, matEquals as T, createDisplayListBuilder as _, Circle as a, validateFilters 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, invert as w, IDENTITY as x, glow 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.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.3.0"
23
+ "@glissade/core": "0.4.0"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",