@glissade/scene 0.23.0 → 0.24.0-pre.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.
@@ -51,6 +51,13 @@ interface DescribedNode {
51
51
  props: {
52
52
  [prop: string]: DescribedProp;
53
53
  };
54
+ /**
55
+ * Runnable, drift-guarded code snippets for this node — present ONLY when
56
+ * `describe({ examples: true })` is called AND `@glissade/scene/examples` has
57
+ * been imported (it registers the corpus). Each string is an executable example
58
+ * the doctest harness runs, so it can't go stale (§0.24 onboarding).
59
+ */
60
+ examples?: readonly string[];
54
61
  /**
55
62
  * What this node's `position` points at WITHOUT an explicit `anchor` (its
56
63
  * legacy origin): 'center' for shapes, 'baseline-left' for Text, etc. Override
@@ -68,6 +75,8 @@ interface DescribedNode {
68
75
  interface DescribedBuilderMethod {
69
76
  name: string;
70
77
  signature: string;
78
+ /** Runnable example snippets — see {@link DescribedNode.examples}. */
79
+ examples?: readonly string[];
71
80
  }
72
81
  /**
73
82
  * One helper/factory in the manifest — the broader builder API beyond the node
@@ -87,6 +96,8 @@ interface DescribedHelper {
87
96
  import: string;
88
97
  /** A minimal signature/usage string showing the call shape. */
89
98
  usage: string;
99
+ /** Runnable example snippets — see {@link DescribedNode.examples}. */
100
+ examples?: readonly string[];
90
101
  }
91
102
  /** The full machine-readable manifest `describe()` returns. */
