@glissade/scene 0.4.0 → 0.4.2

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 createDisplayListBuilder, A as NodeProps, B as DisplayList, C as WordBox, D as EvalContext, E as BindablePropTarget, F as breakLines, G as FontSpec, H as DrawCommand, I as estimatingMeasurer, J as Rect$1, K as Paint, L as quantize, M as resolveAnchor, N as TextMeasurer, O as HitArea, P as TextMetricsLite, Q as StrokeStyle, R as segmentWords, S as VideoProps, T as AnchorSpec, U as FilterSpec, V as DisplayListBuilder, W as FilterValidationError, X as ResourceId, Y as Resource, Z as ShaderRef, _ as Rect, a as LayoutEngineMissingError, at as applyToPoint, b as TextProps, c as requireLayoutEngine, ct as matEquals, d as Group, et as filtersToCanvasFilter, f as ImageNode, g as PathProps, h as Path, i as LayoutEngine, it as Mat2x3, j as PropInit, k as Node, l as setLayoutEngine, lt as multiply, m as LineBox, n as LayoutChildSpec, nt as validateFilters, ot as fromTRS, p as ImageProps, q as PathSeg, r as LayoutContainerSpec, rt as IDENTITY, s as getLayoutEngine, st as invert, t as LayoutBox, tt as glow, u as Circle, v as ShapeProps, w as roundedRectSegs, x as Video, y as Text, z as BlendMode } 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, type WordBox, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, glow, highlight, invert, matEquals, multiply, quantize, requireLayoutEngine, resolveAnchor, roundedRectSegs, segmentWords, 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 validateFilters, D as invert, E as fromTRS, O as matEquals, S as glow, T as applyToPoint, _ as quantize, a as Circle, b as createDisplayListBuilder, c as Path, d as Video, f as roundedRectSegs, g as estimatingMeasurer, h as breakLines, i as setLayoutEngine, k as multiply, 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 segmentWords, w as IDENTITY, x as filtersToCanvasFilter, y as FilterValidationError } 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, segmentWords, 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 NodeProps, D as EvalContext, N as TextMeasurer, V as DisplayListBuilder, a as LayoutEngineMissingError, d as Group, i as LayoutEngine, j as PropInit, k as Node, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox } from "./layoutEngine.js";
2
2
  import { BindableSignal } from "@glissade/core";
3
3
 
4
4
  //#region src/layout.d.ts
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
  /**
@@ -194,6 +194,12 @@ declare function quantize(v: number): number;
194
194
  * faithful; mount(), the CLI, and exporters always inject the real one.
195
195
  */
196
196
  declare const estimatingMeasurer: TextMeasurer;
