@glissade/scene 0.19.1 → 0.20.0-pre.3

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.
@@ -0,0 +1,60 @@
1
+ import { B as NodeProps, V as PropInit, c as Path, z as Node } from "./nodes.js";
2
+ import { BindableSignal, PathValue, Vec2 } from "@glissade/core";
3
+
4
+ //#region src/motionPath.d.ts
5
+
6
+ /** An arc-length-parameterized sampler over a path. */
7
+ interface PathSampler {
8
+ /** total arc length */
9
+ readonly length: number;
10
+ /** point at arc-length s (clamped to [0, length]) */
11
+ at(s: number): Vec2;
12
+ /** unit tangent at arc-length s (forward direction of travel) */
13
+ tangentAt(s: number): Vec2;
14
+ /** point at normalized progress u in [0, 1] */
15
+ atProgress(u: number): Vec2;
16
+ /** unit tangent at normalized progress u in [0, 1] */
17
+ tangentAtProgress(u: number): Vec2;
18
+ }
19
+ /**
20
+ * Build a reusable arc-length sampler. Densely samples each cubic into a
21
+ * cumulative-length polyline (samplesPerSegment, default 32) so `at`/`tangent`
22
+ * are simple span lerps — smooth enough for motion, no per-call bezier solve.
23
+ */
24
+ declare function motionPath(path: PathValue, opts?: {
25
+ samplesPerSegment?: number;
26
+ }): PathSampler;
27
+ /** Total arc length of a path. */
28
+ declare function pathLength(path: PathValue): number;
29
+ /** Point at arc-length s along a path (clamped to [0, length]). */
30
+ declare function pointAtLength(path: PathValue, s: number): Vec2;
31
+ interface FollowPathProps extends NodeProps {
32
+ /** the node to move along the path; its position (and rotation, if orient) is owned by this */
33
+ target: Node;
34
+ /** a static PathValue, or a Path node followed LIVE (re-sampled as its `data` morphs) */
35
+ path: PathValue | Path;
36
+ /** 0→1 position along the path's arc length; default 1 (the end). Track `<id>/progress`. */
37
+ progress?: PropInit<number>;
38
+ /** rotate the target to the path tangent — a cursor that points where it heads; default false */
39
+ orient?: boolean;
40
+ /** degrees added to the orient angle (e.g. if the sprite points up at rest) */
41
+ orientOffset?: number;
42
+ samplesPerSegment?: number;
43
+ }
44
+ /**
45
+ * A companion node that drives `target` along `path` as `progress` animates.
46
+ * Owns the target's `position` (and `rotation` when `orient`) via pull-based
47
+ * binding, so there's no eval-order side effect. Add it to the scene (its
48
+ * `progress` is the animatable target); it draws nothing itself.
49
+ */
50
+ declare class FollowPath extends Node {
51
+ readonly target: Node;
52
+ readonly progress: BindableSignal<number>;
53
+ constructor(props: FollowPathProps);
54
+ protected draw(): void;
55
+ }
56
+ /** `children: [route, cursor, followPath(cursor, route, { orient: true })]` — cursor traces the route.
57
+ * Pass the Path *node* to follow it as it morphs; pass a PathValue for a fixed route. */
58
+ declare function followPath(target: Node, path: PathValue | Path, props?: Omit<FollowPathProps, 'target' | 'path'>): FollowPath;
59
+ //#endregion
60
+ export { FollowPath, type FollowPathProps, type PathSampler, followPath, motionPath, pathLength, pointAtLength };
package/dist/motion.js ADDED
@@ -0,0 +1,171 @@
1
+ import { a as Path, p as Node } from "./nodes.js";
2
+ import { signal } from "@glissade/core";
3
+ //#region src/motionPath.ts
4
+ /**
5
+ * Motion along a path: sample a point (and tangent) at an arc-length position on
6
+ * a PathValue, and drive a node along it over time. The Path node draws/morphs
7
+ * geometry; this is the companion that makes another node — a cursor, a dot, an
8
+ * arrow — *travel* that geometry.
9
+ *
10
+ * Sampling is arc-length parameterized (constant speed), so progress 0→1 moves
11
+ * evenly instead of bunching at the control points. Pure and deterministic: the
12
+ * table is built once from a static PathValue and `atProgress` is a pure
13
+ * function of progress, so evaluate() stays pure and goldens are byte-stable.
14
+ */
15
+ const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
16
+ function cubicPoint(p0, p1, p2, p3, t) {
17
+ const mt = 1 - t;
18
+ const a = mt * mt * mt;
19
+ const b = 3 * mt * mt * t;
20
+ const c = 3 * mt * t * t;
21
+ const d = t * t * t;
22
+ return [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]];
23
+ }
24
+ /** PathValue contours → cubic segments, same v/in/out math as Path.pathSegs. */
25
+ function toCubics(path) {
26
+ const out = [];
27
+ for (const ct of path) {
28
+ const n = ct.v.length;
29
+ for (let i = 0; i < n - 1; i++) out.push([
30
+ ct.v[i],
31
+ [ct.v[i][0] + ct.out[i][0], ct.v[i][1] + ct.out[i][1]],
32
+ [ct.v[i + 1][0] + ct.in[i + 1][0], ct.v[i + 1][1] + ct.in[i + 1][1]],
33
+ ct.v[i + 1]
34
+ ]);
35
+ if (ct.closed && n > 1) out.push([
36
+ ct.v[n - 1],
37
+ [ct.v[n - 1][0] + ct.out[n - 1][0], ct.v[n - 1][1] + ct.out[n - 1][1]],
38
+ [ct.v[0][0] + ct.in[0][0], ct.v[0][1] + ct.in[0][1]],
39
+ ct.v[0]
40
+ ]);
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * Build a reusable arc-length sampler. Densely samples each cubic into a
46
+ * cumulative-length polyline (samplesPerSegment, default 32) so `at`/`tangent`
47
+ * are simple span lerps — smooth enough for motion, no per-call bezier solve.
48
+ */
49
+ function motionPath(path, opts = {}) {
50
+ const steps = Math.max(1, Math.floor(opts.samplesPerSegment ?? 32));
51
+ const cubics = toCubics(path);
52
+ const pts = [];
53
+ const cum = [];
54
+ if (cubics.length > 0) {
55
+ let prev = cubicPoint(...cubics[0], 0);
56
+ pts.push(prev);
57
+ cum.push(0);
58
+ let acc = 0;
59
+ for (const cub of cubics) for (let k = 1; k <= steps; k++) {
60
+ const p = cubicPoint(...cub, k / steps);
61
+ acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
62
+ pts.push(p);
63
+ cum.push(acc);
64
+ prev = p;
65
+ }
66
+ } else {
67
+ const first = path[0]?.v[0];
68
+ pts.push(first ? [first[0], first[1]] : [0, 0]);
69
+ cum.push(0);
70
+ }
71
+ const total = cum[cum.length - 1];
72
+ const locate = (s) => {
73
+ if (total <= 0 || s <= 0) return {
74
+ i: 0,
75
+ f: 0
76
+ };
77
+ if (s >= total) return {
78
+ i: pts.length - 2,
79
+ f: 1
80
+ };
81
+ let i = 0;
82
+ while (i < cum.length - 1 && cum[i + 1] < s) i++;
83
+ const span = cum[i + 1] - cum[i];
84
+ return {
85
+ i,
86
+ f: span > 0 ? (s - cum[i]) / span : 0
87
+ };
88
+ };
89
+ const at = (s) => {
90
+ if (pts.length === 1) return [pts[0][0], pts[0][1]];
91
+ const { i, f } = locate(s);
92
+ const a = pts[i];
93
+ const b = pts[i + 1];
94
+ return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
95
+ };
96
+ const tangentAt = (s) => {
97
+ if (pts.length === 1) return [1, 0];
98
+ const { i } = locate(s);
99
+ const a = pts[i];
100
+ const b = pts[i + 1];
101
+ const dx = b[0] - a[0];
102
+ const dy = b[1] - a[1];
103
+ const len = Math.hypot(dx, dy);
104
+ return len > 0 ? [dx / len, dy / len] : [1, 0];
105
+ };
106
+ return {
107
+ length: total,
108
+ at,
109
+ tangentAt,
110
+ atProgress: (u) => at(clamp01(u) * total),
111
+ tangentAtProgress: (u) => tangentAt(clamp01(u) * total)
112
+ };
113
+ }
114
+ /** Total arc length of a path. */
115
+ function pathLength(path) {
116
+ return motionPath(path).length;
117
+ }
118
+ /** Point at arc-length s along a path (clamped to [0, length]). */
119
+ function pointAtLength(path, s) {
120
+ return motionPath(path).at(s);
121
+ }
122
+ /**
123
+ * A companion node that drives `target` along `path` as `progress` animates.
124
+ * Owns the target's `position` (and `rotation` when `orient`) via pull-based
125
+ * binding, so there's no eval-order side effect. Add it to the scene (its
126
+ * `progress` is the animatable target); it draws nothing itself.
127
+ */
128
+ var FollowPath = class extends Node {
129
+ target;
130
+ progress;
131
+ constructor(props) {
132
+ super(props);
133
+ this.target = props.target;
134
+ this.progress = signal(1);
135
+ if (typeof props.progress === "function") this.progress.bindSource(props.progress);
136
+ else if (props.progress !== void 0) this.progress.set(props.progress);
137
+ this.registerTarget("progress", this.progress, "number");
138
+ const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
139
+ const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
140
+ let cachedPath = getPath();
141
+ let cachedSampler = motionPath(cachedPath, sOpts);
142
+ const sampler = () => {
143
+ const pv = getPath();
144
+ if (pv !== cachedPath) {
145
+ cachedPath = pv;
146
+ cachedSampler = motionPath(pv, sOpts);
147
+ }
148
+ return cachedSampler;
149
+ };
150
+ props.target.position.bindSource(() => sampler().atProgress(this.progress()));
151
+ if (props.orient) {
152
+ const offset = props.orientOffset ?? 0;
153
+ props.target.rotation.bindSource(() => {
154
+ const t = sampler().tangentAtProgress(this.progress());
155
+ return Math.atan2(t[1], t[0]) * 180 / Math.PI + offset;
156
+ });
157
+ }
158
+ }
159
+ draw() {}
160
+ };
161
+ /** `children: [route, cursor, followPath(cursor, route, { orient: true })]` — cursor traces the route.
162
+ * Pass the Path *node* to follow it as it morphs; pass a PathValue for a fixed route. */
163
+ function followPath(target, path, props = {}) {
164
+ return new FollowPath({
165
+ ...props,
166
+ target,
167
+ path
168
+ });
169
+ }
170
+ //#endregion
171
+ export { FollowPath, followPath, motionPath, pathLength, pointAtLength };
package/dist/nodes.d.ts CHANGED
@@ -201,6 +201,14 @@ declare abstract class Node {
201
201
  unbindSource(): void;
202
202
  }, expects?: ValueTypeId | readonly ValueTypeId[]): void;
203
203
  resolveTarget(path: string): BindablePropTarget | undefined;
204
+ /**
205
+ * This node's DESCRIBE type name (e.g. `Image`, `Rect`) — the key the
206
+ * construction-prop schema and `describe()` manifest use. Defaults to the
207
+ * class name; `ImageNode` overrides it (its class name is `ImageNode`, but
208
+ * the public taxonomy name is `Image`). Used by the bind guard to turn a
209
+ * generic unbound-target error into a friendlier construction-prop message.
210
+ */
211
+ get describeType(): string;
204
212
  /**
205
213
  * Enumerate this node's registered track-target paths and the value type each
206
214
  * accepts — the introspection seam `describe()` reads to build the API
@@ -493,6 +501,8 @@ interface ImageProps extends NodeProps {
493
501
  declare class ImageNode extends Node {
494
502
  /** Marks this node as referencing a kind 'image' timeline asset (§2.3). */
495
503
  static readonly assetKind: "image";
504
+ /** Public taxonomy name is `Image` (the class is `ImageNode`). */
505
+ get describeType(): string;
496
506
  readonly assetId: string;
497
507
  readonly width: BindableSignal<number>;
498
508
  readonly height: BindableSignal<number>;
@@ -586,14 +596,18 @@ interface TextProps extends NodeProps {
586
596
  fontStyle?: 'normal' | 'italic';
587
597
  /**
588
598
  * Variable-font axis settings in CSS `font-variation-settings` form
589
- * (e.g. `'"wght" 700, "opsz" 14'`). **Not yet applied.** Accepted so the
590
- * input is discoverable + typed, but as of 0.19 neither rasterizer wires
591
- * variation axes through, so setting it emits a dev-warning and is otherwise
592
- * a no-op (the value does NOT reach `ctx.font`). Animatable axes (a `wght`
593
- * track, `opsz` driven by size, …) are a 0.20 feature. Until then, drive a
594
- * weight axis via the discrete `fontWeight` named instances your font ships.
595
- * Tracked so a silent drop becomes a loud one see the splitText measurer
596
- * precedent (0.19).
599
+ * (e.g. `'"wght" 700, "opsz" 14'`). 0.20 STATIC passthrough: threaded into
600
+ * `FontSpec` and applied by the rasterizer where the context supports it
601
+ * the Skia/export path (`@napi-rs/canvas` exposes a settable
602
+ * `ctx.fontVariationSettings`) renders the axes; the browser DOM 2D context
603
+ * has no such property, so axes are best-effort there (a guarded no-op, never
604
+ * a throw). OMITTED when unset, so default Text emits a byte-identical
605
+ * FontSpec. Axes are STATIC only in 0.20**animatable** axes (a `wght`
606
+ * track, `opsz` driven by size, …) are deferred to 1.0 (an opaque CSS string
607
+ * isn't lerp-able); a track targeting `<id>/fontVariationSettings` hard-throws
608
+ * `UnboundTargetError` today (no property signal resolves to it). For a
609
+ * dynamic weight, use the discrete `fontWeight` named instances your font
610
+ * ships.
597
611
  */
598
612
  fontVariationSettings?: string;
599
613
  /** Horizontal alignment about the node position; default 'left'. */
@@ -629,6 +643,8 @@ declare class Text extends Node {
629
643
  readonly fontFamily: string;
630
644
  readonly fontWeight: number;
631
645
  readonly fontStyle: 'normal' | 'italic';
646
+ /** Static variable-font axes (CSS `font-variation-settings`); undefined = none. */
647
+ readonly fontVariationSettings: string | undefined;
632
648
  readonly align: 'left' | 'center' | 'right';
633
649
  readonly width: BindableSignal<number>;
634
650
  readonly lineHeight: number;
@@ -640,6 +656,14 @@ declare class Text extends Node {
640
656
  */
641
657
  readonly revealFraction: BindableSignal<number>;
642
658
  constructor(props?: TextProps);
659
+ /**
660
+ * The per-draw {@link FontSpec} — the single construction point every measure
661
+ * / layout / draw path routes through, so the spec is identical across them.
662
+ * `style: 'normal'` and an unset `fontVariationSettings` are OMITTED so a
663
+ * default-style, no-axes Text emits a byte-identical FontSpec (§3.6; the
664
+ * golden corpus depends on it).
665
+ */
666
+ private fontSpec;
643
667
  /**
644
668
  * The grapheme COUNT to reveal this frame — the single source the draw mask,
645
669
  * {@link revealHead}, and the masked emit path all read. When `revealFraction`
package/dist/nodes.js CHANGED
@@ -61,14 +61,23 @@ function matEquals(a, b) {
61
61
  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];
62
62
  }
63
63
  //#endregion
64
- //#region src/displayDiff.ts
64
+ //#region src/collapseReplacer.ts
65
65
  /**
66
- * The collapse-replacer shared by the cacheKey serializer
67
- * (`createDisplayListBuilder().cacheKey`), `cacheColdAudit.hashDisplayList`,
68
- * and `serializeDisplayList` here. CRITICAL: this is BYTE-PRESERVING the
69
- * cacheKey it backs stamps into pushGroup and keys the §3.5 raster cache, so
70
- * its output must not move. The three call sites previously DUPLICATED this
71
- * byte-for-byte; it lives here once.
66
+ * The byte-preserving collapse-replacer (DESIGN.md §3.3 / §3.5).
67
+ *
68
+ * Extracted to its OWN tiny module in the 0.20 budget review so the heavy
69
+ * DEV/CLI diagnostic surface (`diffDisplayLists`/`serializeDisplayList`/… in
70
+ * `displayDiff.ts`, now on the `@glissade/scene/diagnostics` subpath) can leave
71
+ * the base scene graph while THIS function — which IS on the render path — stays
72
+ * reachable from `displayList.ts` (the §3.5 cacheKey serializer) at a few bytes.
73
+ * Before the split, `displayList.ts` imported `collapseReplacer` from
74
+ * `displayDiff.ts`, dragging the entire diff/snapshot module into every embed.
75
+ *
76
+ * Shared by the cacheKey serializer (`createDisplayListBuilder().cacheKey`),
77
+ * `cacheColdAudit.hashDisplayList`, and `serializeDisplayList`. CRITICAL: this is
78
+ * BYTE-PRESERVING — the cacheKey it backs stamps into pushGroup and keys the
79
+ * §3.5 raster cache, so its output must not move. The three call sites previously
80
+ * DUPLICATED this byte-for-byte; it lives here once.
72
81
  *
73
82
  * - ArrayBuffer / typed-array views collapse to a `ab:<len>` / `view:<len>`
74
83
  * length marker (opaque binary never belongs in a structural key).
@@ -99,136 +108,6 @@ function collapseReplacer(_key, value) {
99
108
  }
100
109
  return value;
101
110
  }
102
- /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
103
- function commandView(cmd, resources) {
104
- const out = {};
105
- for (const [k, v] of Object.entries(cmd)) out[k] = v;
106
- if (cmd.op === "clip" || cmd.op === "fillPath" || cmd.op === "strokePath") out["path"] = resources[cmd.path];
107
- else if (cmd.op === "drawImage") out["image"] = resources[cmd.image];
108
- return out;
109
- }
110
- const stable = (v) => JSON.stringify(v, collapseReplacer);
111
- /** Field-level diff of two same-op command views (shallow over top-level props). */
112
- function diffFields(a, b) {
113
- const changes = [];
114
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
115
- for (const k of keys) {
116
- if (k === "op") continue;
117
- if (stable(a[k]) !== stable(b[k])) changes.push({
118
- path: k,
119
- from: a[k],
120
- to: b[k]
121
- });
122
- }
123
- return changes;
124
- }
125
- /**
126
- * Index-aligned positional diff of two DisplayLists. Command i in `a` is
127
- * compared to command i in `b`; trailing commands become `add`/`remove`. A
128
- * single insert/remove cascades — this is the documented v1 cliff.
129
- */
130
- function diffDisplayLists(a, b) {
131
- const deltas = [];
132
- const n = Math.max(a.commands.length, b.commands.length);
133
- for (let i = 0; i < n; i++) {
134
- const ca = a.commands[i];
135
- const cb = b.commands[i];
136
- if (ca !== void 0 && cb !== void 0) {
137
- const va = commandView(ca, a.resources);
138
- const vb = commandView(cb, b.resources);
139
- if (stable(va) === stable(vb)) continue;
140
- if (ca.op !== cb.op) deltas.push({
141
- index: i,
142
- kind: "change",
143
- opA: ca.op,
144
- opB: cb.op,
145
- fields: [{
146
- path: "op",
147
- from: ca.op,
148
- to: cb.op
149
- }]
150
- });
151
- else deltas.push({
152
- index: i,
153
- kind: "change",
154
- opA: ca.op,
155
- opB: cb.op,
156
- fields: diffFields(va, vb)
157
- });
158
- } else if (cb !== void 0) deltas.push({
159
- index: i,
160
- kind: "add",
161
- opB: cb.op,
162
- fields: []
163
- });
164
- else if (ca !== void 0) deltas.push({
165
- index: i,
166
- kind: "remove",
167
- opA: ca.op,
168
- fields: []
169
- });
170
- }
171
- const sizeDiffers = a.size.w !== b.size.w || a.size.h !== b.size.h;
172
- return {
173
- equal: deltas.length === 0 && !sizeDiffers,
174
- deltas,
175
- ...sizeDiffers ? { size: {
176
- from: a.size,
177
- to: b.size
178
- } } : {}
179
- };
180
- }
181
- /** Human-readable, multi-line rendering of a DisplayDiff (CLI command-tree). */
182
- function formatDisplayDiff(diff) {
183
- if (diff.equal) return "DisplayLists are identical.";
184
- const lines = [];
185
- if (diff.size) lines.push(`size ${diff.size.from.w}x${diff.size.from.h} -> ${diff.size.to.w}x${diff.size.to.h}`);
186
- for (const d of diff.deltas) if (d.kind === "add") lines.push(`+ [${d.index}] add ${d.opB ?? "?"}`);
187
- else if (d.kind === "remove") lines.push(`- [${d.index}] remove ${d.opA ?? "?"}`);
188
- else {
189
- const op = d.opA === d.opB ? d.opA : `${d.opA ?? "?"} -> ${d.opB ?? "?"}`;
190
- lines.push(`~ [${d.index}] ${op}`);
191
- for (const f of d.fields) lines.push(` ${f.path}: ${stable(f.from)} -> ${stable(f.to)}`);
192
- }
193
- return lines.join("\n");
194
- }
195
- /**
196
- * The `.dl.json` snapshot interchange schema (DESIGN §7.4). Users commit
197
- * `.dl.json` baselines, so this carries the SAME break-policy obligation as
198
- * `Timeline.version` and `SidecarDoc.sidecarVersion`: bump on a breaking shape
199
- * change. Independent of the API version.
200
- */
201
- const DL_SNAPSHOT_VERSION = 1;
202
- /**
203
- * Serialize a DisplayList to a stable `.dl.json` document string (reusing the
204
- * byte-preserving collapse-replacer). Round-trips through `parseDisplaySnapshot`.
205
- */
206
- function serializeDisplayList(dl) {
207
- const snapshot = {
208
- dlSnapshotVersion: 1,
209
- size: dl.size,
210
- commands: dl.commands,
211
- resources: dl.resources
212
- };
213
- return JSON.stringify(snapshot, collapseReplacer, 2);
214
- }
215
- var DlSnapshotError = class extends Error {
216
- constructor(message) {
217
- super(message);
218
- this.name = "DlSnapshotError";
219
- }
220
- };
221
- /** Parse a `.dl.json` snapshot back into a DisplayList (validates the version). */
222
- function parseDisplaySnapshot(json) {
223
- const doc = JSON.parse(json);
224
- if (doc.dlSnapshotVersion !== 1) throw new DlSnapshotError(`unsupported dlSnapshotVersion ${String(doc.dlSnapshotVersion)}; this build reads 1`);
225
- if (!doc.size || !Array.isArray(doc.commands) || !Array.isArray(doc.resources)) throw new DlSnapshotError("malformed .dl.json snapshot (need size, commands[], resources[])");
226
- return {
227
- size: doc.size,
228
- commands: doc.commands,
229
- resources: doc.resources
230
- };
231
- }
232
111
  //#endregion
233
112
  //#region src/displayList.ts
234
113
  var FilterValidationError = class extends Error {
@@ -914,6 +793,16 @@ var Node = class {
914
793
  return this.targets.get(path);
915
794
  }
916
795
  /**
796
+ * This node's DESCRIBE type name (e.g. `Image`, `Rect`) — the key the
797
+ * construction-prop schema and `describe()` manifest use. Defaults to the
798
+ * class name; `ImageNode` overrides it (its class name is `ImageNode`, but
799
+ * the public taxonomy name is `Image`). Used by the bind guard to turn a
800
+ * generic unbound-target error into a friendlier construction-prop message.
801
+ */
802
+ get describeType() {
803
+ return this.constructor.name;
804
+ }
805
+ /**
917
806
  * Enumerate this node's registered track-target paths and the value type each
918
807
  * accepts — the introspection seam `describe()` reads to build the API
919
808
  * manifest from the REAL `registerTarget` calls (so it can't drift). Returns
@@ -1006,6 +895,7 @@ var Node = class {
1006
895
  const shader = this.groupShader();
1007
896
  const group = this.requiresGroup() || shader !== void 0 || this.cache;
1008
897
  const wantsKey = this.cache && out.mark !== void 0;
898
+ out.enterNode?.(this.id);
1009
899
  out.push({ op: "save" });
1010
900
  if (!isIdentity) out.push({
1011
901
  op: "transform",
@@ -1032,6 +922,7 @@ var Node = class {
1032
922
  out.push({ op: "popGroup" });
1033
923
  }
1034
924
  out.push({ op: "restore" });
925
+ out.exitNode?.();
1035
926
  }
1036
927
  };
1037
928
  //#endregion
@@ -1620,6 +1511,10 @@ var Path = class extends Shape {
1620
1511
  var ImageNode = class extends Node {
1621
1512
  /** Marks this node as referencing a kind 'image' timeline asset (§2.3). */
1622
1513
  static assetKind = "image";
1514
+ /** Public taxonomy name is `Image` (the class is `ImageNode`). */
1515
+ get describeType() {
1516
+ return "Image";
1517
+ }
1623
1518
  assetId;
1624
1519
  width;
1625
1520
  height;
@@ -1725,6 +1620,8 @@ var Text = class extends Node {
1725
1620
  fontFamily;
1726
1621
  fontWeight;
1727
1622
  fontStyle;
1623
+ /** Static variable-font axes (CSS `font-variation-settings`); undefined = none. */
1624
+ fontVariationSettings;
1728
1625
  align;
1729
1626
  width;
1730
1627
  lineHeight;
@@ -1743,6 +1640,7 @@ var Text = class extends Node {
1743
1640
  this.fontFamily = props.fontFamily ?? "sans-serif";
1744
1641
  this.fontWeight = props.fontWeight ?? 400;
1745
1642
  this.fontStyle = props.fontStyle ?? "normal";
1643
+ this.fontVariationSettings = props.fontVariationSettings;
1746
1644
  this.align = props.align ?? "left";
1747
1645
  this.width = initProp(signal(0), props.width);
1748
1646
  this.lineHeight = props.lineHeight ?? 1.25;
@@ -1756,6 +1654,22 @@ var Text = class extends Node {
1756
1654
  this.registerTarget("revealFraction", this.revealFraction, "number");
1757
1655
  }
1758
1656
  /**
1657
+ * The per-draw {@link FontSpec} — the single construction point every measure
1658
+ * / layout / draw path routes through, so the spec is identical across them.
1659
+ * `style: 'normal'` and an unset `fontVariationSettings` are OMITTED so a
1660
+ * default-style, no-axes Text emits a byte-identical FontSpec (§3.6; the
1661
+ * golden corpus depends on it).
1662
+ */
1663
+ fontSpec() {
1664
+ return {
1665
+ family: this.fontFamily,
1666
+ size: this.fontSize(),
1667
+ weight: this.fontWeight,
1668
+ ...this.fontStyle === "italic" ? { style: "italic" } : {},
1669
+ ...this.fontVariationSettings !== void 0 ? { fontVariationSettings: this.fontVariationSettings } : {}
1670
+ };
1671
+ }
1672
+ /**
1759
1673
  * The grapheme COUNT to reveal this frame — the single source the draw mask,
1760
1674
  * {@link revealHead}, and the masked emit path all read. When `revealFraction`
1761
1675
  * is set (not NaN) it wins: `round(clamp(fraction, 0, 1) * graphemeCount)`,
@@ -1775,12 +1689,7 @@ var Text = class extends Node {
1775
1689
  w: 0,
1776
1690
  h: 0
1777
1691
  };
1778
- const font = {
1779
- family: this.fontFamily,
1780
- size: this.fontSize(),
1781
- weight: this.fontWeight,
1782
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1783
- };
1692
+ const font = this.fontSpec();
1784
1693
  const maxWidth = this.width();
1785
1694
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, measurer);
1786
1695
  const widest = Math.max(...lines.map((l) => quantize(measurer.measureText(l, font).width)), 0);
@@ -1793,12 +1702,7 @@ var Text = class extends Node {
1793
1702
  drawOffset(measurer) {
1794
1703
  const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1795
1704
  const size = this.intrinsicSize(m);
1796
- const font = {
1797
- family: this.fontFamily,
1798
- size: this.fontSize(),
1799
- weight: this.fontWeight,
1800
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1801
- };
1705
+ const font = this.fontSpec();
1802
1706
  const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m)[0] ?? "";
1803
1707
  const ascent = m.measureText(firstLine, font).ascent;
1804
1708
  return {
@@ -1826,12 +1730,7 @@ var Text = class extends Node {
1826
1730
  if (measurer === void 0) warnIfEstimating(m, "Text.lineBoxes");
1827
1731
  const text = this.text();
1828
1732
  if (!text) return [];
1829
- const font = {
1830
- family: this.fontFamily,
1831
- size: this.fontSize(),
1832
- weight: this.fontWeight,
1833
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1834
- };
1733
+ const font = this.fontSpec();
1835
1734
  const maxWidth = this.width();
1836
1735
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1837
1736
  const step = quantize(font.size * this.lineHeight);
@@ -1865,12 +1764,7 @@ var Text = class extends Node {
1865
1764
  if (measurer === void 0) warnIfEstimating(m, "Text.wordBoxes");
1866
1765
  const text = this.text();
1867
1766
  if (!text) return [];
1868
- const font = {
1869
- family: this.fontFamily,
1870
- size: this.fontSize(),
1871
- weight: this.fontWeight,
1872
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1873
- };
1767
+ const font = this.fontSpec();
1874
1768
  const maxWidth = this.width();
1875
1769
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1876
1770
  const step = quantize(font.size * this.lineHeight);
@@ -1920,12 +1814,7 @@ var Text = class extends Node {
1920
1814
  if (measurer === void 0) warnIfEstimating(m, "Text.graphemeBoxes");
1921
1815
  const text = this.text();
1922
1816
  if (!text) return [];
1923
- const font = {
1924
- family: this.fontFamily,
1925
- size: this.fontSize(),
1926
- weight: this.fontWeight,
1927
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1928
- };
1817
+ const font = this.fontSpec();
1929
1818
  const maxWidth = this.width();
1930
1819
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1931
1820
  const step = quantize(font.size * this.lineHeight);
@@ -1972,12 +1861,7 @@ var Text = class extends Node {
1972
1861
  const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1973
1862
  const text = this.text();
1974
1863
  if (!text) return [];
1975
- const font = {
1976
- family: this.fontFamily,
1977
- size: this.fontSize(),
1978
- weight: this.fontWeight,
1979
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
1980
- };
1864
+ const font = this.fontSpec();
1981
1865
  const maxWidth = this.width();
1982
1866
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1983
1867
  const out = [];
@@ -1992,12 +1876,7 @@ var Text = class extends Node {
1992
1876
  */
1993
1877
  revealHead(measurer) {
1994
1878
  const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1995
- const font = {
1996
- family: this.fontFamily,
1997
- size: this.fontSize(),
1998
- weight: this.fontWeight,
1999
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
2000
- };
1879
+ const font = this.fontSpec();
2001
1880
  const maxWidth = this.width();
2002
1881
  const lines = breakLines(this.text(), font, maxWidth > 0 ? maxWidth : void 0, m);
2003
1882
  const step = quantize(font.size * this.lineHeight);
@@ -2041,12 +1920,7 @@ var Text = class extends Node {
2041
1920
  draw(out, ctx) {
2042
1921
  const text = this.text();
2043
1922
  if (!text) return;
2044
- const font = {
2045
- family: this.fontFamily,
2046
- size: this.fontSize(),
2047
- weight: this.fontWeight,
2048
- ...this.fontStyle === "italic" ? { style: "italic" } : {}
2049
- };
1923
+ const font = this.fontSpec();
2050
1924
  const maxWidth = this.width();
2051
1925
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
2052
1926
  const step = quantize(font.size * this.lineHeight);
@@ -2121,7 +1995,8 @@ function revealSchedule(text, reveal, measurer) {
2121
1995
  family: text.fontFamily,
2122
1996
  size: text.fontSize(),
2123
1997
  weight: text.fontWeight,
2124
- ...text.fontStyle === "italic" ? { style: "italic" } : {}
1998
+ ...text.fontStyle === "italic" ? { style: "italic" } : {},
1999
+ ...text.fontVariationSettings !== void 0 ? { fontVariationSettings: text.fontVariationSettings } : {}
2125
2000
  };
2126
2001
  const maxWidth = text.width();
2127
2002
  const lines = breakLines(src, font, maxWidth > 0 ? maxWidth : void 0, m);
@@ -2159,4 +2034,4 @@ function revealSchedule(text, reveal, measurer) {
2159
2034
  return marks;
2160
2035
  }
2161
2036
  //#endregion
2162
- export { multiply as $, hashStr as A, validateFilters as B, segmentWords as C, arcLength as D, SketchValidationError as E, validateSketch as F, formatDisplayDiff as G, DlSnapshotError as H, FilterValidationError as I, IDENTITY as J, parseDisplaySnapshot as K, createDisplayListBuilder as L, roughen as M, sketchStrokes as N, flatten as O, validateHachure as P, matEquals as Q, filtersToCanvasFilter as R, segmentGraphemes as S, warnIfEstimating as T, collapseReplacer as U, DL_SNAPSHOT_VERSION as V, diffDisplayLists as W, fromTRS as X, applyToPoint as Y, invert as Z, breakLines as _, Path as a, isEstimatingMeasurer as b, Video as c, revealSchedule as d, roundedRectSegs as f, __resetEstimateWarnings as g, MEASURE_QUANTUM_PX as h, ImageNode as i, resolveSketch as j, hachureLines as k, coercePathData as l, resolveAnchor as m, Custom as n, Rect as o, Node as p, serializeDisplayList as q, Group as r, Text as s, Circle as t, pathFromSegs as u, estimatingMeasurer as v, setDefaultMeasurer as w, quantize as x, fallbackMeasurer as y, glow as z };
2037
+ export { hashStr as A, validateFilters as B, segmentWords as C, arcLength as D, SketchValidationError as E, validateSketch as F, invert as G, IDENTITY as H, FilterValidationError as I, matEquals as K, createDisplayListBuilder as L, roughen as M, sketchStrokes as N, flatten as O, validateHachure as P, filtersToCanvasFilter as R, segmentGraphemes as S, warnIfEstimating as T, applyToPoint as U, collapseReplacer as V, fromTRS as W, breakLines as _, Path as a, isEstimatingMeasurer as b, Video as c, revealSchedule as d, roundedRectSegs as f, __resetEstimateWarnings as g, MEASURE_QUANTUM_PX as h, ImageNode as i, resolveSketch as j, hachureLines as k, coercePathData as l, resolveAnchor as m, Custom as n, Rect as o, Node as p, multiply as q, Group as r, Text as s, Circle as t, pathFromSegs as u, estimatingMeasurer as v, setDefaultMeasurer as w, quantize as x, fallbackMeasurer as y, glow as z };