@glissade/scene 0.18.0-pre.4 → 0.18.0-pre.5

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,67 @@
1
+ //#region src/describe.d.ts
2
+ /**
3
+ * `describe()` — a machine-readable API manifest (0.18), the structural antidote
4
+ * to discoverability: an AI consumer reads GROUND TRUTH from the artifact instead
5
+ * of reverse-engineering the surface. PURE INTROSPECTION — it instantiates each
6
+ * built-in node once, reads its registered track targets, and enumerates the core
7
+ * registries; it never touches `evaluate()` or any cross-frame state, so it has
8
+ * zero determinism impact.
9
+ *
10
+ * The whole point is NO-DRIFT: every section is GENERATED from the live registry
11
+ * it documents (`Node.listTargets()` → the real `registerTarget` calls,
12
+ * `listValueTypes()` → the ValueType registry, `easings` → the easing registry),
13
+ * so the manifest can't fall out of sync with what the framework actually does.
14
+ * The builder method list and the subpath map are the lone curated parts (runtime
15
+ * signatures aren't introspectable); a test pins the builder names to the
16
+ * `TimelineBuilder` interface so even those can't silently drift.
17
+ *
18
+ * Tree-shakeable: this lives on its OWN module (re-exported on the
19
+ * `@glissade/scene/describe` subpath / the `@glissade/browser` bundle), so it is
20
+ * never pulled onto the base embed path — a scene that never calls `describe()`
21
+ * pays zero bytes for it.
22
+ */
23
+ /** One animatable / settable prop in the manifest. */
24
+ interface DescribedProp {
25
+ /** The §2.2 value-type id this prop accepts (e.g. `'vec2'`, `'number'`, `'color'`). */
26
+ type: string;
27
+ /** Whether a Track can drive it (every registered target is animatable). */
28
+ animatable: boolean;
29
+ /** The track-target template, `'<id>/<path>'` — substitute the node's real id. */
30
+ target?: string;
31
+ /** Component count of the value (`vec2` → 2, scalar → 1); omitted for non-numeric reprs. */
32
+ arity?: number;
33
+ }
34
+ interface DescribedNode {
35
+ props: {
36
+ [prop: string]: DescribedProp;
37
+ };
38
+ }
39
+ interface DescribedBuilderMethod {
40
+ name: string;
41
+ signature: string;
42
+ }
43
+ /** The full machine-readable manifest `describe()` returns. */
44
+ interface ApiManifest {
45
+ version: string;
46
+ nodes: {
47
+ [typeName: string]: DescribedNode;
48
+ };
49
+ valueTypes: string[];
50
+ easings: string[];
51
+ builder: {
52
+ methods: DescribedBuilderMethod[];
53
+ };
54
+ createScene: string;
55
+ subpaths: {
56
+ [entry: string]: string;
57
+ };
58
+ }
59
+ /**
60
+ * Build the machine-readable API manifest from the live registries (§4.4). Pure
61
+ * introspection: instantiate each built-in node once to read its registered track
62
+ * targets, enumerate the ValueType + easing registries, and curate the builder /
63
+ * subpath surface. JSON-serializable; safe to call any number of times.
64
+ */
65
+ declare function describe(): ApiManifest;
66
+ //#endregion
67
+ export { ApiManifest, DescribedBuilderMethod, DescribedNode, DescribedProp, describe };
@@ -0,0 +1,153 @@
1
+ import { a as Path, c as Video, i as ImageNode, o as Rect, r as Group, s as Text, t as Circle } from "./nodes.js";
2
+ import { easings, listValueTypes } from "@glissade/core";
3
+ //#region src/describe.ts
4
+ /**
5
+ * `describe()` — a machine-readable API manifest (0.18), the structural antidote
6
+ * to discoverability: an AI consumer reads GROUND TRUTH from the artifact instead
7
+ * of reverse-engineering the surface. PURE INTROSPECTION — it instantiates each
8
+ * built-in node once, reads its registered track targets, and enumerates the core
9
+ * registries; it never touches `evaluate()` or any cross-frame state, so it has
10
+ * zero determinism impact.
11
+ *
12
+ * The whole point is NO-DRIFT: every section is GENERATED from the live registry
13
+ * it documents (`Node.listTargets()` → the real `registerTarget` calls,
14
+ * `listValueTypes()` → the ValueType registry, `easings` → the easing registry),
15
+ * so the manifest can't fall out of sync with what the framework actually does.
16
+ * The builder method list and the subpath map are the lone curated parts (runtime
17
+ * signatures aren't introspectable); a test pins the builder names to the
18
+ * `TimelineBuilder` interface so even those can't silently drift.
19
+ *
20
+ * Tree-shakeable: this lives on its OWN module (re-exported on the
21
+ * `@glissade/scene/describe` subpath / the `@glissade/browser` bundle), so it is
22
+ * never pulled onto the base embed path — a scene that never calls `describe()`
23
+ * pays zero bytes for it.
24
+ */
25
+ const RAW_VERSION = "0.18.0-pre.5";
26
+ const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
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
+ function arityOf(type) {
29
+ if (type === "number") return 1;
30
+ if (type === "vec2" || type === "vec2-arc") return 2;
31
+ }
32
+ /**
33
+ * Render a target's `expects` stamp to a single manifest type string. A
34
+ * polymorphic prop (e.g. `fill` is `['color','paint']`) joins with `|`; an
35
+ * untagged target (the 0.13 back-compat 2-arg `registerTarget`) reports
36
+ * `'unknown'`.
37
+ */
38
+ function expectsToType(expects) {
39
+ if (expects === void 0) return "unknown";
40
+ return Array.isArray(expects) ? expects.join("|") : expects;
41
+ }
42
+ /** Introspect one freshly-instantiated node into its prop manifest, reading the
43
+ * REAL `registerTarget` calls via `listTargets()` (so it can't drift). */
44
+ function describeNode(node) {
45
+ const props = {};
46
+ for (const { path, expects } of node.listTargets()) {
47
+ const type = expectsToType(expects);
48
+ const arity = arityOf(type);
49
+ props[path] = {
50
+ type,
51
+ animatable: true,
52
+ target: `<id>/${path}`,
53
+ ...arity !== void 0 ? { arity } : {}
54
+ };
55
+ }
56
+ return { props };
57
+ }
58
+ const NODE_FACTORIES = {
59
+ Group: () => new Group(),
60
+ Rect: () => new Rect(),
61
+ Circle: () => new Circle(),
62
+ Path: () => new Path(),
63
+ Text: () => new Text(),
64
+ Image: () => new ImageNode({ assetId: "~describe" }),
65
+ Video: () => new Video({ assetId: "~describe" })
66
+ };
67
+ /**
68
+ * The curated `TimelineBuilder` surface (§2.6). Runtime signatures aren't
69
+ * introspectable, so this is hand-kept — `describe.test.ts` pins every name here
70
+ * to the `TimelineBuilder` interface so it can't silently drift from the API.
71
+ */
72
+ const BUILDER_METHODS = [
73
+ {
74
+ name: "to",
75
+ signature: "to<T>(target, value, opts?: { duration?, ease?, at?, from? }): TimelineBuilder"
76
+ },
77
+ {
78
+ name: "fromTo",
79
+ signature: "fromTo<T>(target, from, to, opts?: { duration?, ease?, at? }): TimelineBuilder"
80
+ },
81
+ {
82
+ name: "stagger",
83
+ signature: "stagger<T>(targets, { to, from?, duration?, ease? }, { each, anchor?, at? }): TimelineBuilder"
84
+ },
85
+ {
86
+ name: "set",
87
+ signature: "set<T>(target, value, opts?: { at? }): TimelineBuilder"
88
+ },
89
+ {
90
+ name: "label",
91
+ signature: "label(name, at?): TimelineBuilder"
92
+ },
93
+ {
94
+ name: "add",
95
+ signature: "add(child, at?, opts?: { mode?: 'add'|'sync', timeScale? }): TimelineBuilder"
96
+ },
97
+ {
98
+ name: "sequence",
99
+ signature: "sequence(subs, opts?: { gap? }): TimelineBuilder"
100
+ },
101
+ {
102
+ name: "at",
103
+ signature: "at(time, sub): TimelineBuilder"
104
+ },
105
+ {
106
+ name: "call",
107
+ signature: "call(fn, at?): TimelineBuilder"
108
+ },
109
+ {
110
+ name: "cue",
111
+ signature: "cue(at, name, data?): TimelineBuilder"
112
+ },
113
+ {
114
+ name: "adBreak",
115
+ signature: "adBreak(at, opts?: { id?, duration? }): TimelineBuilder"
116
+ },
117
+ {
118
+ name: "editable",
119
+ signature: "editable(): TimelineBuilder"
120
+ },
121
+ {
122
+ name: "editableDuration",
123
+ signature: "editableDuration(): TimelineBuilder"
124
+ }
125
+ ];
126
+ /** One-line "what's there" for each documented tree-shakeable subpath entry. */
127
+ const SUBPATHS = {
128
+ "@glissade/core/clips": "motion clips: clip/clipList + the popIn/slideIn/pulse/driftLoop literals, presence (enter/exit) and morph (box-FLIP) build-time sugar.",
129
+ "@glissade/core/i18n": "localization: requireParity (id-set diff), localize (doc→doc resolver), t() ambient-table sugar.",
130
+ "@glissade/scene/layout": "flexbox: the Yoga-backed Layout node + LayoutEngine (the only entry that ships Yoga wasm).",
131
+ "@glissade/scene/path": "SVG geometry: pathFromSvg / parseSvgPathData — parse an SVG `d` string into a PathValue for Path.data."
132
+ };
133
+ /**
134
+ * Build the machine-readable API manifest from the live registries (§4.4). Pure
135
+ * introspection: instantiate each built-in node once to read its registered track
136
+ * targets, enumerate the ValueType + easing registries, and curate the builder /
137
+ * subpath surface. JSON-serializable; safe to call any number of times.
138
+ */
139
+ function describe() {
140
+ const nodes = {};
141
+ for (const [name, factory] of Object.entries(NODE_FACTORIES)) nodes[name] = describeNode(factory());
142
+ return {
143
+ version: PACKAGE_VERSION,
144
+ nodes,
145
+ valueTypes: listValueTypes(),
146
+ easings: Object.keys(easings),
147
+ builder: { methods: BUILDER_METHODS },
148
+ createScene: "createScene({ size: { w, h }, children: Node[] }): Scene",
149
+ subpaths: SUBPATHS
150
+ };
151
+ }
152
+ //#endregion
153
+ export { describe };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as segmentGraphemes, B as collapseReplacer, C as Node, D as estimatingMeasurer, E as breakLines, F as filtersToCanvasFilter, G as IDENTITY, H as formatDisplayDiff, I as glow, J as invert, K as applyToPoint, L as validateFilters, M as setDefaultMeasurer, N as FilterValidationError, O as fallbackMeasurer, P as createDisplayListBuilder, R as DL_SNAPSHOT_VERSION, S as validateSketch, T as MEASURE_QUANTUM_PX, U as parseDisplaySnapshot, V as diffDisplayLists, W as serializeDisplayList, X as multiply, Y as matEquals, _ as hashStr, a as Path, b as sketchStrokes, c as Video, d as revealSchedule, f as roundedRectSegs, g as hachureLines, h as flatten, i as ImageNode, j as segmentWords, k as quantize, l as coercePathData, m as arcLength, n as Custom, o as Rect, p as SketchValidationError, q as fromTRS, r as Group, s as Text, t as Circle, u as pathFromSegs, v as resolveSketch, w as resolveAnchor, x as validateHachure, y as roughen, z as DlSnapshotError } from "./nodes.js";
1
+ import { A as sketchStrokes, B as collapseReplacer, C as SketchValidationError, D as hashStr, E as hachureLines, F as filtersToCanvasFilter, G as IDENTITY, H as formatDisplayDiff, I as glow, J as invert, K as applyToPoint, L as validateFilters, M as validateSketch, N as FilterValidationError, O as resolveSketch, P as createDisplayListBuilder, R as DL_SNAPSHOT_VERSION, S as setDefaultMeasurer, T as flatten, U as parseDisplaySnapshot, V as diffDisplayLists, W as serializeDisplayList, X as multiply, Y as matEquals, _ as estimatingMeasurer, a as Path, b as segmentGraphemes, c as Video, d as revealSchedule, f as roundedRectSegs, g as breakLines, h as MEASURE_QUANTUM_PX, i as ImageNode, j as validateHachure, k as roughen, l as coercePathData, m as resolveAnchor, n as Custom, o as Rect, p as Node, q as fromTRS, r as Group, s as Text, t as Circle, u as pathFromSegs, v as fallbackMeasurer, w as arcLength, x as segmentWords, y as quantize, z as DlSnapshotError } from "./nodes.js";
2
2
  import { i as setLayoutEngine, n as getLayoutEngine, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
3
3
  import { bindTimeline, buildFontRegistry, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts, vec2Signal } from "@glissade/core";
4
4
  //#region src/taxonomy.ts
package/dist/layout.js CHANGED
@@ -1,4 +1,4 @@
1
- import { O as fallbackMeasurer, r as Group } from "./nodes.js";
1
+ import { r as Group, v as fallbackMeasurer } from "./nodes.js";
2
2
  import { i as setLayoutEngine, n as getLayoutEngine, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
3
3
  import { computed, signal } from "@glissade/core";
4
4
  //#region src/layout.ts
@@ -182,6 +182,18 @@ declare abstract class Node {
182
182
  unbindSource(): void;
183
183
  }, expects?: ValueTypeId | readonly ValueTypeId[]): void;
184
184
  resolveTarget(path: string): BindablePropTarget | undefined;
185
+ /**
186
+ * Enumerate this node's registered track-target paths and the value type each
187
+ * accepts — the introspection seam `describe()` reads to build the API
188
+ * manifest from the REAL `registerTarget` calls (so it can't drift). Returns
189
+ * `[path, expects]` pairs in registration order; `expects` is the §2.2 type
190
+ * stamp (a `ValueTypeId`, an array for a polymorphic prop like `fill`, or
191
+ * `undefined` for an untagged target).
192
+ */
193
+ listTargets(): {
194
+ path: string;
195
+ expects: ValueTypeId | readonly ValueTypeId[] | undefined;
196
+ }[];
185
197
  /** Subclass drawing: emit own commands (and children for containers). */
186
198
  protected abstract draw(out: DisplayListBuilder, ctx: EvalContext): void;
187
199
  /**
package/dist/nodes.js CHANGED
@@ -373,117 +373,404 @@ function createDisplayListBuilder(size) {
373
373
  };
374
374
  }
375
375
  //#endregion
376
- //#region src/text.ts
377
- /**
378
- * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
379
- * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
380
- * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
381
- * whole layout. The single source of truth for the grid; `quantize` rounds to
382
- * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
383
- */
384
- const MEASURE_QUANTUM_PX = .5;
385
- /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
386
- function quantize(v) {
387
- return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
388
- }
389
- /**
390
- * Estimating fallback measurer — used only when no backend has been injected
391
- * (e.g. evaluating for IR-level tests). Deterministic but not metrically
392
- * faithful; mount(), the CLI, and exporters always inject the real one.
393
- */
394
- let defaultMeasurer = null;
395
- /**
396
- * Process-wide fallback measurer for FACTORY-TIME measurement — component
397
- * factories run before any scene exists, so Text pulls (measuredSize,
398
- * lineBoxes, wordBoxes) and createScene fall back here before the estimator.
399
- * Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
400
- * @glissade/backend-skia gives factory code the rasterizer's real metrics.
401
- * Scene-injected measurers (mount/CLI/golden harness) always win.
402
- */
403
- function setDefaultMeasurer(m) {
404
- defaultMeasurer = m;
405
- }
406
- /** The default-or-estimating chain end; internal fallback for measurer pulls. */
407
- function fallbackMeasurer() {
408
- return defaultMeasurer ?? estimatingMeasurer;
409
- }
410
- const estimatingMeasurer = { measureText(text, font) {
411
- return {
412
- width: text.length * font.size * .52,
413
- ascent: font.size * .8,
414
- descent: font.size * .2
415
- };
416
- } };
417
- let wordSegmenter;
376
+ //#region src/sketch.ts
418
377
  /**
419
- * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
420
- * glued to its predecessor) exported so Text.wordBoxes() boxes EXACTLY the
421
- * units the breaker flows.
378
+ * Hand-drawn stroke styles via GEOMETRIC roughening — not raster textures. A
379
+ * shape's outline is flattened to polylines, then each segment is redrawn as a
380
+ * slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
381
+ * path math seeded by a stable per-shape seed, the result is byte-identical on
382
+ * both backends and re-evaluates deterministically (the seed is consumed fresh
383
+ * each draw, never as a shared stateful stream).
422
384
  */
423
- function segmentWords(text) {
424
- if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
425
- if (wordSegmenter) {
426
- const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
427
- const glued = [];
428
- for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
429
- else glued.push(seg);
430
- return glued;
385
+ const KINDS = [
386
+ "marker",
387
+ "crayon",
388
+ "pencil",
389
+ "ink",
390
+ "chalk"
391
+ ];
392
+ var SketchValidationError = class extends Error {
393
+ constructor(message) {
394
+ super(message);
395
+ this.name = "SketchValidationError";
431
396
  }
432
- return text.split(/(\s+)/).filter((w) => w.length > 0);
397
+ };
398
+ /** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
399
+ function validateSketch(s) {
400
+ if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
401
+ if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
402
+ if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
403
+ if ((s.kind === "crayon" || s.kind === "pencil") && s.passes !== void 0 && !(s.passes >= 1)) throw new SketchValidationError(`sketch passes must be ≥ 1, got ${String(s.passes)}`);
404
+ if (s.kind === "chalk" && s.dash !== void 0 && (!Array.isArray(s.dash) || s.dash.some((d) => !(d >= 0)))) throw new SketchValidationError("sketch chalk dash must be an array of non-negative numbers");
433
405
  }
434
- let graphemeSegmenter;
435
- /**
436
- * Split text into graphemes (user-perceived characters). Exported so Text.draw
437
- * (reveal masking), Text.graphemes() (authoring), and revealSchedule() (the SFX
438
- * keystroke contract) all count the SAME units.
439
- */
440
- function segmentGraphemes(text) {
441
- if (graphemeSegmenter === void 0) graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
442
- if (graphemeSegmenter) return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
443
- return Array.from(text);
406
+ /** Per-kind defaults — the character of each look. */
407
+ function resolveSketch(s) {
408
+ switch (s.kind) {
409
+ case "marker": return {
410
+ width: s.width ?? 8,
411
+ roughness: s.roughness ?? 1.2,
412
+ passes: 2
413
+ };
414
+ case "crayon": return {
415
+ width: s.width ?? 4,
416
+ roughness: s.roughness ?? 2.4,
417
+ passes: s.passes ?? 3
418
+ };
419
+ case "pencil": return {
420
+ width: s.width ?? 1.5,
421
+ roughness: s.roughness ?? 1,
422
+ passes: s.passes ?? 2
423
+ };
424
+ case "ink": return {
425
+ width: s.width ?? 2.5,
426
+ roughness: s.roughness ?? .8,
427
+ passes: 1
428
+ };
429
+ case "chalk": return {
430
+ width: s.width ?? 3,
431
+ roughness: s.roughness ?? 1.6,
432
+ passes: 1,
433
+ dash: s.dash ?? [6, 5]
434
+ };
435
+ }
444
436
  }
437
+ const cubic = (p0, c1, c2, p1, t) => {
438
+ const mt = 1 - t;
439
+ const a = mt * mt * mt;
440
+ const b = 3 * mt * mt * t;
441
+ const c = 3 * mt * t * t;
442
+ const d = t * t * t;
443
+ return [a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1]];
444
+ };
445
+ const quad = (p0, c, p1, t) => {
446
+ const mt = 1 - t;
447
+ return [mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p1[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p1[1]];
448
+ };
449
+ const ellipse = (cx, cy, rx, ry, rot, ang) => {
450
+ const ex = rx * Math.cos(ang);
451
+ const ey = ry * Math.sin(ang);
452
+ const cos = Math.cos(rot);
453
+ const sin = Math.sin(rot);
454
+ return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
455
+ };
445
456
  /**
446
- * Greedy line breaking: explicit '\n' always breaks; otherwise word segments
447
- * flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
448
- * without spaces). A segment wider than maxWidth gets its own line (no
449
- * intra-word breaking in v1).
457
+ * Flatten a path to polylines de Casteljau for C/Q, arc sampling for E
458
+ * (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
459
+ * or those shapes roughen wrong). `steps` is the samples per curved segment.
450
460
  */
451
- function breakLines(text, font, maxWidth, measurer) {
452
- const paragraphs = text.split("\n");
453
- if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
454
- const lines = [];
455
- for (const para of paragraphs) {
456
- const words = segmentWords(para);
457
- let line = "";
458
- for (const word of words) {
459
- const candidate = line + word;
460
- if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
461
- lines.push(line.trimEnd());
462
- line = word.trimStart() === "" ? "" : word;
463
- } else line = candidate;
461
+ function flatten(segs, steps = 16) {
462
+ const polys = [];
463
+ let cur = null;
464
+ let px = 0;
465
+ let py = 0;
466
+ let sx = 0;
467
+ let sy = 0;
468
+ const ensure = (x, y) => {
469
+ if (!cur) {
470
+ cur = {
471
+ points: [[x, y]],
472
+ closed: false
473
+ };
474
+ polys.push(cur);
475
+ sx = x;
476
+ sy = y;
464
477
  }
465
- lines.push(line.trimEnd());
478
+ return cur;
479
+ };
480
+ for (const s of segs) switch (s[0]) {
481
+ case "M":
482
+ cur = {
483
+ points: [[s[1], s[2]]],
484
+ closed: false
485
+ };
486
+ polys.push(cur);
487
+ px = sx = s[1];
488
+ py = sy = s[2];
489
+ break;
490
+ case "L":
491
+ ensure(px, py).points.push([s[1], s[2]]);
492
+ px = s[1];
493
+ py = s[2];
494
+ break;
495
+ case "C": {
496
+ const c = ensure(px, py);
497
+ for (let k = 1; k <= steps; k++) c.points.push(cubic([px, py], [s[1], s[2]], [s[3], s[4]], [s[5], s[6]], k / steps));
498
+ px = s[5];
499
+ py = s[6];
500
+ break;
501
+ }
502
+ case "Q": {
503
+ const c = ensure(px, py);
504
+ for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
505
+ px = s[3];
506
+ py = s[4];
507
+ break;
508
+ }
509
+ case "E": {
510
+ const [, cx, cy, rx, ry, rot, a0, a1] = s;
511
+ const begin = ellipse(cx, cy, rx, ry, rot, a0);
512
+ const c = ensure(begin[0], begin[1]);
513
+ for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
514
+ const end = ellipse(cx, cy, rx, ry, rot, a1);
515
+ px = end[0];
516
+ py = end[1];
517
+ break;
518
+ }
519
+ case "Z":
520
+ if (cur) {
521
+ cur.points.push([sx, sy]);
522
+ cur.closed = true;
523
+ px = sx;
524
+ py = sy;
525
+ }
526
+ break;
466
527
  }
467
- return lines;
528
+ return polys.filter((p) => p.points.length > 0);
529
+ }
530
+ /** Total length of a flattened polyline (for draw-on dashing). */
531
+ function arcLength(poly) {
532
+ let len = 0;
533
+ for (let i = 1; i < poly.points.length; i++) len += Math.hypot(poly.points[i][0] - poly.points[i - 1][0], poly.points[i][1] - poly.points[i - 1][1]);
534
+ return len;
535
+ }
536
+ function validateHachure(h) {
537
+ if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
538
+ if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
468
539
  }
469
- //#endregion
470
- //#region src/node.ts
471
540
  /**
472
- * Scene-graph node base (DESIGN.md §3.1): every animatable property is a
473
- * signal; transforms are computed matrix signals; emit() is pure it reads
474
- * signals and ctx only, and produces IR commands, never canvas calls.
541
+ * Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
542
+ * `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
543
+ * clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
475
544
  */
476
- const ANCHOR_PRESETS = {
477
- "center": [.5, .5],
478
- "top-left": [0, 0],
479
- "top": [.5, 0],
480
- "top-right": [1, 0],
481
- "left": [0, .5],
482
- "right": [1, .5],
483
- "bottom-left": [0, 1],
484
- "bottom": [.5, 1],
485
- "bottom-right": [1, 1]
486
- };
545
+ function hachureLines(segs, spec, rng) {
546
+ const polys = flatten(segs);
547
+ let minX = Infinity;
548
+ let minY = Infinity;
549
+ let maxX = -Infinity;
550
+ let maxY = -Infinity;
551
+ for (const p of polys) for (const [x, y] of p.points) {
552
+ if (x < minX) minX = x;
553
+ if (y < minY) minY = y;
554
+ if (x > maxX) maxX = x;
555
+ if (y > maxY) maxY = y;
556
+ }
557
+ if (!Number.isFinite(minX)) return [];
558
+ const cx = (minX + maxX) / 2;
559
+ const cy = (minY + maxY) / 2;
560
+ const ca = Math.cos(spec.angleRad);
561
+ const sa = Math.sin(spec.angleRad);
562
+ const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
563
+ const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
564
+ let rMinX = Infinity;
565
+ let rMinY = Infinity;
566
+ let rMaxX = -Infinity;
567
+ let rMaxY = -Infinity;
568
+ const corners = [
569
+ [minX, minY],
570
+ [maxX, minY],
571
+ [maxX, maxY],
572
+ [minX, maxY]
573
+ ];
574
+ for (const [x, y] of corners) {
575
+ const [rx, ry] = toRot(x, y);
576
+ if (rx < rMinX) rMinX = rx;
577
+ if (ry < rMinY) rMinY = ry;
578
+ if (rx > rMaxX) rMaxX = rx;
579
+ if (ry > rMaxY) rMaxY = ry;
580
+ }
581
+ const rough = spec.roughness ?? 1;
582
+ const jit = () => (rng() * 2 - 1) * rough;
583
+ const out = [];
584
+ for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
585
+ const a = fromRot(rMinX, y + jit());
586
+ const b = fromRot(rMaxX, y + jit());
587
+ out.push([
588
+ "M",
589
+ a[0],
590
+ a[1]
591
+ ], [
592
+ "L",
593
+ b[0],
594
+ b[1]
595
+ ]);
596
+ }
597
+ return out;
598
+ }
599
+ /**
600
+ * Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
601
+ * jittered quadratic; `passes` overlay slightly different jitters for the
602
+ * built-up look. `rng` must be a freshly seeded generator (the caller reseeds
603
+ * per draw from a stable seed, so evaluate() stays pure).
604
+ */
605
+ function roughen(segs, style, rng) {
606
+ const resolved = resolveSketch(style);
607
+ const polys = flatten(segs);
608
+ const jit = () => (rng() * 2 - 1) * resolved.roughness;
609
+ const strokes = [];
610
+ for (let pass = 0; pass < resolved.passes; pass++) {
611
+ const out = [];
612
+ for (const poly of polys) {
613
+ const pts = poly.points;
614
+ if (pts.length < 2) continue;
615
+ let ax = pts[0][0] + jit();
616
+ let ay = pts[0][1] + jit();
617
+ out.push([
618
+ "M",
619
+ ax,
620
+ ay
621
+ ]);
622
+ for (let i = 1; i < pts.length; i++) {
623
+ const bx = pts[i][0] + jit();
624
+ const by = pts[i][1] + jit();
625
+ const dx = bx - ax;
626
+ const dy = by - ay;
627
+ const len = Math.hypot(dx, dy) || 1;
628
+ const bow = jit() * .5;
629
+ const mx = (ax + bx) / 2 + -dy / len * bow;
630
+ const my = (ay + by) / 2 + dx / len * bow;
631
+ out.push([
632
+ "Q",
633
+ mx,
634
+ my,
635
+ bx,
636
+ by
637
+ ]);
638
+ ax = bx;
639
+ ay = by;
640
+ }
641
+ }
642
+ strokes.push(out);
643
+ }
644
+ return {
645
+ strokes,
646
+ resolved
647
+ };
648
+ }
649
+ /** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
650
+ function hashStr(s) {
651
+ let h = 2166136261;
652
+ for (let i = 0; i < s.length; i++) {
653
+ h ^= s.charCodeAt(i);
654
+ h = Math.imul(h, 16777619);
655
+ }
656
+ return h >>> 0;
657
+ }
658
+ /** Convenience: the rough stroke passes for a path at a given seed. */
659
+ function sketchStrokes(segs, style, seed) {
660
+ return roughen(segs, style, random(seed >>> 0)).strokes;
661
+ }
662
+ //#endregion
663
+ //#region src/text.ts
664
+ /**
665
+ * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
666
+ * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
667
+ * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
668
+ * whole layout. The single source of truth for the grid; `quantize` rounds to
669
+ * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
670
+ */
671
+ const MEASURE_QUANTUM_PX = .5;
672
+ /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
673
+ function quantize(v) {
674
+ return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
675
+ }
676
+ /**
677
+ * Estimating fallback measurer — used only when no backend has been injected
678
+ * (e.g. evaluating for IR-level tests). Deterministic but not metrically
679
+ * faithful; mount(), the CLI, and exporters always inject the real one.
680
+ */
681
+ let defaultMeasurer = null;
682
+ /**
683
+ * Process-wide fallback measurer for FACTORY-TIME measurement — component
684
+ * factories run before any scene exists, so Text pulls (measuredSize,
685
+ * lineBoxes, wordBoxes) and createScene fall back here before the estimator.
686
+ * Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
687
+ * @glissade/backend-skia gives factory code the rasterizer's real metrics.
688
+ * Scene-injected measurers (mount/CLI/golden harness) always win.
689
+ */
690
+ function setDefaultMeasurer(m) {
691
+ defaultMeasurer = m;
692
+ }
693
+ /** The default-or-estimating chain end; internal fallback for measurer pulls. */
694
+ function fallbackMeasurer() {
695
+ return defaultMeasurer ?? estimatingMeasurer;
696
+ }
697
+ const estimatingMeasurer = { measureText(text, font) {
698
+ return {
699
+ width: text.length * font.size * .52,
700
+ ascent: font.size * .8,
701
+ descent: font.size * .2
702
+ };
703
+ } };
704
+ let wordSegmenter;
705
+ /**
706
+ * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
707
+ * glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
708
+ * units the breaker flows.
709
+ */
710
+ function segmentWords(text) {
711
+ if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
712
+ if (wordSegmenter) {
713
+ const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
714
+ const glued = [];
715
+ for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
716
+ else glued.push(seg);
717
+ return glued;
718
+ }
719
+ return text.split(/(\s+)/).filter((w) => w.length > 0);
720
+ }
721
+ let graphemeSegmenter;
722
+ /**
723
+ * Split text into graphemes (user-perceived characters). Exported so Text.draw
724
+ * (reveal masking), Text.graphemes() (authoring), and revealSchedule() (the SFX
725
+ * keystroke contract) all count the SAME units.
726
+ */
727
+ function segmentGraphemes(text) {
728
+ if (graphemeSegmenter === void 0) graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
729
+ if (graphemeSegmenter) return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
730
+ return Array.from(text);
731
+ }
732
+ /**
733
+ * Greedy line breaking: explicit '\n' always breaks; otherwise word segments
734
+ * flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
735
+ * without spaces). A segment wider than maxWidth gets its own line (no
736
+ * intra-word breaking in v1).
737
+ */
738
+ function breakLines(text, font, maxWidth, measurer) {
739
+ const paragraphs = text.split("\n");
740
+ if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
741
+ const lines = [];
742
+ for (const para of paragraphs) {
743
+ const words = segmentWords(para);
744
+ let line = "";
745
+ for (const word of words) {
746
+ const candidate = line + word;
747
+ if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
748
+ lines.push(line.trimEnd());
749
+ line = word.trimStart() === "" ? "" : word;
750
+ } else line = candidate;
751
+ }
752
+ lines.push(line.trimEnd());
753
+ }
754
+ return lines;
755
+ }
756
+ //#endregion
757
+ //#region src/node.ts
758
+ /**
759
+ * Scene-graph node base (DESIGN.md §3.1): every animatable property is a
760
+ * signal; transforms are computed matrix signals; emit() is pure — it reads
761
+ * signals and ctx only, and produces IR commands, never canvas calls.
762
+ */
763
+ const ANCHOR_PRESETS = {
764
+ "center": [.5, .5],
765
+ "top-left": [0, 0],
766
+ "top": [.5, 0],
767
+ "top-right": [1, 0],
768
+ "left": [0, .5],
769
+ "right": [1, .5],
770
+ "bottom-left": [0, 1],
771
+ "bottom": [.5, 1],
772
+ "bottom-right": [1, 1]
773
+ };
487
774
  function resolveAnchor(spec) {
488
775
  if (typeof spec === "string") {
489
776
  const preset = ANCHOR_PRESETS[spec];
@@ -591,6 +878,20 @@ var Node = class {
591
878
  return this.targets.get(path);
592
879
  }
593
880
  /**
881
+ * Enumerate this node's registered track-target paths and the value type each
882
+ * accepts — the introspection seam `describe()` reads to build the API
883
+ * manifest from the REAL `registerTarget` calls (so it can't drift). Returns
884
+ * `[path, expects]` pairs in registration order; `expects` is the §2.2 type
885
+ * stamp (a `ValueTypeId`, an array for a polymorphic prop like `fill`, or
886
+ * `undefined` for an untagged target).
887
+ */
888
+ listTargets() {
889
+ return [...this.targets].map(([path, sig]) => ({
890
+ path,
891
+ expects: sig.expects
892
+ }));
893
+ }
894
+ /**
594
895
  * Natural size for flex flow (§3.2); null = not flowable (a Layout parent
595
896
  * emits such children absolutely, untouched).
596
897
  */
@@ -698,293 +999,6 @@ var Node = class {
698
999
  }
699
1000
  };
700
1001
  //#endregion
701
- //#region src/sketch.ts
702
- /**
703
- * Hand-drawn stroke styles via GEOMETRIC roughening — not raster textures. A
704
- * shape's outline is flattened to polylines, then each segment is redrawn as a
705
- * slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
706
- * path math seeded by a stable per-shape seed, the result is byte-identical on
707
- * both backends and re-evaluates deterministically (the seed is consumed fresh
708
- * each draw, never as a shared stateful stream).
709
- */
710
- const KINDS = [
711
- "marker",
712
- "crayon",
713
- "pencil",
714
- "ink",
715
- "chalk"
716
- ];
717
- var SketchValidationError = class extends Error {
718
- constructor(message) {
719
- super(message);
720
- this.name = "SketchValidationError";
721
- }
722
- };
723
- /** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
724
- function validateSketch(s) {
725
- if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
726
- if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
727
- if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
728
- if ((s.kind === "crayon" || s.kind === "pencil") && s.passes !== void 0 && !(s.passes >= 1)) throw new SketchValidationError(`sketch passes must be ≥ 1, got ${String(s.passes)}`);
729
- if (s.kind === "chalk" && s.dash !== void 0 && (!Array.isArray(s.dash) || s.dash.some((d) => !(d >= 0)))) throw new SketchValidationError("sketch chalk dash must be an array of non-negative numbers");
730
- }
731
- /** Per-kind defaults — the character of each look. */
732
- function resolveSketch(s) {
733
- switch (s.kind) {
734
- case "marker": return {
735
- width: s.width ?? 8,
736
- roughness: s.roughness ?? 1.2,
737
- passes: 2
738
- };
739
- case "crayon": return {
740
- width: s.width ?? 4,
741
- roughness: s.roughness ?? 2.4,
742
- passes: s.passes ?? 3
743
- };
744
- case "pencil": return {
745
- width: s.width ?? 1.5,
746
- roughness: s.roughness ?? 1,
747
- passes: s.passes ?? 2
748
- };
749
- case "ink": return {
750
- width: s.width ?? 2.5,
751
- roughness: s.roughness ?? .8,
752
- passes: 1
753
- };
754
- case "chalk": return {
755
- width: s.width ?? 3,
756
- roughness: s.roughness ?? 1.6,
757
- passes: 1,
758
- dash: s.dash ?? [6, 5]
759
- };
760
- }
761
- }
762
- const cubic = (p0, c1, c2, p1, t) => {
763
- const mt = 1 - t;
764
- const a = mt * mt * mt;
765
- const b = 3 * mt * mt * t;
766
- const c = 3 * mt * t * t;
767
- const d = t * t * t;
768
- return [a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1]];
769
- };
770
- const quad = (p0, c, p1, t) => {
771
- const mt = 1 - t;
772
- return [mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p1[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p1[1]];
773
- };
774
- const ellipse = (cx, cy, rx, ry, rot, ang) => {
775
- const ex = rx * Math.cos(ang);
776
- const ey = ry * Math.sin(ang);
777
- const cos = Math.cos(rot);
778
- const sin = Math.sin(rot);
779
- return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
780
- };
781
- /**
782
- * Flatten a path to polylines — de Casteljau for C/Q, arc sampling for E
783
- * (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
784
- * or those shapes roughen wrong). `steps` is the samples per curved segment.
785
- */
786
- function flatten(segs, steps = 16) {
787
- const polys = [];
788
- let cur = null;
789
- let px = 0;
790
- let py = 0;
791
- let sx = 0;
792
- let sy = 0;
793
- const ensure = (x, y) => {
794
- if (!cur) {
795
- cur = {
796
- points: [[x, y]],
797
- closed: false
798
- };
799
- polys.push(cur);
800
- sx = x;
801
- sy = y;
802
- }
803
- return cur;
804
- };
805
- for (const s of segs) switch (s[0]) {
806
- case "M":
807
- cur = {
808
- points: [[s[1], s[2]]],
809
- closed: false
810
- };
811
- polys.push(cur);
812
- px = sx = s[1];
813
- py = sy = s[2];
814
- break;
815
- case "L":
816
- ensure(px, py).points.push([s[1], s[2]]);
817
- px = s[1];
818
- py = s[2];
819
- break;
820
- case "C": {
821
- const c = ensure(px, py);
822
- for (let k = 1; k <= steps; k++) c.points.push(cubic([px, py], [s[1], s[2]], [s[3], s[4]], [s[5], s[6]], k / steps));
823
- px = s[5];
824
- py = s[6];
825
- break;
826
- }
827
- case "Q": {
828
- const c = ensure(px, py);
829
- for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
830
- px = s[3];
831
- py = s[4];
832
- break;
833
- }
834
- case "E": {
835
- const [, cx, cy, rx, ry, rot, a0, a1] = s;
836
- const begin = ellipse(cx, cy, rx, ry, rot, a0);
837
- const c = ensure(begin[0], begin[1]);
838
- for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
839
- const end = ellipse(cx, cy, rx, ry, rot, a1);
840
- px = end[0];
841
- py = end[1];
842
- break;
843
- }
844
- case "Z":
845
- if (cur) {
846
- cur.points.push([sx, sy]);
847
- cur.closed = true;
848
- px = sx;
849
- py = sy;
850
- }
851
- break;
852
- }
853
- return polys.filter((p) => p.points.length > 0);
854
- }
855
- /** Total length of a flattened polyline (for draw-on dashing). */
856
- function arcLength(poly) {
857
- let len = 0;
858
- for (let i = 1; i < poly.points.length; i++) len += Math.hypot(poly.points[i][0] - poly.points[i - 1][0], poly.points[i][1] - poly.points[i - 1][1]);
859
- return len;
860
- }
861
- function validateHachure(h) {
862
- if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
863
- if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
864
- }
865
- /**
866
- * Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
867
- * `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
868
- * clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
869
- */
870
- function hachureLines(segs, spec, rng) {
871
- const polys = flatten(segs);
872
- let minX = Infinity;
873
- let minY = Infinity;
874
- let maxX = -Infinity;
875
- let maxY = -Infinity;
876
- for (const p of polys) for (const [x, y] of p.points) {
877
- if (x < minX) minX = x;
878
- if (y < minY) minY = y;
879
- if (x > maxX) maxX = x;
880
- if (y > maxY) maxY = y;
881
- }
882
- if (!Number.isFinite(minX)) return [];
883
- const cx = (minX + maxX) / 2;
884
- const cy = (minY + maxY) / 2;
885
- const ca = Math.cos(spec.angleRad);
886
- const sa = Math.sin(spec.angleRad);
887
- const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
888
- const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
889
- let rMinX = Infinity;
890
- let rMinY = Infinity;
891
- let rMaxX = -Infinity;
892
- let rMaxY = -Infinity;
893
- const corners = [
894
- [minX, minY],
895
- [maxX, minY],
896
- [maxX, maxY],
897
- [minX, maxY]
898
- ];
899
- for (const [x, y] of corners) {
900
- const [rx, ry] = toRot(x, y);
901
- if (rx < rMinX) rMinX = rx;
902
- if (ry < rMinY) rMinY = ry;
903
- if (rx > rMaxX) rMaxX = rx;
904
- if (ry > rMaxY) rMaxY = ry;
905
- }
906
- const rough = spec.roughness ?? 1;
907
- const jit = () => (rng() * 2 - 1) * rough;
908
- const out = [];
909
- for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
910
- const a = fromRot(rMinX, y + jit());
911
- const b = fromRot(rMaxX, y + jit());
912
- out.push([
913
- "M",
914
- a[0],
915
- a[1]
916
- ], [
917
- "L",
918
- b[0],
919
- b[1]
920
- ]);
921
- }
922
- return out;
923
- }
924
- /**
925
- * Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
926
- * jittered quadratic; `passes` overlay slightly different jitters for the
927
- * built-up look. `rng` must be a freshly seeded generator (the caller reseeds
928
- * per draw from a stable seed, so evaluate() stays pure).
929
- */
930
- function roughen(segs, style, rng) {
931
- const resolved = resolveSketch(style);
932
- const polys = flatten(segs);
933
- const jit = () => (rng() * 2 - 1) * resolved.roughness;
934
- const strokes = [];
935
- for (let pass = 0; pass < resolved.passes; pass++) {
936
- const out = [];
937
- for (const poly of polys) {
938
- const pts = poly.points;
939
- if (pts.length < 2) continue;
940
- let ax = pts[0][0] + jit();
941
- let ay = pts[0][1] + jit();
942
- out.push([
943
- "M",
944
- ax,
945
- ay
946
- ]);
947
- for (let i = 1; i < pts.length; i++) {
948
- const bx = pts[i][0] + jit();
949
- const by = pts[i][1] + jit();
950
- const dx = bx - ax;
951
- const dy = by - ay;
952
- const len = Math.hypot(dx, dy) || 1;
953
- const bow = jit() * .5;
954
- const mx = (ax + bx) / 2 + -dy / len * bow;
955
- const my = (ay + by) / 2 + dx / len * bow;
956
- out.push([
957
- "Q",
958
- mx,
959
- my,
960
- bx,
961
- by
962
- ]);
963
- ax = bx;
964
- ay = by;
965
- }
966
- }
967
- strokes.push(out);
968
- }
969
- return {
970
- strokes,
971
- resolved
972
- };
973
- }
974
- /** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
975
- function hashStr(s) {
976
- let h = 2166136261;
977
- for (let i = 0; i < s.length; i++) {
978
- h ^= s.charCodeAt(i);
979
- h = Math.imul(h, 16777619);
980
- }
981
- return h >>> 0;
982
- }
983
- /** Convenience: the rough stroke passes for a path at a given seed. */
984
- function sketchStrokes(segs, style, seed) {
985
- return roughen(segs, style, random(seed >>> 0)).strokes;
986
- }
987
- //#endregion
988
1002
  //#region src/nodes.ts
989
1003
  /**
990
1004
  * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
@@ -2032,4 +2046,4 @@ function revealSchedule(text, reveal, measurer) {
2032
2046
  return marks;
2033
2047
  }
2034
2048
  //#endregion
2035
- export { segmentGraphemes as A, collapseReplacer as B, Node as C, estimatingMeasurer as D, breakLines as E, filtersToCanvasFilter as F, IDENTITY as G, formatDisplayDiff as H, glow as I, invert as J, applyToPoint as K, validateFilters as L, setDefaultMeasurer as M, FilterValidationError as N, fallbackMeasurer as O, createDisplayListBuilder as P, DL_SNAPSHOT_VERSION as R, validateSketch as S, MEASURE_QUANTUM_PX as T, parseDisplaySnapshot as U, diffDisplayLists as V, serializeDisplayList as W, multiply as X, matEquals as Y, hashStr as _, Path as a, sketchStrokes as b, Video as c, revealSchedule as d, roundedRectSegs as f, hachureLines as g, flatten as h, ImageNode as i, segmentWords as j, quantize as k, coercePathData as l, arcLength as m, Custom as n, Rect as o, SketchValidationError as p, fromTRS as q, Group as r, Text as s, Circle as t, pathFromSegs as u, resolveSketch as v, resolveAnchor as w, validateHachure as x, roughen as y, DlSnapshotError as z };
2049
+ export { sketchStrokes as A, collapseReplacer as B, SketchValidationError as C, hashStr as D, hachureLines as E, filtersToCanvasFilter as F, IDENTITY as G, formatDisplayDiff as H, glow as I, invert as J, applyToPoint as K, validateFilters as L, validateSketch as M, FilterValidationError as N, resolveSketch as O, createDisplayListBuilder as P, DL_SNAPSHOT_VERSION as R, setDefaultMeasurer as S, flatten as T, parseDisplaySnapshot as U, diffDisplayLists as V, serializeDisplayList as W, multiply as X, matEquals as Y, estimatingMeasurer as _, Path as a, segmentGraphemes as b, Video as c, revealSchedule as d, roundedRectSegs as f, breakLines as g, MEASURE_QUANTUM_PX as h, ImageNode as i, validateHachure as j, roughen as k, coercePathData as l, resolveAnchor as m, Custom as n, Rect as o, Node as p, fromTRS as q, Group as r, Text as s, Circle as t, pathFromSegs as u, fallbackMeasurer as v, arcLength as w, segmentWords as x, quantize as y, DlSnapshotError as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.18.0-pre.4",
3
+ "version": "0.18.0-pre.5",
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": {
@@ -20,6 +20,10 @@
20
20
  "./path": {
21
21
  "types": "./dist/path.d.ts",
22
22
  "default": "./dist/path.js"
23
+ },
24
+ "./describe": {
25
+ "types": "./dist/describe.d.ts",
26
+ "default": "./dist/describe.js"
23
27
  }
24
28
  },
25
29
  "files": [
@@ -27,7 +31,7 @@
27
31
  ],
28
32
  "dependencies": {
29
33
  "yoga-layout": "^3.2.1",
30
- "@glissade/core": "0.18.0-pre.4"
34
+ "@glissade/core": "0.18.0-pre.5"
31
35
  },
32
36
  "repository": {
33
37
  "type": "git",