@glissade/scene 0.4.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 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";
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
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
@@ -38,7 +64,6 @@ declare class ColdAssetError extends Error {
38
64
  }
39
65
  //#endregion
40
66
  //#region src/shaderEffect.d.ts
41
-
42
67
  interface ShaderEffectProps extends NodeProps {
43
68
  children?: Node[];
44
69
  /** WGSL fragment module: `struct Uniforms {...}` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
@@ -193,4 +218,4 @@ declare function bindScene(scene: Scene, doc: Timeline): BindingCacheEntry;
193
218
  */
194
219
  declare function evaluate(scene: Scene, doc: Timeline, t: number): DisplayList;
195
220
  //#endregion
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 };
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 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";
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
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;
@@ -484,4 +563,4 @@ function evaluate(scene, doc, t) {
484
563
  });
485
564
  }
486
565
  //#endregion
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 };
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
  /**
@@ -203,6 +203,15 @@ declare const estimatingMeasurer: TextMeasurer;
203
203
  declare function breakLines(text: string, font: FontSpec, maxWidth: number | undefined, measurer: TextMeasurer): string[];
204
204
  //#endregion
205
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;
206
215
  interface EvalContext {
207
216
  /** The playhead value at evaluate() entry — the only time channel (§3.1). */
208
217
  readonly time: number;
@@ -223,6 +232,8 @@ interface NodeProps {
223
232
  zIndex?: PropInit<number>;
224
233
  /** Group filters (§3.4): the subtree composites as a unit through them. */
225
234
  filters?: PropInit<FilterSpec[]>;
235
+ /** Placement point + transform pivot on the intrinsic box; default 'center'. */
236
+ anchor?: AnchorSpec;
226
237
  }