197
+ /**
198
+ * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
199
+ * glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
200
+ * units the breaker flows.
201
+ */
202
+ declare function segmentWords(text: string): string[];
197
203
  /**
198
204
  * Greedy line breaking: explicit '\n' always breaks; otherwise word segments
199
205
  * flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
@@ -203,6 +209,15 @@ declare const estimatingMeasurer: TextMeasurer;
203
209
  declare function breakLines(text: string, font: FontSpec, maxWidth: number | undefined, measurer: TextMeasurer): string[];
204
210
  //#endregion
205
211
  //#region src/node.d.ts
212
+ /**
213
+ * Where `position` pins to on the node's intrinsic box, as fractions of its
214
+ * size — and the rotation/scale pivot (the Lottie anchor model). Default
215
+ * 'center' preserves every pre-anchor scene byte-for-byte. With a non-center
216
+ * anchor, grow direction falls out: anchor 'left' + a width track sweeps
217
+ * rightward, anchor [0, 1] grows a bar upward.
218
+ */
219
+ type AnchorSpec = 'center' | 'top-left' | 'top' | 'top-right' | 'left' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | readonly [number, number];
220
+ declare function resolveAnchor(spec: AnchorSpec): Vec2;
206
221
  interface EvalContext {
207
222
  /** The playhead value at evaluate() entry — the only time channel (§3.1). */
208
223
  readonly time: number;
@@ -223,6 +238,8 @@ interface NodeProps {
223
238
  zIndex?: PropInit<number>;
224
239
  /** Group filters (§3.4): the subtree composites as a unit through them. */
225
240
  filters?: PropInit<FilterSpec[]>;
241
+ /** Placement point + transform pivot on the intrinsic box; default 'center'. */
242
+ anchor?: AnchorSpec;
226
243
  }
227
244
  interface BindablePropTarget {
228
245
  bindSource(fn: () => unknown): void;
@@ -242,6 +259,7 @@ type HitArea = {
242
259
  r: number;
243
260
  };
244
261
  declare abstract class Node {
262
+ #private;
245
263
  readonly id: string | undefined;
246
264
  readonly position: Vec2Signal;
247
265
  readonly rotation: BindableSignal<number>;
@@ -250,6 +268,10 @@ declare abstract class Node {
250
268
  readonly blend: BindableSignal<BlendMode>;
251
269
  readonly zIndex: BindableSignal<number>;
252
270
  readonly filters: BindableSignal<FilterSpec[]>;
271
+ /** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
272
+ readonly anchor: Vec2;
273
+ /** True only when the author SET an anchor — unset keeps the legacy origin. */
274
+ readonly hasAnchor: boolean;
253
275
  parent: Node | null;
254
276
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
255
277
  interactive: boolean;
@@ -281,14 +303,35 @@ declare abstract class Node {
281
303
  h: number;
282
304
  } | null;
283
305
  /**
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.
306
+ * Vector from the DRAW origin to the intrinsic box's top-left, in the
307
+ * geometry space draw() emits into (anchor-independent the anchor shift
308
+ * lives in localMatrix). Hit testing boxes nodes with this. Default:
309
+ * center-anchored geometry (every shape). Text overrides — it draws from a
310
+ * left/center/right baseline origin; Path from author-positioned bounds.
311
+ */
312
+ drawOffset(measurer?: TextMeasurer): {
313
+ x: number;
314
+ y: number;
315
+ };
316
+ /**
317
+ * Vector from the node ORIGIN (the point `position` places) to the box's
318
+ * top-left, so Layout can place any node. With an anchor this is exactly
319
+ * (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
287
320
  */
288
- flowOffset(measurer: TextMeasurer): {
321
+ flowOffset(measurer?: TextMeasurer): {
289
322
  x: number;
290
323
  y: number;
291
324
  };
325
+ /**
326
+ * Translation composed after TRS in localMatrix: moves the drawn box so the
327
+ * anchor point lands on the origin. shift = −(drawOffset + anchor·size).
328
+ * No anchor set → zero shift, the legacy origin (shape center / Text
329
+ * baseline / Path author coords) — every pre-anchor scene is byte-stable.
330
+ * An EXPLICIT anchor pins position to that fraction of the box, even
331
+ * 'center' (which differs from the legacy origin only for Text and Path).
332
+ * Nodes without an intrinsic box (Group) warn once and ignore it.
333
+ */
334
+ protected anchorShift(measurer?: TextMeasurer): Vec2;
292
335
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
293
336
  protected requiresGroup(): boolean;
294
337
  /** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
@@ -297,6 +340,8 @@ declare abstract class Node {
297
340
  }
298
341
  //#endregion
299
342
  //#region src/nodes.d.ts
343
+ /** Rounded-rect path segments — Rect's outline, shared with Highlight. */
344
+ declare function roundedRectSegs(x: number, y: number, w: number, h: number, r: number): PathSeg[];
300
345
  declare class Group extends Node {
301
346
  readonly children: Node[];
302
347
  constructor(props?: NodeProps & {
@@ -369,7 +414,7 @@ declare class Path extends Shape {
369
414
  h: number;
370
415
  };
371
416
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
372
- flowOffset(): {
417
+ drawOffset(): {
373
418
  x: number;
374
419
  y: number;
375
420
  };
@@ -430,6 +475,24 @@ declare class Video extends Node {
430
475
  mediaTime(t: number): number | null;
431
476
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
432
477
  }
478
+ /** One laid-out line's ink box, in the Text node's draw space. */
479
+ interface LineBox {
480
+ text: string;
481
+ x: number;
482
+ y: number;
483
+ w: number;
484
+ h: number;
485
+ }
486
+ /** One word's ink box within a laid-out line, in the Text node's draw space. */
487
+ interface WordBox {
488
+ text: string;
489
+ /** laid-out line index (blank lines keep their slot in the numbering) */
490
+ line: number;
491
+ x: number;
492
+ y: number;
493
+ w: number;
494
+ h: number;
495
+ }
433
496
  interface TextProps extends NodeProps {
434
497
  text?: PropInit<string>;
435
498
  fill?: PropInit<string>;
@@ -458,10 +521,36 @@ declare class Text extends Node {
458
521
  h: number;
459
522
  };
460
523
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
461
- flowOffset(measurer: TextMeasurer): {
524
+ drawOffset(measurer?: TextMeasurer): {
462
525
  x: number;
463
526
  y: number;
464
527
  };
528
+ /**
529
+ * The wrapped box {w, h}, measured with the scene's active measurer — the
530
+ * same numbers Layout flows with, public so bindings never hand-calculate
531
+ * text dimensions (e.g. underline width = () => title.measuredSize().w).
532
+ */
533
+ measuredSize(measurer?: TextMeasurer): {
534
+ w: number;
535
+ h: number;
536
+ };
537
+ /**
538
+ * Per-line ink boxes in this node's DRAW space (origin = first baseline at
539
+ * the align edge), from the same breakLines pass that draws. Pull-based:
540
+ * re-measures when text/font/width animate. Blank lines (from '\n\n')
541
+ * produce no box. The substrate for highlights, underlines, per-line
542
+ * reveals, selections.
543
+ */
544
+ lineBoxes(measurer?: TextMeasurer): LineBox[];
545
+ /**
546
+ * Per-word ink boxes within each laid-out line — the SAME segmentation the
547
+ * breaker flows (Intl.Segmenter boundaries, punctuation glued), positioned
548
+ * by cumulative prefix advances so cross-word kerning is exact and word
549
+ * widths sum to the line's width. Whitespace contributes advance but no
550
+ * box. Pair index-wise with a narration manifest's word timestamps for
551
+ * karaoke; draw your own rects for sub-line multi-color token work.
552
+ */
553
+ wordBoxes(measurer?: TextMeasurer): WordBox[];
465
554
  protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
466
555
  }
467
556
  //#endregion
@@ -513,4 +602,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
513
602
  declare function getLayoutEngine(): LayoutEngine | null;
514
603
  declare function requireLayoutEngine(): LayoutEngine;
515
604
  //#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 };
605
+ export { createDisplayListBuilder as $, NodeProps as A, DisplayList as B, WordBox as C, EvalContext as D, BindablePropTarget as E, breakLines as F, FontSpec as G, DrawCommand as H, estimatingMeasurer as I, Rect$1 as J, Paint as K, quantize as L, resolveAnchor as M, TextMeasurer as N, HitArea as O, TextMetricsLite as P, StrokeStyle as Q, segmentWords as R, VideoProps as S, AnchorSpec as T, FilterSpec as U, DisplayListBuilder as V, FilterValidationError as W, ResourceId as X, Resource as Y, ShaderRef as Z, Rect as _, LayoutEngineMissingError as a, applyToPoint as at, TextProps as b, requireLayoutEngine as c, matEquals as ct, Group as d, filtersToCanvasFilter as et, ImageNode as f, PathProps as g, Path as h, LayoutEngine as i, Mat2x3 as it, PropInit as j, Node as k, setLayoutEngine as l, multiply as lt, LineBox as m, LayoutChildSpec as n, validateFilters as nt, LayoutResult as o, fromTRS as ot, ImageProps as p, PathSeg as q, LayoutContainerSpec as r, IDENTITY as rt, getLayoutEngine as s, invert as st, LayoutBox as t, glow as tt, Circle as u, ShapeProps as v, roundedRectSegs as w, Video as x, Text as y, BlendMode 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,
@@ -166,6 +166,11 @@ const estimatingMeasurer = { measureText(text, font) {
166
166
  };
167
167
  } };
168
168
  let wordSegmenter;
169
+ /**
170
+ * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
171
+ * glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
172
+ * units the breaker flows.
173
+ */
169
174
  function segmentWords(text) {
170
175
  if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
171
176
  if (wordSegmenter) {
@@ -208,6 +213,25 @@ function breakLines(text, font, maxWidth, measurer) {
208
213
  * signal; transforms are computed matrix signals; emit() is pure — it reads
209
214
  * signals and ctx only, and produces IR commands, never canvas calls.
210
215
  */
216
+ const ANCHOR_PRESETS = {
217
+ "center": [.5, .5],
218
+ "top-left": [0, 0],
219
+ "top": [.5, 0],
220
+ "top-right": [1, 0],
221
+ "left": [0, .5],
222
+ "right": [1, .5],
223
+ "bottom-left": [0, 1],
224
+ "bottom": [.5, 1],
225
+ "bottom-right": [1, 1]
226
+ };
227
+ function resolveAnchor(spec) {
228
+ if (typeof spec === "string") {
229
+ const preset = ANCHOR_PRESETS[spec];
230
+ if (!preset) throw new Error(`unknown anchor '${spec}' (use a preset like 'top-left' or a [ax, ay] pair)`);
231
+ return preset;
232
+ }
233
+ return [spec[0], spec[1]];
234
+ }
211
235
  function initScalar(sig, init) {
212
236
  if (typeof init === "function") sig.bindSource(init);
213
237
  else if (init !== void 0) sig.set(init);
@@ -227,7 +251,12 @@ var Node = class {
227
251
  blend;
228
252
  zIndex;
229
253
  filters;
254
+ /** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
255
+ anchor;
256
+ /** True only when the author SET an anchor — unset keeps the legacy origin. */
257
+ hasAnchor;
230
258
  parent = null;
259
+ #warnedAnchor = false;
231
260
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
232
261
  interactive = false;
233
262
  /** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
@@ -253,7 +282,20 @@ var Node = class {
253
282
  this.blend = initScalar(signal("source-over"), props.blend);
254
283
  this.zIndex = initScalar(signal(0), props.zIndex);
255
284
  this.filters = initScalar(signal([]), props.filters);
256
- this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
285
+ this.hasAnchor = props.anchor !== void 0;
286
+ this.anchor = resolveAnchor(props.anchor ?? "center");
287
+ this.localMatrix = computed(() => {
288
+ const trs = fromTRS(this.position(), this.rotation(), this.scale());
289
+ const [sx, sy] = this.anchorShift();
290
+ return sx === 0 && sy === 0 ? trs : multiply(trs, [
291
+ 1,
292
+ 0,
293
+ 0,
294
+ 1,
295
+ sx,
296
+ sy
297
+ ]);
298
+ }, { equals: matEquals });
257
299
  this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
258
300
  this.registerTarget("position", this.position);
259
301
  this.registerTarget("position.x", this.position.x);
@@ -280,12 +322,15 @@ var Node = class {
280
322
  return null;
281
323
  }
282
324
  /**
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.
325
+ * Vector from the DRAW origin to the intrinsic box's top-left, in the
326
+ * geometry space draw() emits into (anchor-independent the anchor shift
327
+ * lives in localMatrix). Hit testing boxes nodes with this. Default:
328
+ * center-anchored geometry (every shape). Text overrides — it draws from a
329
+ * left/center/right baseline origin; Path from author-positioned bounds.
286
330
  */
287
- flowOffset(measurer) {
288
- const size = this.intrinsicSize(measurer) ?? {
331
+ drawOffset(measurer) {
332
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
333
+ const size = this.intrinsicSize(m) ?? {
289
334
  w: 0,
290
335
  h: 0
291
336
  };
@@ -294,6 +339,46 @@ var Node = class {
294
339
  y: -size.h / 2
295
340
  };
296
341
  }
342
+ /**
343
+ * Vector from the node ORIGIN (the point `position` places) to the box's
344
+ * top-left, so Layout can place any node. With an anchor this is exactly
345
+ * (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
346
+ */
347
+ flowOffset(measurer) {
348
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
349
+ const d = this.drawOffset(m);
350
+ const [sx, sy] = this.anchorShift(m);
351
+ return {
352
+ x: d.x + sx,
353
+ y: d.y + sy
354
+ };
355
+ }
356
+ /**
357
+ * Translation composed after TRS in localMatrix: moves the drawn box so the
358
+ * anchor point lands on the origin. shift = −(drawOffset + anchor·size).
359
+ * No anchor set → zero shift, the legacy origin (shape center / Text
360
+ * baseline / Path author coords) — every pre-anchor scene is byte-stable.
361
+ * An EXPLICIT anchor pins position to that fraction of the box, even
362
+ * 'center' (which differs from the legacy origin only for Text and Path).
363
+ * Nodes without an intrinsic box (Group) warn once and ignore it.
364
+ */
365
+ anchorShift(measurer) {
366
+ if (!this.hasAnchor) return [0, 0];
367
+ const [ax, ay] = this.anchor;
368
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
369
+ const size = this.intrinsicSize(m);
370
+ if (!size) {
371
+ if (!this.#warnedAnchor) {
372
+ this.#warnedAnchor = true;
373
+ emitDevWarning(`anchor set on a node without an intrinsic box${this.id ? ` ('${this.id}')` : ""} — ignored (give it a sized node, or position children explicitly)`);
374
+ }
375
+ return [0, 0];
376
+ }
377
+ const d = this.drawOffset(m);
378
+ const sx = -(d.x + ax * size.w);
379
+ const sy = -(d.y + ay * size.h);
380
+ return [sx === 0 ? 0 : sx, sy === 0 ? 0 : sy];
381
+ }
297
382
  /** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
298
383
  requiresGroup() {
299
384
  return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
@@ -330,6 +415,101 @@ var Node = class {
330
415
  * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
331
416
  * Path/Image/Video/Layout arrive with their milestones.
332
417
  */
418
+ /** Rounded-rect path segments — Rect's outline, shared with Highlight. */
419
+ function roundedRectSegs(x, y, w, h, r) {
420
+ if (r <= 0) return [
421
+ [
422
+ "M",
423
+ x,
424
+ y
425
+ ],
426
+ [
427
+ "L",
428
+ x + w,
429
+ y
430
+ ],
431
+ [
432
+ "L",
433
+ x + w,
434
+ y + h
435
+ ],
436
+ [
437
+ "L",
438
+ x,
439
+ y + h
440
+ ],
441
+ ["Z"]
442
+ ];
443
+ const HALF = Math.PI / 2;
444
+ return [
445
+ [
446
+ "M",
447
+ x + r,
448
+ y
449
+ ],
450
+ [
451
+ "L",
452
+ x + w - r,
453
+ y
454
+ ],
455
+ [
456
+ "E",
457
+ x + w - r,
458
+ y + r,
459
+ r,
460
+ r,
461
+ 0,
462
+ -HALF,
463
+ 0
464
+ ],
465
+ [
466
+ "L",
467
+ x + w,
468
+ y + h - r
469
+ ],
470
+ [
471
+ "E",
472
+ x + w - r,
473
+ y + h - r,
474
+ r,
475
+ r,
476
+ 0,
477
+ 0,
478
+ HALF
479
+ ],
480
+ [
481
+ "L",
482
+ x + r,
483
+ y + h
484
+ ],
485
+ [
486
+ "E",
487
+ x + r,
488
+ y + h - r,
489
+ r,
490
+ r,
491
+ 0,
492
+ HALF,
493
+ Math.PI
494
+ ],
495
+ [
496
+ "L",
497
+ x,
498
+ y + r
499
+ ],
500
+ [
501
+ "E",
502
+ x + r,
503
+ y + r,
504
+ r,
505
+ r,
506
+ 0,
507
+ Math.PI,
508
+ Math.PI + HALF
509
+ ],
510
+ ["Z"]
511
+ ];
512
+ }
333
513
  var Group = class extends Node {
334
514
  children;
335
515
  constructor(props = {}) {
@@ -419,101 +599,8 @@ var Rect = class extends Shape {
419
599
  pathSegs() {
420
600
  const w = this.width();
421
601
  const h = this.height();
422
- const x = -w / 2;
423
- const y = -h / 2;
424
602
  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
- ];
603
+ return roundedRectSegs(-w / 2, -h / 2, w, h, r);
517
604
  }
518
605
  };
519
606
  var Circle = class extends Shape {
@@ -598,7 +685,7 @@ var Path = class extends Shape {
598
685
  };
599
686
  }
600
687
  /** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
601
- flowOffset() {
688
+ drawOffset() {
602
689
  const b = this.bounds();
603
690
  return {
604
691
  x: b.minX,
@@ -781,20 +868,112 @@ var Text = class extends Node {
781
868
  };
782
869
  }
783
870
  /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
784
- flowOffset(measurer) {
785
- const size = this.intrinsicSize(measurer);
871
+ drawOffset(measurer) {
872
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
873
+ const size = this.intrinsicSize(m);
786
874
  const font = {
787
875
  family: this.fontFamily,
788
876
  size: this.fontSize(),
789
877
  weight: this.fontWeight
790
878
  };
791
- const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, measurer)[0] ?? "";
792
- const ascent = measurer.measureText(firstLine, font).ascent;
879
+ const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m)[0] ?? "";
880
+ const ascent = m.measureText(firstLine, font).ascent;
793
881
  return {
794
882
  x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
795
883
  y: -ascent
796
884
  };
797
885
  }
886
+ /**
887
+ * The wrapped box {w, h}, measured with the scene's active measurer — the
888
+ * same numbers Layout flows with, public so bindings never hand-calculate
889
+ * text dimensions (e.g. underline width = () => title.measuredSize().w).
890
+ */
891
+ measuredSize(measurer) {
892
+ return this.intrinsicSize(measurer ?? this.measurerSource?.() ?? estimatingMeasurer);
893
+ }
894
+ /**
895
+ * Per-line ink boxes in this node's DRAW space (origin = first baseline at
896
+ * the align edge), from the same breakLines pass that draws. Pull-based:
897
+ * re-measures when text/font/width animate. Blank lines (from '\n\n')
898
+ * produce no box. The substrate for highlights, underlines, per-line
899
+ * reveals, selections.
900
+ */
901
+ lineBoxes(measurer) {
902
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
903
+ const text = this.text();
904
+ if (!text) return [];
905
+ const font = {
906
+ family: this.fontFamily,
907
+ size: this.fontSize(),
908
+ weight: this.fontWeight
909
+ };
910
+ const maxWidth = this.width();
911
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
912
+ const step = quantize(font.size * this.lineHeight);
913
+ const boxes = [];
914
+ for (let i = 0; i < lines.length; i++) {
915
+ const line = lines[i];
916
+ if (!line) continue;
917
+ const met = m.measureText(line, font);
918
+ const w = quantize(met.width);
919
+ const x = this.align === "left" ? 0 : this.align === "center" ? -w / 2 : -w;
920
+ boxes.push({
921
+ text: line,
922
+ x,
923
+ y: i * step - met.ascent,
924
+ w,
925
+ h: met.ascent + met.descent
926
+ });
927
+ }
928
+ return boxes;
929
+ }
930
+ /**
931
+ * Per-word ink boxes within each laid-out line — the SAME segmentation the
932
+ * breaker flows (Intl.Segmenter boundaries, punctuation glued), positioned
933
+ * by cumulative prefix advances so cross-word kerning is exact and word
934
+ * widths sum to the line's width. Whitespace contributes advance but no
935
+ * box. Pair index-wise with a narration manifest's word timestamps for
936
+ * karaoke; draw your own rects for sub-line multi-color token work.
937
+ */
938
+ wordBoxes(measurer) {
939
+ const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
940
+ const text = this.text();
941
+ if (!text) return [];
942
+ const font = {
943
+ family: this.fontFamily,
944
+ size: this.fontSize(),
945
+ weight: this.fontWeight
946
+ };
947
+ const maxWidth = this.width();
948
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
949
+ const step = quantize(font.size * this.lineHeight);
950
+ const boxes = [];
951
+ for (let i = 0; i < lines.length; i++) {
952
+ const line = lines[i];
953
+ if (!line) continue;
954
+ const met = m.measureText(line, font);
955
+ const lineW = quantize(met.width);
956
+ const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
957
+ const y = i * step - met.ascent;
958
+ const h = met.ascent + met.descent;
959
+ let prefix = "";
960
+ for (const seg of segmentWords(line)) {
961
+ const before = prefix === "" ? 0 : m.measureText(prefix, font).width;
962
+ prefix += seg;
963
+ if (seg.trim() === "") continue;
964
+ const after = m.measureText(prefix, font).width;
965
+ boxes.push({
966
+ text: seg,
967
+ line: i,
968
+ x: lineX + before,
969
+ y,
970
+ w: after - before,
971
+ h
972
+ });
973
+ }
974
+ }
975
+ return boxes;
976
+ }
798
977
  draw(out, ctx) {
799
978
  const text = this.text();
800
979
  if (!text) return;
@@ -843,4 +1022,4 @@ function requireLayoutEngine() {
843
1022
  return engine;
844
1023
  }
845
1024
  //#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 };
1025
+ export { validateFilters as C, invert as D, fromTRS as E, matEquals as O, glow as S, applyToPoint as T, quantize as _, Circle as a, createDisplayListBuilder as b, Path as c, Video as d, roundedRectSegs as f, estimatingMeasurer as g, breakLines as h, setLayoutEngine as i, multiply as k, 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, segmentWords as v, IDENTITY as w, filtersToCanvasFilter as x, FilterValidationError 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.2",
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.2"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",