92
103
  interface ApiManifest {
@@ -117,6 +128,23 @@ interface ApiManifest {
117
128
  * targets, enumerate the ValueType + easing registries, and curate the builder /
118
129
  * subpath surface. JSON-serializable; safe to call any number of times.
119
130
  */
120
- declare function describe(): ApiManifest;
131
+ interface DescribeOptions {
132
+ /**
133
+ * Attach runnable example snippets (per node / builder method / helper) from
134
+ * the registered corpus (§0.24 onboarding). The corpus is registered by
135
+ * importing `@glissade/scene/examples`, kept OFF the base index so it costs
136
+ * nothing when unused. With no corpus registered, `examples: true` is a no-op
137
+ * (the manifest is byte-identical to `describe()`).
138
+ */
139
+ examples?: boolean;
140
+ }
141
+ /**
142
+ * Register the runnable-example corpus — called by `@glissade/scene/examples` on
143
+ * import. A registration hook (not a static import) so `describe()` stays lean.
144
+ */
145
+ declare function registerExamples(corpus: {
146
+ readonly [key: string]: readonly string[];
147
+ }): void;
148
+ declare function describe(opts?: DescribeOptions): ApiManifest;
121
149
  //#endregion
122
- export { ApiManifest, DescribedBuilderMethod, DescribedHelper, DescribedNode, DescribedProp, describe };
150
+ export { ApiManifest, DescribeOptions, DescribedBuilderMethod, DescribedHelper, DescribedNode, DescribedProp, describe, registerExamples };
package/dist/describe.js CHANGED
@@ -22,7 +22,7 @@ import { easings, listValueTypes } from "@glissade/core";
22
22
  * never pulled onto the base embed path — a scene that never calls `describe()`
23
23
  * pays zero bytes for it.
24
24
  */
25
- const RAW_VERSION = "0.23.0";
25
+ const RAW_VERSION = "0.24.0-pre.1";
26
26
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
27
27
  /** Arity of a value type's numeric repr: vec2/vec2-arc → 2, number → 1; others (color/paint/path/string/boolean) carry no scalar arity. */
28
28
  function arityOf(type) {
@@ -390,31 +390,60 @@ const SUBPATHS = {
390
390
  "@glissade/scene/path": "SVG geometry: pathFromSvg / parseSvgPathData — parse an SVG `d` string into a PathValue for Path.data."
391
391
  };
392
392
  /**
393
- * Build the machine-readable API manifest from the live registries (§4.4). Pure
394
- * introspection: instantiate each built-in node once to read its registered track
395
- * targets, enumerate the ValueType + easing registries, and curate the builder /
396
- * subpath surface. JSON-serializable; safe to call any number of times.
393
+ * The registered runnable-example corpus, keyed by describe-key (node type name /
394
+ * builder method name / helper name). Populated by `@glissade/scene/examples` at
395
+ * import time via {@link registerExamples} describe NEVER statically imports the
396
+ * corpus, so the base index and the IIFE never pay for it (the value-type-registry
397
+ * pattern). Empty until that subpath is loaded.
397
398
  */
398
- function describe() {
399
+ let exampleCorpus = {};
400
+ /**
401
+ * Register the runnable-example corpus — called by `@glissade/scene/examples` on
402
+ * import. A registration hook (not a static import) so `describe()` stays lean.
403
+ */
404
+ function registerExamples(corpus) {
405
+ exampleCorpus = corpus;
406
+ }
407
+ function describe(opts = {}) {
408
+ const withEx = opts.examples === true && Object.keys(exampleCorpus).length > 0;
409
+ const ex = (key) => {
410
+ const e = withEx ? exampleCorpus[key] : void 0;
411
+ return e && e.length > 0 ? { examples: e } : void 0;
412
+ };
399
413
  const nodes = {};
400
- for (const [name, factory] of Object.entries(NODE_FACTORIES)) nodes[name] = describeNode(factory(), name);
414
+ for (const [name, factory] of Object.entries(NODE_FACTORIES)) nodes[name] = {
415
+ ...describeNode(factory(), name),
416
+ ...ex(name)
417
+ };
401
418
  const layout = describeLayoutNode();
402
419
  for (const name of [
403
420
  "Layout",
404
421
  "Stack",
405
422
  "Row",
406
423
  "Column"
407
- ]) nodes[name] = layout;
424
+ ]) {
425
+ const e = ex(name);
426
+ nodes[name] = e ? {
427
+ ...layout,
428
+ ...e
429
+ } : layout;
430
+ }
408
431
  return {
409
432
  version: PACKAGE_VERSION,
410
433
  nodes,
411
434
  valueTypes: listValueTypes(),
412
435
  easings: Object.keys(easings),
413
- builder: { methods: BUILDER_METHODS },
414
- helpers: HELPERS,
436
+ builder: { methods: withEx ? BUILDER_METHODS.map((m) => ({
437
+ ...m,
438
+ ...ex(m.name)
439
+ })) : BUILDER_METHODS },
440
+ helpers: withEx ? HELPERS.map((h) => ({
441
+ ...h,
442
+ ...ex(h.name)
443
+ })) : HELPERS,
415
444
  createScene: "createScene({ size: { w, h }, children: Node[] }): Scene — media assets are declared on the Timeline document: timeline({ assets: { <id>: { kind: 'image'|'video', url } } }); an Image/Video node's `assetId` names an entry here.",
416
445
  subpaths: SUBPATHS
417
446
  };
418
447
  }
419
448
  //#endregion
420
- export { describe };
449
+ export { describe, registerExamples };
@@ -1,4 +1,4 @@
1
- import { H as createDisplayListBuilder, K as collapseReplacer, r as Group } from "./nodes.js";
1
+ import { U as createDisplayListBuilder, q as collapseReplacer, r as Group } from "./nodes.js";
2
2
  import { a as evaluate } from "./scene.js";
3
3
  //#region src/displayDiff.ts
4
4
  /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
@@ -0,0 +1,38 @@
1
+ //#region src/examples.d.ts
2
+ /**
3
+ * `@glissade/scene/examples` — the runnable example corpus (§0.24 onboarding,
4
+ * card `8jQ9rNqStGDL`). The single highest agent-onboarding cost is STALE
5
+ * examples: prose that drifts from the runtime, which a cold agent can't
6
+ * glance-test. The fix is to make canonical examples EXECUTABLE and surfaced from
7
+ * the same place the doctest harness runs them, so they can't go stale.
8
+ *
9
+ * Each {@link ApiExample} carries a copy-pasteable `code` string (what
10
+ * `describe({ examples: true })` surfaces) AND an executable `run` thunk against
11
+ * the REAL API (what `examples.test.ts` asserts never throws). The two sit
12
+ * adjacent and are kept in lockstep; the `run` guard is what fails CI the moment
13
+ * an example's API drifts (a renamed export, a changed shape).
14
+ *
15
+ * Tree-shaking: this module is OFF the base scene index (its own subpath entry),
16
+ * so a scene that never imports it pays zero bytes. Importing it REGISTERS the
17
+ * corpus with `describe()` via {@link registerExamples} (the value-type-registry
18
+ * pattern) — describe never imports examples, so the base embed + IIFE stay lean.
19
+ */
20
+ /** One runnable example, attached to its describe-key (node type / builder method
21
+ * / helper name). `code` is surfaced; `run` is the executed drift guard. */
22
+ interface ApiExample {
23
+ /** The describe-key this attaches to: a node type ('Rect'), builder method
24
+ * ('to'), or helper name ('splitText'). */
25
+ key: string;
26
+ /** The copy-pasteable snippet `describe({ examples: true })` surfaces. */
27
+ code: string;
28
+ /** Executes the SAME call against the real API — the doctest harness asserts it
29
+ * never throws, so the surfaced `code` can't reference a drifted API. */
30
+ run: () => void;
31
+ }
32
+ declare const EXAMPLES: readonly ApiExample[];
33
+ /** Group the corpus by describe-key → the surfaced code snippets. */
34
+ declare function examplesByKey(): {
35
+ readonly [key: string]: readonly string[];
36
+ };
37
+ //#endregion
38
+ export { ApiExample, EXAMPLES, examplesByKey };
@@ -0,0 +1,217 @@
1
+ import { a as Path, i as ImageNode, o as Rect, r as Group, s as Text, t as Circle } from "./nodes.js";
2
+ import { registerExamples } from "./describe.js";
3
+ import { a as evaluate, i as createScene } from "./scene.js";
4
+ import { Grid } from "./grid.js";
5
+ import { Stack } from "./layoutCtors.js";
6
+ import { splitText } from "./type.js";
7
+ import { r as motionPath } from "./motionPath.js";
8
+ import { pathFromSvg } from "./path.js";
9
+ import { key, timeline, track } from "@glissade/core";
10
+ //#region src/examples.ts
11
+ /**
12
+ * `@glissade/scene/examples` — the runnable example corpus (§0.24 onboarding,
13
+ * card `8jQ9rNqStGDL`). The single highest agent-onboarding cost is STALE
14
+ * examples: prose that drifts from the runtime, which a cold agent can't
15
+ * glance-test. The fix is to make canonical examples EXECUTABLE and surfaced from
16
+ * the same place the doctest harness runs them, so they can't go stale.
17
+ *
18
+ * Each {@link ApiExample} carries a copy-pasteable `code` string (what
19
+ * `describe({ examples: true })` surfaces) AND an executable `run` thunk against
20
+ * the REAL API (what `examples.test.ts` asserts never throws). The two sit
21
+ * adjacent and are kept in lockstep; the `run` guard is what fails CI the moment
22
+ * an example's API drifts (a renamed export, a changed shape).
23
+ *
24
+ * Tree-shaking: this module is OFF the base scene index (its own subpath entry),
25
+ * so a scene that never imports it pays zero bytes. Importing it REGISTERS the
26
+ * corpus with `describe()` via {@link registerExamples} (the value-type-registry
27
+ * pattern) — describe never imports examples, so the base embed + IIFE stay lean.
28
+ */
29
+ /** A throwaway scene+evaluate so a node example proves it renders end-to-end, not
30
+ * just constructs (the estimating measurer suffices — no backend needed). */
31
+ function renders(...children) {
32
+ evaluate(createScene({
33
+ size: {
34
+ w: 320,
35
+ h: 200
36
+ },
37
+ children
38
+ }), timeline(() => {}), 0);
39
+ }
40
+ const EXAMPLES = [
41
+ {
42
+ key: "Rect",
43
+ code: "import { Rect } from '@glissade/scene';\nnew Rect({ position: [160, 100], width: 200, height: 100, fill: '#3b82f6', cornerRadius: 12 });",
44
+ run: () => renders(new Rect({
45
+ position: [160, 100],
46
+ width: 200,
47
+ height: 100,
48
+ fill: "#3b82f6",
49
+ cornerRadius: 12
50
+ }))
51
+ },
52
+ {
53
+ key: "Circle",
54
+ code: "import { Circle } from '@glissade/scene';\nnew Circle({ position: [160, 100], radius: 48, fill: '#ef4444' });",
55
+ run: () => renders(new Circle({
56
+ position: [160, 100],
57
+ radius: 48,
58
+ fill: "#ef4444"
59
+ }))
60
+ },
61
+ {
62
+ key: "Text",
63
+ code: "import { Text } from '@glissade/scene';\n// position anchors at the baseline-left by default; set `anchor` to share a corner with a shape\nnew Text({ position: [40, 60], text: 'Hello', fontSize: 32, fill: '#111827' });",
64
+ run: () => renders(new Text({
65
+ position: [40, 60],
66
+ text: "Hello",
67
+ fontSize: 32,
68
+ fill: "#111827"
69
+ }))
70
+ },
71
+ {
72
+ key: "Path",
73
+ code: "import { Path } from '@glissade/scene';\nimport { pathFromSvg } from '@glissade/scene/path';\n// Path.data wants a PathValue — parse an SVG `d` string with pathFromSvg (NOT a raw string)\nnew Path({ data: pathFromSvg('M0 0 L100 0 L50 80 Z'), fill: '#10b981' });",
74
+ run: () => renders(new Path({
75
+ data: pathFromSvg("M0 0 L100 0 L50 80 Z"),
76
+ fill: "#10b981"
77
+ }))
78
+ },
79
+ {
80
+ key: "Group",
81
+ code: "import { Group, Rect, Text } from '@glissade/scene';\n// a Group nests its children as one unit (and renders as a wrapper <div> on the DOM tier)\nnew Group({ id: 'card', children: [\n new Rect({ anchor: 'top-left', position: [0, 0], width: 240, height: 96, fill: '#0f172a', cornerRadius: 16 }),\n new Text({ anchor: 'top-left', position: [16, 16], text: 'Title', fontSize: 24, fill: '#f8fafc' }),\n] });",
82
+ run: () => renders(new Group({
83
+ id: "card",
84
+ children: [new Rect({
85
+ anchor: "top-left",
86
+ position: [0, 0],
87
+ width: 240,
88
+ height: 96,
89
+ fill: "#0f172a",
90
+ cornerRadius: 16
91
+ }), new Text({
92
+ anchor: "top-left",
93
+ position: [16, 16],
94
+ text: "Title",
95
+ fontSize: 24,
96
+ fill: "#f8fafc"
97
+ })]
98
+ }))
99
+ },
100
+ {
101
+ key: "Image",
102
+ code: "import { Image } from '@glissade/scene';\n// `assetId` names a media entry declared on the Timeline: timeline({ assets: { hero: { kind: 'image', url } } })\nnew Image({ assetId: 'hero', position: [160, 100], width: 200, height: 120 });",
103
+ run: () => renders(new ImageNode({
104
+ assetId: "hero",
105
+ position: [160, 100],
106
+ width: 200,
107
+ height: 120
108
+ }))
109
+ },
110
+ {
111
+ key: "to",
112
+ code: "import { timeline } from '@glissade/core';\n// `from` anchors the start; the per-target cursor advances by `duration`\ntimeline((tl) => tl.to('card/position', [200, 100], { duration: 1, from: [0, 0] }));",
113
+ run: () => void timeline((tl) => tl.to("card/position", [200, 100], {
114
+ duration: 1,
115
+ from: [0, 0]
116
+ }))
117
+ },
118
+ {
119
+ key: "fromTo",
120
+ code: "import { timeline } from '@glissade/core';\ntimeline((tl) => tl.fromTo('card/opacity', 0, 1, { duration: 0.5 }));",
121
+ run: () => void timeline((tl) => tl.fromTo("card/opacity", 0, 1, { duration: .5 }))
122
+ },
123
+ {
124
+ key: "stagger",
125
+ code: "import { timeline } from '@glissade/core';\n// one tween per target, cascaded by `each`; `anchor` picks where the cascade ranks from\ntimeline((tl) => tl.stagger(['a/opacity', 'b/opacity', 'c/opacity'], { to: 1, from: 0, duration: 0.4 }, { each: 0.1 }));",
126
+ run: () => void timeline((tl) => tl.stagger([
127
+ "a/opacity",
128
+ "b/opacity",
129
+ "c/opacity"
130
+ ], {
131
+ to: 1,
132
+ from: 0,
133
+ duration: .4
134
+ }, { each: .1 }))
135
+ },
136
+ {
137
+ key: "set",
138
+ code: "import { timeline } from '@glissade/core';\n// a hold key — the value snaps at the resolved position\ntimeline((tl) => tl.set('card/fill', '#ef4444', { at: 0.5 }));",
139
+ run: () => void timeline((tl) => tl.set("card/fill", "#ef4444", { at: .5 }))
140
+ },
141
+ {
142
+ key: "tracks",
143
+ code: "import { timeline, track, key } from '@glissade/core';\n// attach raw keyframe tracks (the value type is the 2nd arg)\ntimeline((tl) => tl.tracks([track('card/x', 'number', [key(0, 0), key(1, 100)])]));",
144
+ run: () => void timeline((tl) => tl.tracks([track("card/x", "number", [key(0, 0), key(1, 100)])]))
145
+ },
146
+ {
147
+ key: "splitText",
148
+ code: "import { splitText } from '@glissade/scene/type';\n// the source needs an `id` — parts bind tracks against `<id>/<i>`. sp.targets('opacity') gives the reveal-recipe targets\nconst sp = splitText({ id: 'title', text: 'Hello', fontSize: 40 }, { by: 'grapheme' });",
149
+ run: () => void splitText({
150
+ id: "title",
151
+ text: "Hello",
152
+ fontSize: 40
153
+ }, { by: "grapheme" })
154
+ },
155
+ {
156
+ key: "measureWrappedText",
157
+ code: "import { createScene } from '@glissade/scene';\n// size a bubble/card to wrapped text WITHOUT a Text node (the FontSpec field is `size`, not `fontSize`)\nconst scene = createScene({ size: { w: 400, h: 200 }, children: [] });\nconst { width, lines, height } = scene.measureWrappedText('a long string that wraps across the box', { family: 'sans-serif', size: 24 }, 280);",
158
+ run: () => {
159
+ createScene({
160
+ size: {
161
+ w: 400,
162
+ h: 200
163
+ },
164
+ children: []
165
+ }).measureWrappedText("a long string that wraps across the box", {
166
+ family: "sans-serif",
167
+ size: 24
168
+ }, 280);
169
+ }
170
+ },
171
+ {
172
+ key: "Grid",
173
+ code: "import { Grid } from '@glissade/scene/grid';\n// build-time fan-out into a column grid (no Yoga) — children move to cell centers.\n// fr columns (`columns: 3`) need a `width` to resolve against; `cellHeight` is the row pitch\nGrid({ columns: 3, width: 360, gap: 16, cellHeight: 80, children: [new Rect({ width: 80, height: 60 }), new Rect({ width: 80, height: 60 })] });",
174
+ run: () => void Grid({
175
+ columns: 3,
176
+ width: 360,
177
+ gap: 16,
178
+ cellHeight: 80,
179
+ children: [new Rect({
180
+ width: 80,
181
+ height: 60
182
+ }), new Rect({
183
+ width: 80,
184
+ height: 60
185
+ })]
186
+ })
187
+ },
188
+ {
189
+ key: "Stack",
190
+ code: "import { Stack } from '@glissade/scene/layout';\nimport { loadYogaLayoutEngine } from '@glissade/scene/layout';\n// flexbox via Yoga — load the engine ONCE before evaluating any layout scene:\n// await loadYogaLayoutEngine();\nStack({ direction: 'row', gap: 16, children: [new Rect({ width: 80, height: 80 }), new Rect({ width: 80, height: 80 })] });",
191
+ run: () => void Stack({
192
+ direction: "row",
193
+ gap: 16,
194
+ children: [new Rect({
195
+ width: 80,
196
+ height: 80
197
+ }), new Rect({
198
+ width: 80,
199
+ height: 80
200
+ })]
201
+ })
202
+ },
203
+ {
204
+ key: "motionPath",
205
+ code: "import { motionPath } from '@glissade/scene/motion';\nimport { pathFromSvg } from '@glissade/scene/path';\nconst mp = motionPath(pathFromSvg('M0 0 C50 0 50 100 100 100'));\nconst pointHalfway = mp.atProgress(0.5); // { x, y }",
206
+ run: () => void motionPath(pathFromSvg("M0 0 C50 0 50 100 100 100")).atProgress(.5)
207
+ }
208
+ ];
209
+ /** Group the corpus by describe-key → the surfaced code snippets. */
210
+ function examplesByKey() {
211
+ const byKey = {};
212
+ for (const ex of EXAMPLES) (byKey[ex.key] ??= []).push(ex.code);
213
+ return byKey;
214
+ }
215
+ registerExamples(examplesByKey());
216
+ //#endregion
217
+ export { EXAMPLES, examplesByKey };
package/dist/identity.js CHANGED
@@ -1,4 +1,4 @@
1
- import { H as createDisplayListBuilder } from "./nodes.js";
1
+ import { U as createDisplayListBuilder } from "./nodes.js";
2
2
  import { r as bindScene } from "./scene.js";
3
3
  import { evaluateAt } from "@glissade/core";
4
4
  //#region src/identity.ts
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { C as Mat2x3, D as matEquals, E as invert, O as multiply, S as IDENTITY, T as fromTRS, _ as StrokeStyle, a as FilterSpec, b as glow, c as MeshInterpolation, d as Paint, f as PathSeg, g as ShaderRef, h as ResourceId, i as DrawCommand, l as MeshPaint, m as Resource, n as DisplayList, o as FilterValidationError, p as Rect$1, r as DisplayListBuilder, s as FontSpec, t as BlendMode, u as MeshPoint, v as createDisplayListBuilder, w as applyToPoint, x as validateFilters, y as filtersToCanvasFilter } from "./displayList.js";
2
2
  import { t as collapseReplacer } from "./collapseReplacer.js";
3
- import { $ as quantize, A as resolveSketch, B as NodeConstructionError, C as Polyline, D as arcLength, E as SketchValidationError, F as AnchorSpec, G as TextMeasurer, H as PropInit, I as BindablePropTarget, J as __resetEstimateWarnings, K as TextMetricsLite, L as EvalContext, M as sketchStrokes, N as validateHachure, O as flatten, P as validateSketch, Q as measureWrappedText, R as HitArea, S as HachureSpec, T as SketchStyle, U as resolveAnchor, V as NodeProps, W as MEASURE_QUANTUM_PX, X as estimatingMeasurer, Y as breakLines, Z as isEstimatingMeasurer, _ as WordBox, a as ImageNode, b as revealSchedule, c as Path, d as RevealMark, et as segmentGraphemes, f as ShapeProps, g as VideoProps, h as Video, i as Group, j as roughen, k as hachureLines, l as PathProps, m as TextProps, n as Custom, nt as setDefaultMeasurer, o as ImageProps, p as Text, q as WrappedTextMetrics, r as GraphemeBox, s as LineBox, t as Circle, tt as segmentWords, u as Rect, v as coercePathData, w as ResolvedSketch, x as roundedRectSegs, y as pathFromSegs, z as Node } from "./nodes.js";
3
+ import { $ as measureWrappedText, A as resolveSketch, B as NodeConstructionError, C as Polyline, D as arcLength, E as SketchValidationError, F as AnchorSpec, G as TextMeasurer, H as PropInit, I as BindablePropTarget, J as __resetEstimateWarnings, K as TextMetricsLite, L as EvalContext, M as sketchStrokes, N as validateHachure, O as flatten, P as validateSketch, Q as isEstimatingMeasurer, R as HitArea, S as HachureSpec, T as SketchStyle, U as resolveAnchor, V as NodeProps, W as MEASURE_QUANTUM_PX, X as breakLines, Y as assertFiniteFontSize, Z as estimatingMeasurer, _ as WordBox, a as ImageNode, b as revealSchedule, c as Path, d as RevealMark, et as quantize, f as ShapeProps, g as VideoProps, h as Video, i as Group, j as roughen, k as hachureLines, l as PathProps, m as TextProps, n as Custom, nt as segmentWords, o as ImageProps, p as Text, q as WrappedTextMetrics, r as GraphemeBox, rt as setDefaultMeasurer, s as LineBox, t as Circle, tt as segmentGraphemes, u as Rect, v as coercePathData, w as ResolvedSketch, x as roundedRectSegs, y as pathFromSegs, z as Node } from "./nodes.js";
4
4
  import { a as SceneModule, c as evaluate, i as SceneInit, n as ReservedNodeIdError, o as bindScene, r as Scene, s as createScene, t as DuplicateNodeIdError } from "./scene.js";
5
5
  import { a as LayoutEngineMissingError, c as requireLayoutEngine, i as LayoutEngine, l as setLayoutEngine, n as LayoutChildSpec, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox } from "./layoutEngine.js";
6
6
  import { BindableSignal, CoverageReport, EaseSpec, FontMode, FontUsage, MeshPaint as MeshPaint$1, Rng, Timeline, Track } from "@glissade/core";
@@ -619,4 +619,4 @@ declare function meshRasterSize(bw: number, bh: number): {
619
619
  h: number;
620
620
  };
621
621
  //#endregion
622
- export { ALL_FILTER_KINDS, type AnchorSpec, type BackendCaps, type BindablePropTarget, type BlendMode, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, Custom, DeterminismViolationError, type DisplayList, type DisplayListBuilder, type DrawCommand, type DrawOnEachOptions, type DrawOnOptions, DuplicateNodeIdError, type EachBox, type EachContext, type EachDistribute, EachError, type EachLayout, type EachMotion, type EachOpts, type EachResult, type EditMark, type EvalContext, type FilterKind, type FilterSpec, FilterValidationError, type FontByteLoader, type FontSpec, type GraphemeBox, Group, type GuardMode, type HachureSpec, Highlight, type HighlightProps, type HitArea, IDENTITY, ImageNode as Image, ImageNode, type ImageDataLike, type ImageHandle, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type LineBox, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, type Mat2x3, type MeshInterpolation, type MeshPaint, type MeshPoint, NODE_TAXONOMY, Node, NodeConstructionError, type NodeProps, type NodeTypeName, type Paint, Path, type PathLike, type PathProps, type PathSeg, type Place, type Polyline, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type RenderBackend, ReservedNodeIdError, type ResolvedSketch, type Resource, type ResourceId, type RevealMark, type Scene, type SceneInit, type SceneModule, type ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, type ShapeProps, type SketchStyle, SketchValidationError, type StepMark, type StrokeStyle, Text, TextCursor, type TextCursorProps, type TextMeasurer, type TextMetricsLite, type TextProps, type TypeEdit, type TypewriterResult, type ValidateSceneFontsOptions, Video, type VideoFrameSource, type VideoProps, type WordBox, type WrappedTextMetrics, __resetEstimateWarnings, applyToPoint, arcLength, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, measureWrappedText, meshRasterSize, multiply, pathFromSegs, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
622
+ export { ALL_FILTER_KINDS, type AnchorSpec, type BackendCaps, type BindablePropTarget, type BlendMode, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, Custom, DeterminismViolationError, type DisplayList, type DisplayListBuilder, type DrawCommand, type DrawOnEachOptions, type DrawOnOptions, DuplicateNodeIdError, type EachBox, type EachContext, type EachDistribute, EachError, type EachLayout, type EachMotion, type EachOpts, type EachResult, type EditMark, type EvalContext, type FilterKind, type FilterSpec, FilterValidationError, type FontByteLoader, type FontSpec, type GraphemeBox, Group, type GuardMode, type HachureSpec, Highlight, type HighlightProps, type HitArea, IDENTITY, ImageNode as Image, ImageNode, type ImageDataLike, type ImageHandle, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type LineBox, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, type Mat2x3, type MeshInterpolation, type MeshPaint, type MeshPoint, NODE_TAXONOMY, Node, NodeConstructionError, type NodeProps, type NodeTypeName, type Paint, Path, type PathLike, type PathProps, type PathSeg, type Place, type Polyline, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type RenderBackend, ReservedNodeIdError, type ResolvedSketch, type Resource, type ResourceId, type RevealMark, type Scene, type SceneInit, type SceneModule, type ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, type ShapeProps, type SketchStyle, SketchValidationError, type StepMark, type StrokeStyle, Text, TextCursor, type TextCursorProps, type TextMeasurer, type TextMetricsLite, type TextProps, type TypeEdit, type TypewriterResult, type ValidateSceneFontsOptions, Video, type VideoFrameSource, type VideoProps, type WordBox, type WrappedTextMetrics, __resetEstimateWarnings, applyToPoint, arcLength, assertFiniteFontSize, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, measureWrappedText, meshRasterSize, multiply, pathFromSegs, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as validateSketch, D as segmentGraphemes, E as quantize, F as hashStr, G as validateFilters, H as createDisplayListBuilder, I as resolveSketch, J as applyToPoint, K as collapseReplacer, L as roughen, M as arcLength, N as flatten, O as segmentWords, P as hachureLines, Q as multiply, R as sketchStrokes, S as estimatingMeasurer, T as measureWrappedText, U as filtersToCanvasFilter, V as FilterValidationError, W as glow, X as invert, Y as fromTRS, Z as matEquals, a as Path, b as __resetEstimateWarnings, c as Video, d as revealSchedule, f as roundedRectSegs, h as resolveAnchor, i as ImageNode, j as SketchValidationError, k as setDefaultMeasurer, l as coercePathData, m as NodeConstructionError, n as Custom, o as Rect, p as Node, q as IDENTITY, r as Group, s as Text, t as Circle, u as pathFromSegs, w as isEstimatingMeasurer, x as breakLines, y as MEASURE_QUANTUM_PX, z as validateHachure } from "./nodes.js";
1
+ import { $ as multiply, A as setDefaultMeasurer, B as validateHachure, C as estimatingMeasurer, D as quantize, E as measureWrappedText, F as hachureLines, G as glow, H as FilterValidationError, I as hashStr, J as IDENTITY, K as validateFilters, L as resolveSketch, M as SketchValidationError, N as arcLength, O as segmentGraphemes, P as flatten, Q as matEquals, R as roughen, S as breakLines, T as isEstimatingMeasurer, U as createDisplayListBuilder, V as validateSketch, W as filtersToCanvasFilter, X as fromTRS, Y as applyToPoint, Z as invert, a as Path, b as __resetEstimateWarnings, c as Video, d as revealSchedule, f as roundedRectSegs, h as resolveAnchor, i as ImageNode, k as segmentWords, l as coercePathData, m as NodeConstructionError, n as Custom, o as Rect, p as Node, q as collapseReplacer, r as Group, s as Text, t as Circle, u as pathFromSegs, x as assertFiniteFontSize, y as MEASURE_QUANTUM_PX, z as sketchStrokes } from "./nodes.js";
2
2
  import { a as evaluate, i as createScene, n as ReservedNodeIdError, r as bindScene, t as DuplicateNodeIdError } from "./scene.js";
3
3
  import { i as setLayoutEngine, n as getLayoutEngine, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
4
4
  import { buildFontRegistry, emitDevWarning, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts } from "@glissade/core";
@@ -1356,4 +1356,4 @@ var Raster2D = class {
1356
1356
  }
1357
1357
  };
1358
1358
  //#endregion
1359
- export { ALL_FILTER_KINDS, Circle, ColdAssetError, Custom, DeterminismViolationError, DuplicateNodeIdError, EachError, FilterValidationError, Group, Highlight, IDENTITY, ImageNode as Image, ImageNode, LayoutEngineMissingError, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, NODE_TAXONOMY, Node, NodeConstructionError, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, Video, __resetEstimateWarnings, applyToPoint, arcLength, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, measureWrappedText, meshRasterSize, multiply, pathFromSegs, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
1359
+ export { ALL_FILTER_KINDS, Circle, ColdAssetError, Custom, DeterminismViolationError, DuplicateNodeIdError, EachError, FilterValidationError, Group, Highlight, IDENTITY, ImageNode as Image, ImageNode, LayoutEngineMissingError, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, NODE_TAXONOMY, Node, NodeConstructionError, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, Video, __resetEstimateWarnings, applyToPoint, arcLength, assertFiniteFontSize, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, measureWrappedText, meshRasterSize, multiply, pathFromSegs, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
@@ -1,4 +1,4 @@
1
- import { C as fallbackMeasurer, r as Group } from "./nodes.js";
1
+ import { r as Group, w as fallbackMeasurer } from "./nodes.js";
2
2
  import { r as requireLayoutEngine } from "./layoutEngine.js";
3
3
  import { computed, signal } from "@glissade/core";
4
4
  //#region src/layoutCtors.ts
package/dist/motion.js CHANGED
@@ -1,171 +1,2 @@
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
1
+ import { a as pointAtLength, i as pathLength, n as followPath, r as motionPath, t as FollowPath } from "./motionPath.js";
171
2
  export { FollowPath, followPath, motionPath, pathLength, pointAtLength };
@@ -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 { pointAtLength as a, pathLength as i, followPath as n, motionPath as r, FollowPath as t };
package/dist/nodes.d.ts CHANGED
@@ -21,6 +21,17 @@ interface TextMeasurer {
21
21
  declare const MEASURE_QUANTUM_PX = 0.5;
22
22
  /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
23
23
  declare function quantize(v: number): number;
24
+ /**
25
+ * FAIL LOUD on a non-measurable FontSpec (§0.24 fail-loud sweep). A `size` that
26
+ * isn't a finite positive number silently yields NaN/0 metrics in the estimating
27
+ * measurers (and a wrong-font fallback in the real backends) → zero-height layout
28
+ * boxes, broken wrapping/reveal, all with NO error — the silent-wrong-result class
29
+ * an agent can't glance-test. The common cause is the field name: the FontSpec
30
+ * field is `size`, NOT `fontSize` (that is the Text node prop). The single guard
31
+ * every measurement entry point (breakLines, measureWrappedText, the backend
32
+ * `measureText`s) routes through, so the contract is enforced uniformly.
33
+ */
34
+ declare function assertFiniteFontSize(font: FontSpec, where: string): void;
24
35
  /**
25
36
  * Process-wide fallback measurer for FACTORY-TIME measurement — component
26
37
  * factories run before any scene exists, so Text pulls (measuredSize,
@@ -879,4 +890,4 @@ interface RevealMark {
879
890
  */
880
891
  declare function revealSchedule(text: Text, reveal: Track<number>, measurer?: TextMeasurer): RevealMark[];
881
892
  //#endregion
882
- export { quantize as $, resolveSketch as A, NodeConstructionError as B, Polyline as C, arcLength as D, SketchValidationError as E, AnchorSpec as F, TextMeasurer as G, PropInit as H, BindablePropTarget as I, __resetEstimateWarnings as J, TextMetricsLite as K, EvalContext as L, sketchStrokes as M, validateHachure as N, flatten as O, validateSketch as P, measureWrappedText as Q, HitArea as R, HachureSpec as S, SketchStyle as T, resolveAnchor as U, NodeProps as V, MEASURE_QUANTUM_PX as W, estimatingMeasurer as X, breakLines as Y, isEstimatingMeasurer as Z, WordBox as _, ImageNode as a, revealSchedule as b, Path as c, RevealMark as d, segmentGraphemes as et, ShapeProps as f, VideoProps as g, Video as h, Group as i, roughen as j, hachureLines as k, PathProps as l, TextProps as m, Custom as n, setDefaultMeasurer as nt, ImageProps as o, Text as p, WrappedTextMetrics as q, GraphemeBox as r, LineBox as s, Circle as t, segmentWords as tt, Rect as u, coercePathData as v, ResolvedSketch as w, roundedRectSegs as x, pathFromSegs as y, Node as z };
893
+ export { measureWrappedText as $, resolveSketch as A, NodeConstructionError as B, Polyline as C, arcLength as D, SketchValidationError as E, AnchorSpec as F, TextMeasurer as G, PropInit as H, BindablePropTarget as I, __resetEstimateWarnings as J, TextMetricsLite as K, EvalContext as L, sketchStrokes as M, validateHachure as N, flatten as O, validateSketch as P, isEstimatingMeasurer as Q, HitArea as R, HachureSpec as S, SketchStyle as T, resolveAnchor as U, NodeProps as V, MEASURE_QUANTUM_PX as W, breakLines as X, assertFiniteFontSize as Y, estimatingMeasurer as Z, WordBox as _, ImageNode as a, revealSchedule as b, Path as c, RevealMark as d, quantize as et, ShapeProps as f, VideoProps as g, Video as h, Group as i, roughen as j, hachureLines as k, PathProps as l, TextProps as m, Custom as n, segmentWords as nt, ImageProps as o, Text as p, WrappedTextMetrics as q, GraphemeBox as r, setDefaultMeasurer as rt, LineBox as s, Circle as t, segmentGraphemes as tt, Rect as u, coercePathData as v, ResolvedSketch as w, roundedRectSegs as x, pathFromSegs as y, Node as z };
package/dist/nodes.js CHANGED
@@ -560,6 +560,19 @@ function quantize(v) {
560
560
  return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
561
561
  }
562
562
  /**
563
+ * FAIL LOUD on a non-measurable FontSpec (§0.24 fail-loud sweep). A `size` that
564
+ * isn't a finite positive number silently yields NaN/0 metrics in the estimating
565
+ * measurers (and a wrong-font fallback in the real backends) → zero-height layout
566
+ * boxes, broken wrapping/reveal, all with NO error — the silent-wrong-result class
567
+ * an agent can't glance-test. The common cause is the field name: the FontSpec
568
+ * field is `size`, NOT `fontSize` (that is the Text node prop). The single guard
569
+ * every measurement entry point (breakLines, measureWrappedText, the backend
570
+ * `measureText`s) routes through, so the contract is enforced uniformly.
571
+ */
572
+ function assertFiniteFontSize(font, where) {
573
+ if (typeof font.size !== "number" || !Number.isFinite(font.size) || font.size <= 0) throw new Error(`${where}: font.size must be a positive number (got ${JSON.stringify(font.size)}). The FontSpec field is \`size\`, not \`fontSize\` (that is the Text node prop) — pass \`{ family, size }\`.`);
574
+ }
575
+ /**
563
576
  * Estimating fallback measurer — used only when no backend has been injected
564
577
  * (e.g. evaluating for IR-level tests). Deterministic but not metrically
565
578
  * faithful; mount(), the CLI, and exporters always inject the real one.
@@ -651,6 +664,7 @@ function segmentGraphemes(text) {
651
664
  * intra-word breaking in v1).
652
665
  */
653
666
  function breakLines(text, font, maxWidth, measurer) {
667
+ assertFiniteFontSize(font, "breakLines");
654
668
  const paragraphs = text.split("\n");
655
669
  if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
656
670
  const lines = [];
@@ -678,7 +692,7 @@ function breakLines(text, font, maxWidth, measurer) {
678
692
  * `FontSpec`, so it's a parameter here).
679
693
  */
680
694
  function measureWrappedText(text, font, width, lineHeight, measurer) {
681
- if (typeof font.size !== "number" || !Number.isFinite(font.size) || font.size <= 0) throw new Error(`measureWrappedText: font.size must be a positive number (got ${JSON.stringify(font.size)}). The FontSpec field is \`size\`, not \`fontSize\` (that is the Text node prop) — pass \`{ family, size }\`.`);
695
+ assertFiniteFontSize(font, "measureWrappedText");
682
696
  const lines = breakLines(text, font, width > 0 ? width : void 0, measurer);
683
697
  let widest = 0;
684
698
  let ascent = 0;
@@ -2288,4 +2302,4 @@ function revealSchedule(text, reveal, measurer) {
2288
2302
  return marks;
2289
2303
  }
2290
2304
  //#endregion
2291
- export { warnIfEstimating as A, validateSketch as B, fallbackMeasurer as C, segmentGraphemes as D, quantize as E, hashStr as F, validateFilters as G, createDisplayListBuilder as H, resolveSketch as I, applyToPoint as J, collapseReplacer as K, roughen as L, arcLength as M, flatten as N, segmentWords as O, hachureLines as P, multiply as Q, sketchStrokes as R, estimatingMeasurer as S, measureWrappedText as T, filtersToCanvasFilter as U, FilterValidationError as V, glow as W, invert as X, fromTRS as Y, matEquals as Z, NODE_CONSTRUCTION_PROP_NAMES as _, Path as a, __resetEstimateWarnings as b, Video as c, revealSchedule as d, roundedRectSegs as f, BASE_CONSTRUCTION_PROP_NAMES as g, resolveAnchor as h, ImageNode as i, SketchValidationError as j, setDefaultMeasurer as k, coercePathData as l, NodeConstructionError as m, Custom as n, Rect as o, Node as p, IDENTITY as q, Group as r, Text as s, Circle as t, pathFromSegs as u, isConstructionProp as v, isEstimatingMeasurer as w, breakLines as x, MEASURE_QUANTUM_PX as y, validateHachure as z };
2305
+ export { multiply as $, setDefaultMeasurer as A, validateHachure as B, estimatingMeasurer as C, quantize as D, measureWrappedText as E, hachureLines as F, glow as G, FilterValidationError as H, hashStr as I, IDENTITY as J, validateFilters as K, resolveSketch as L, SketchValidationError as M, arcLength as N, segmentGraphemes as O, flatten as P, matEquals as Q, roughen as R, breakLines as S, isEstimatingMeasurer as T, createDisplayListBuilder as U, validateSketch as V, filtersToCanvasFilter as W, fromTRS as X, applyToPoint as Y, invert as Z, NODE_CONSTRUCTION_PROP_NAMES as _, Path as a, __resetEstimateWarnings as b, Video as c, revealSchedule as d, roundedRectSegs as f, BASE_CONSTRUCTION_PROP_NAMES as g, resolveAnchor as h, ImageNode as i, warnIfEstimating as j, segmentWords as k, coercePathData as l, NodeConstructionError as m, Custom as n, Rect as o, Node as p, collapseReplacer as q, Group as r, Text as s, Circle as t, pathFromSegs as u, isConstructionProp as v, fallbackMeasurer as w, assertFiniteFontSize as x, MEASURE_QUANTUM_PX as y, sketchStrokes as z };
package/dist/scene.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as fallbackMeasurer, H as createDisplayListBuilder, T as measureWrappedText, r as Group, v as isConstructionProp } from "./nodes.js";
1
+ import { E as measureWrappedText, U as createDisplayListBuilder, r as Group, v as isConstructionProp, w as fallbackMeasurer } from "./nodes.js";
2
2
  import { bindTimeline, compileTimeline, createPlayhead, evaluateAt, signal } from "@glissade/core";
3
3
  //#region src/scene.ts
4
4
  /**
package/dist/tokens.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Z as matEquals, f as roundedRectSegs, p as Node, q as IDENTITY } from "./nodes.js";
1
+ import { J as IDENTITY, Q as matEquals, f as roundedRectSegs, p as Node } from "./nodes.js";
2
2
  import { signal, vec2Signal } from "@glissade/core";
3
3
  //#region src/tokenHighlight.ts
4
4
  /**
package/dist/type.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as warnIfEstimating, C as fallbackMeasurer, E as quantize, r as Group, s as Text } from "./nodes.js";
1
+ import { D as quantize, j as warnIfEstimating, r as Group, s as Text, w as fallbackMeasurer } from "./nodes.js";
2
2
  //#region src/type.ts
3
3
  /**
4
4
  * `@glissade/scene/type` — `splitText()`: build-time split-text sub-targets
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.23.0",
3
+ "version": "0.24.0-pre.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
  "engines": {
7
7
  "node": ">=20.19"
8
8
  },
9
9
  "type": "module",
10
- "sideEffects": false,
10
+ "sideEffects": [
11
+ "./dist/examples.js"
12
+ ],
11
13
  "exports": {
12
14
  ".": {
13
15
  "types": "./dist/index.d.ts",
@@ -52,6 +54,10 @@
52
54
  "./identity": {
53
55
  "types": "./dist/identity.d.ts",
54
56
  "default": "./dist/identity.js"
57
+ },
58
+ "./examples": {
59
+ "types": "./dist/examples.d.ts",
60
+ "default": "./dist/examples.js"
55
61
  }
56
62
  },
57
63
  "files": [
@@ -59,7 +65,7 @@
59
65
  ],
60
66
  "dependencies": {
61
67
  "yoga-layout": "^3.2.1",
62
- "@glissade/core": "0.23.0"
68
+ "@glissade/core": "0.24.0-pre.1"
63
69
  },
64
70
  "repository": {
65
71
  "type": "git",