227
238
  interface BindablePropTarget {
228
239
  bindSource(fn: () => unknown): void;
@@ -242,6 +253,7 @@ type HitArea = {
242
253
  r: number;
243
254
  };
244
255
  declare abstract class Node {
256
+ #private;
245
257
  readonly id: string | undefined;
246
258
  readonly position: Vec2Signal;
247
259
  readonly rotation: BindableSignal<number>;
@@ -250,6 +262,10 @@ declare abstract class Node {
250
262
  readonly blend: BindableSignal<BlendMode>;
251
263
  readonly zIndex: BindableSignal<number>;
252
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;
253
269
  parent: Node | null;
254
270
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
255
271
  interactive: boolean;
@@ -281,14 +297,35 @@ declare abstract class Node {
281
297
  h: number;
282
298
  } | null;
283
299
  /**
284
- * Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
285
- * can place any anchor correctly. Default: center-anchored (every shape).
286
- * 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.
287
305
  */
288
- flowOffset(measurer: TextMeasurer): {
306
+ drawOffset(measurer?: TextMeasurer): {
289
307
  x: number;
290
308
  y: number;
291
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;
292
329
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
293
330
  protected requiresGroup(): boolean;
294
331
  /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
@@ -297,6 +334,8 @@ declare abstract class Node {
297
334
  }
298
335
  //#endregion
299
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[];
300
339
  declare class Group extends Node {
301
340
  readonly children: Node[];
302
341
  constructor(props?: NodeProps & {
@@ -369,7 +408,7 @@ declare class Path extends Shape {
369
408
  h: number;
370
409
  };
371
410
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
372
- flowOffset(): {
411
+ drawOffset(): {
373
412
  x: number;
374
413
  y: number;
375
414
  };
@@ -430,6 +469,14 @@ declare class Video extends Node {
430
469
  mediaTime(t: number): number | null;
431
470
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
432
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
+ }
433
480
  interface TextProps extends NodeProps {
434
481
  text?: PropInit<string>;
435
482
  fill?: PropInit<string>;
@@ -458,10 +505,27 @@ declare class Text extends Node {
458
505
  h: number;
459
506
  };
460
507
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
461
- flowOffset(measurer: TextMeasurer): {
508
+ drawOffset(measurer?: TextMeasurer): {
462
509
  x: number;
463
510
  y: number;
464
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[];
465
529
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
466
530
  }
467
531
  //#endregion
@@ -513,4 +577,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
513
577
  declare function getLayoutEngine(): LayoutEngine | null;
514
578
  declare function requireLayoutEngine(): LayoutEngine;
515
579
  //#endregion
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 };
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,
@@ -208,6 +208,25 @@ function breakLines(text, font, maxWidth, measurer) {
208
208
  * signal; transforms are computed matrix signals; emit() is pure — it reads
209
209
  * signals and ctx only, and produces IR commands, never canvas calls.
210
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
+ }
211
230
  function initScalar(sig, init) {
212
231
  if (typeof init === "function") sig.bindSource(init);
213
232
  else if (init !== void 0) sig.set(init);
@@ -227,7 +246,12 @@ var Node = class {
227
246
  blend;
228
247
  zIndex;
229
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;
230
253
  parent = null;
254
+ #warnedAnchor = false;
231
255
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
232
256
  interactive = false;
233
257
  /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
@@ -253,7 +277,20 @@ var Node = class {
253
277
  this.blend = initScalar(signal("source-over"), props.blend);
254
278
  this.zIndex = initScalar(signal(0), props.zIndex);
255
279
  this.filters = initScalar(signal([]), props.filters);
256
- 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 });
257
294
  this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
258
295
  this.registerTarget("position", this.position);
259
296
  this.registerTarget("position.x", this.position.x);
@@ -280,12 +317,15 @@ var Node = class {
280
317
  return null;
281
318
  }
282
319
  /**
283
- * Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
284
- * can place any anchor correctly. Default: center-anchored (every shape).
285
- * 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.
286
325
  */
287
- flowOffset(measurer) {
288
- const size = this.intrinsicSize(measurer) ?? {
326
+ drawOffset(measurer) {
327
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
328
+ const size = this.intrinsicSize(m) ?? {
289
329
  w: 0,
290
330
  h: 0
291
331
  };
@@ -294,6 +334,46 @@ var Node = class {
294
334
  y: -size.h / 2
295
335
  };
296
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
+ }
297
377
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
298
378
  requiresGroup() {
299
379
  return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
@@ -330,6 +410,101 @@ var Node = class {
330
410
  * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
331
411
  * Path/Image/Video/Layout arrive with their milestones.
332
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
+ }
333
508
  var Group = class extends Node {
334
509
  children;
335
510
  constructor(props = {}) {
@@ -419,101 +594,8 @@ var Rect = class extends Shape {
419
594
  pathSegs() {
420
595
  const w = this.width();
421
596
  const h = this.height();
422
- const x = -w / 2;
423
- const y = -h / 2;
424
597
  const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
425
- if (r <= 0) return [
426
- [
427
- "M",
428
- x,
429
- y
430
- ],
431
- [
432
- "L",
433
- x + w,
434
- y
435
- ],
436
- [
437
- "L",
438
- x + w,
439
- y + h
440
- ],
441
- [
442
- "L",
443
- x,
444
- y + h
445
- ],
446
- ["Z"]
447
- ];
448
- const HALF = Math.PI / 2;
449
- return [
450
- [
451
- "M",
452
- x + r,
453
- y
454
- ],
455
- [
456
- "L",
457
- x + w - r,
458
- y
459
- ],
460
- [
461
- "E",
462
- x + w - r,
463
- y + r,
464
- r,
465
- r,
466
- 0,
467
- -HALF,
468
- 0
469
- ],
470
- [
471
- "L",
472
- x + w,
473
- y + h - r
474
- ],
475
- [
476
- "E",
477
- x + w - r,
478
- y + h - r,
479
- r,
480
- r,
481
- 0,
482
- 0,
483
- HALF
484
- ],
485
- [
486
- "L",
487
- x + r,
488
- y + h
489
- ],
490
- [
491
- "E",
492
- x + r,
493
- y + h - r,
494
- r,
495
- r,
496
- 0,
497
- HALF,
498
- Math.PI
499
- ],
500
- [
501
- "L",
502
- x,
503
- y + r
504
- ],
505
- [
506
- "E",
507
- x + r,
508
- y + r,
509
- r,
510
- r,
511
- 0,
512
- Math.PI,
513
- Math.PI + HALF
514
- ],
515
- ["Z"]
516
- ];
598
+ return roundedRectSegs(-w / 2, -h / 2, w, h, r);
517
599
  }
518
600
  };
519
601
  var Circle = class extends Shape {
@@ -598,7 +680,7 @@ var Path = class extends Shape {
598
680
  };
599
681
  }
600
682
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
601
- flowOffset() {
683
+ drawOffset() {
602
684
  const b = this.bounds();
603
685
  return {
604
686
  x: b.minX,
@@ -781,20 +863,65 @@ var Text = class extends Node {
781
863
  };
782
864
  }
783
865
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
784
- flowOffset(measurer) {
785
- const size = this.intrinsicSize(measurer);
866
+ drawOffset(measurer) {
867
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
868
+ const size = this.intrinsicSize(m);
786
869
  const font = {
787
870
  family: this.fontFamily,
788
871
  size: this.fontSize(),
789
872
  weight: this.fontWeight
790
873
  };
791
- const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, measurer)[0] ?? "";
792
- 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;
793
876
  return {
794
877
  x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
795
878
  y: -ascent
796
879
  };
797
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
+ }
798
925
  draw(out, ctx) {
799
926
  const text = this.text();
800
927
  if (!text) return;
@@ -843,4 +970,4 @@ function requireLayoutEngine() {
843
970
  return engine;
844
971
  }
845
972
  //#endregion
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 };
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.4.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.4.0"
23
+ "@glissade/core": "0.4.1"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",