@glissade/scene 0.31.0-pre.1 → 0.32.0-pre.0

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,106 @@
1
+ import { V as NodeProps, i as Group, u as Rect } from "./nodes.js";
2
+
3
+ //#region src/chart.d.ts
4
+
5
+ declare class ChartError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /** One data row: a plain record. `xKey`/`yKey` name the label + numeric columns. */
9
+ type DataRow = Readonly<Record<string, unknown>>;
10
+ /**
11
+ * A continuous scale: a pure, serializable map from a numeric DOMAIN onto a pixel
12
+ * (or unit) RANGE. `linearScale`/`logScale` produce these. Serializable by shape
13
+ * (`{ id, domain, range }`) so an agent/tool can round-trip it, matching the
14
+ * data-motion card's "scales are value-types" pitch.
15
+ */
16
+ interface Scale {
17
+ readonly id: 'linear' | 'log';
18
+ readonly domain: readonly [number, number];
19
+ readonly range: readonly [number, number];
20
+ /** Map a domain value onto the range (clamped-free — extrapolates linearly). */
21
+ map(v: number): number;
22
+ }
23
+ /** A categorical BAND scale: N equal bands across a range, each with a `bandwidth`. */
24
+ interface BandScale {
25
+ readonly id: 'band';
26
+ readonly count: number;
27
+ readonly range: readonly [number, number];
28
+ /** The width of one band's drawable area (after padding). */
29
+ readonly bandwidth: number;
30
+ /** The CENTER of band `i` (0-based) within the range. */
31
+ map(i: number): number;
32
+ }
33
+ /** A colour-ramp scale: a numeric domain → an interpolated hex colour STRING. */
34
+ interface ColorScale {
35
+ readonly id: 'color-ramp';
36
+ readonly stops: readonly string[];
37
+ readonly domain: readonly [number, number];
38
+ /** Map a domain value onto its interpolated colour (`#rrggbb`). */
39
+ map(v: number): string;
40
+ }
41
+ /** A linear scale mapping `domain` → `range` (the workhorse for a value axis). */
42
+ declare function linearScale(domain: readonly [number, number], range: readonly [number, number]): Scale;
43
+ /**
44
+ * A base-10 logarithmic scale. Requires a strictly-positive domain (log of a
45
+ * non-positive number is undefined) — throws otherwise, fail-loud.
46
+ */
47
+ declare function logScale(domain: readonly [number, number], range: readonly [number, number]): Scale;
48
+ /**
49
+ * A categorical band scale: `count` equal bands across `range`, separated by a
50
+ * `padding` fraction (0..1) of each step. `map(i)` is band `i`'s center;
51
+ * `bandwidth` is its drawable width.
52
+ */
53
+ declare function bandScale(count: number, range: readonly [number, number], padding?: number): BandScale;
54
+ /**
55
+ * A colour ramp over `domain` (default `[0, 1]`): at least two hex stops,
56
+ * interpolated in sRGB byte space. `map(v)` returns `#rrggbb`. Deterministic and
57
+ * pure — the same v yields the same bytes, so a fill driven by a ramp is golden-
58
+ * stable.
59
+ */
60
+ declare function colorRamp(stops: readonly string[], domain?: readonly [number, number]): ColorScale;
61
+ interface ChartSpec extends NodeProps {
62
+ /** Stable id — REQUIRED; bars bind tracks against `${id}/bars/${i}`. */
63
+ id: string;
64
+ /** The rows to plot (each a plain record). */
65
+ data: readonly DataRow[];
66
+ /** The column naming each bar's category label (used for ordering / count). */
67
+ xKey: string;
68
+ /** The column naming each bar's numeric value. */
69
+ yKey: string;
70
+ /** Total chart width (px) the bands divide. */
71
+ width: number;
72
+ /** Total chart height (px) — a full-value bar fills it. */
73
+ height: number;
74
+ /**
75
+ * The value → height scale. Defaults to `linearScale([0, max(y)], [0, height])`
76
+ * (turnkey: bars are proportional to their value, tallest fills the box).
77
+ */
78
+ yScale?: Scale;
79
+ /** Gap fraction between bars (0..1) passed to the internal band scale. Default 0.2. */
80
+ bandPadding?: number;
81
+ /**
82
+ * Bar fill: a solid colour string, or a `ColorScale` evaluated at each bar's
83
+ * VALUE (a colour ramp over the data). Default `'#4f8cff'`.
84
+ */
85
+ fill?: string | ColorScale;
86
+ }
87
+ interface ChartResult {
88
+ /** The wrapping group (center-anchored on its content box, like Grid). */
89
+ readonly node: Group;
90
+ /** The bar nodes in row order — `bars[i]` plots `data[i]`. */
91
+ readonly bars: readonly Rect[];
92
+ /** Ready-to-bind target ids in row order: `${id}/bars/${i}/${prop}`. */
93
+ targets(prop: string): string[];
94
+ }
95
+ /**
96
+ * Build a bar chart from `data` and return a `Group` of bars, each pinned to the
97
+ * axis at its base and sized to its value. Center-anchored (the group origin is
98
+ * the center of the content box), matching every other glissade node's convention.
99
+ *
100
+ * The bars are freshly constructed nodes (never cloned from the caller); their
101
+ * `height`/`fill` are plain signals, so a later track bind against
102
+ * `chart.targets(prop)` wins and drives the reveal.
103
+ */
104
+ declare function Chart(spec: ChartSpec): ChartResult;
105
+ //#endregion
106
+ export { BandScale, Chart, ChartError, ChartResult, ChartSpec, ColorScale, DataRow, Scale, bandScale, colorRamp, linearScale, logScale };
package/dist/chart.js ADDED
@@ -0,0 +1,157 @@
1
+ import { o as Rect, r as Group } from "./nodes.js";
2
+ //#region src/chart.ts
3
+ var ChartError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "ChartError";
7
+ }
8
+ };
9
+ /** A linear scale mapping `domain` → `range` (the workhorse for a value axis). */
10
+ function linearScale(domain, range) {
11
+ const [d0, d1] = domain;
12
+ const [r0, r1] = range;
13
+ const span = d1 - d0;
14
+ return {
15
+ id: "linear",
16
+ domain,
17
+ range,
18
+ map: (v) => span === 0 ? r0 : r0 + (v - d0) / span * (r1 - r0)
19
+ };
20
+ }
21
+ /**
22
+ * A base-10 logarithmic scale. Requires a strictly-positive domain (log of a
23
+ * non-positive number is undefined) — throws otherwise, fail-loud.
24
+ */
25
+ function logScale(domain, range) {
26
+ const [d0, d1] = domain;
27
+ const [r0, r1] = range;
28
+ if (d0 <= 0 || d1 <= 0) throw new ChartError(`logScale needs a strictly-positive domain (got [${d0}, ${d1}])`);
29
+ const l0 = Math.log10(d0);
30
+ const lspan = Math.log10(d1) - l0;
31
+ return {
32
+ id: "log",
33
+ domain,
34
+ range,
35
+ map: (v) => {
36
+ if (v <= 0) throw new ChartError(`logScale cannot map a non-positive value (${v})`);
37
+ return lspan === 0 ? r0 : r0 + (Math.log10(v) - l0) / lspan * (r1 - r0);
38
+ }
39
+ };
40
+ }
41
+ /**
42
+ * A categorical band scale: `count` equal bands across `range`, separated by a
43
+ * `padding` fraction (0..1) of each step. `map(i)` is band `i`'s center;
44
+ * `bandwidth` is its drawable width.
45
+ */
46
+ function bandScale(count, range, padding = .2) {
47
+ if (!Number.isInteger(count) || count < 1) throw new ChartError(`bandScale needs a positive integer count (got ${count})`);
48
+ if (padding < 0 || padding >= 1) throw new ChartError(`bandScale padding must be in [0, 1) (got ${padding})`);
49
+ const [r0, r1] = range;
50
+ const step = (r1 - r0) / count;
51
+ return {
52
+ id: "band",
53
+ count,
54
+ range,
55
+ bandwidth: step * (1 - padding),
56
+ map: (i) => r0 + step * i + step / 2
57
+ };
58
+ }
59
+ /** Parse a `#rgb`/`#rrggbb` hex string to `[r, g, b]` bytes; throws on a bad format. */
60
+ function parseHex(hex) {
61
+ const h = hex.trim().replace(/^#/, "");
62
+ const full = h.length === 3 ? h.replace(/(.)/g, "$1$1") : h;
63
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) throw new ChartError(`colorRamp stop '${hex}' is not a #rgb/#rrggbb hex colour`);
64
+ return [
65
+ parseInt(full.slice(0, 2), 16),
66
+ parseInt(full.slice(2, 4), 16),
67
+ parseInt(full.slice(4, 6), 16)
68
+ ];
69
+ }
70
+ function toHex(n) {
71
+ return Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0");
72
+ }
73
+ /**
74
+ * A colour ramp over `domain` (default `[0, 1]`): at least two hex stops,
75
+ * interpolated in sRGB byte space. `map(v)` returns `#rrggbb`. Deterministic and
76
+ * pure — the same v yields the same bytes, so a fill driven by a ramp is golden-
77
+ * stable.
78
+ */
79
+ function colorRamp(stops, domain = [0, 1]) {
80
+ if (stops.length < 2) throw new ChartError("colorRamp needs at least two colour stops");
81
+ const rgb = stops.map(parseHex);
82
+ const [d0, d1] = domain;
83
+ const span = d1 - d0;
84
+ return {
85
+ id: "color-ramp",
86
+ stops,
87
+ domain,
88
+ map: (v) => {
89
+ const seg = (span === 0 ? 0 : Math.max(0, Math.min(1, (v - d0) / span))) * (stops.length - 1);
90
+ const i = Math.min(stops.length - 2, Math.floor(seg));
91
+ const f = seg - i;
92
+ const a = rgb[i];
93
+ const b = rgb[i + 1];
94
+ return `#${toHex(a[0] + (b[0] - a[0]) * f)}${toHex(a[1] + (b[1] - a[1]) * f)}${toHex(a[2] + (b[2] - a[2]) * f)}`;
95
+ }
96
+ };
97
+ }
98
+ /** Coerce a row's numeric column, failing loud on a non-finite value. */
99
+ function numAt(row, key, i) {
100
+ const raw = row[key];
101
+ const n = typeof raw === "number" ? raw : Number(raw);
102
+ if (!Number.isFinite(n)) throw new ChartError(`Chart row ${i}: ${key}=${JSON.stringify(raw)} is not a finite number`);
103
+ return n;
104
+ }
105
+ /**
106
+ * Build a bar chart from `data` and return a `Group` of bars, each pinned to the
107
+ * axis at its base and sized to its value. Center-anchored (the group origin is
108
+ * the center of the content box), matching every other glissade node's convention.
109
+ *
110
+ * The bars are freshly constructed nodes (never cloned from the caller); their
111
+ * `height`/`fill` are plain signals, so a later track bind against
112
+ * `chart.targets(prop)` wins and drives the reveal.
113
+ */
114
+ function Chart(spec) {
115
+ const { id, data, xKey, yKey, width, height, bandPadding = .2, fill = "#4f8cff" } = spec;
116
+ if (data.length === 0) throw new ChartError("Chart needs at least one data row");
117
+ if (!(width > 0) || !(height > 0)) throw new ChartError(`Chart needs positive width/height (got ${width}×${height})`);
118
+ const values = data.map((row, i) => numAt(row, yKey, i));
119
+ data.forEach((row, i) => {
120
+ if (row[xKey] === void 0) throw new ChartError(`Chart row ${i}: missing xKey '${xKey}'`);
121
+ });
122
+ const yMax = Math.max(...values, 0);
123
+ const yScale = spec.yScale ?? linearScale([0, yMax === 0 ? 1 : yMax], [0, height]);
124
+ const bands = bandScale(data.length, [0, width], bandPadding);
125
+ const ox = -width / 2;
126
+ const baseline = height / 2;
127
+ const bars = data.map((row, i) => {
128
+ const h = yScale.map(values[i]);
129
+ const barFill = typeof fill === "string" ? fill : fill.map(values[i]);
130
+ return new Rect({
131
+ id: `${id}/bars/${i}`,
132
+ anchor: "bottom",
133
+ position: [ox + bands.map(i), baseline],
134
+ width: bands.bandwidth,
135
+ height: h,
136
+ fill: barFill
137
+ });
138
+ });
139
+ const node = new Group({
140
+ id,
141
+ children: bars,
142
+ ...stripChartOnly(spec)
143
+ });
144
+ const targets = (prop) => bars.map((_, i) => `${id}/bars/${i}/${prop}`);
145
+ return {
146
+ node,
147
+ bars,
148
+ targets
149
+ };
150
+ }
151
+ /** Strip Chart's own props so the rest (position/opacity/anchor/…) pass to the Group. */
152
+ function stripChartOnly(spec) {
153
+ const { id, data, xKey, yKey, width, height, yScale, bandPadding, fill, children, ...nodeProps } = spec;
154
+ return nodeProps;
155
+ }
156
+ //#endregion
157
+ export { Chart, ChartError, bandScale, colorRamp, linearScale, logScale };
package/dist/describe.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as NODE_CONSTRUCTION_PROP_NAMES, a as Path, c as Video, g as BASE_CONSTRUCTION_PROP_NAMES, i as ImageNode, o as Rect, r as Group, s as Text, t as Circle } from "./nodes.js";
1
+ import { D as NODE_CONSTRUCTION_PROP_NAMES, E as BASE_CONSTRUCTION_PROP_NAMES, 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
2
  import { easings, listValueTypes } from "@glissade/core";
3
3
  //#region src/describe.ts
4
4
  /**
@@ -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.31.0-pre.1";
25
+ const RAW_VERSION = "0.32.0-pre.0";
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) {
@@ -393,6 +393,36 @@ const HELPERS = [
393
393
  import: "@glissade/scene/grid",
394
394
  usage: "Grid({ columns: number | (number | { fr })[], children: Node[], gap?, columnGap?, rowGap?, cellHeight?, width? }): Group — child[i] → row floor(i/cols), col i%cols"
395
395
  },
396
+ {
397
+ name: "Chart",
398
+ summary: "Build-time bar chart: bind a table (rows) → positioned+sized Rect bars, each pinned to the axis and grown from its base, returning a Group. Pure fan-out (like Grid) — animate a reveal with tl.stagger(chart.targets(\"height\"), …) or a colour sweep on \"fill\". Tree-shaken off the base scene index.",
399
+ import: "@glissade/scene/chart",
400
+ usage: "Chart({ id, data: Row[], xKey, yKey, width, height, yScale?, bandPadding?, fill?: string | ColorScale }): { node: Group, bars: Rect[], targets(prop): string[] }"
401
+ },
402
+ {
403
+ name: "linearScale",
404
+ summary: "A serializable linear scale (value axis): maps a numeric domain onto a pixel/unit range. Pair with Chart({ yScale }). On the @glissade/scene/chart subpath.",
405
+ import: "@glissade/scene/chart",
406
+ usage: "linearScale(domain: [number, number], range: [number, number]): Scale"
407
+ },
408
+ {
409
+ name: "logScale",
410
+ summary: "A serializable base-10 log scale (strictly-positive domain; throws otherwise) for a value axis. Pair with Chart({ yScale }). On the @glissade/scene/chart subpath.",
411
+ import: "@glissade/scene/chart",
412
+ usage: "logScale(domain: [number, number], range: [number, number]): Scale"
413
+ },
414
+ {
415
+ name: "bandScale",
416
+ summary: "A categorical band scale: N equal bands across a range with a padding gap, each with a bandwidth. Chart uses this internally for the x axis; exposed for custom layouts. On the @glissade/scene/chart subpath.",
417
+ import: "@glissade/scene/chart",
418
+ usage: "bandScale(count: number, range: [number, number], padding?: number): BandScale"
419
+ },
420
+ {
421
+ name: "colorRamp",
422
+ summary: "A serializable colour ramp (>=2 hex stops, sRGB-interpolated) over a numeric domain → a #rrggbb string. Pass as Chart({ fill }) to colour bars by value. On the @glissade/scene/chart subpath.",
423
+ import: "@glissade/scene/chart",
424
+ usage: "colorRamp(stops: string[], domain?: [number, number]): ColorScale"
425
+ },
396
426
  {
397
427
  name: "Stack",
398
428
  summary: "Yoga-flexbox layout factory (column by default) — a Layout subclass that stacks children with gap/padding/justify/align. Needs loadYogaLayoutEngine() before mount/render. On the @glissade/scene/layout subpath.",
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
- import { t as collapseReplacer } from "./collapseReplacer.js";
3
2
  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";
3
+ import { t as collapseReplacer } from "./collapseReplacer.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";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { $ as multiply, A as __resetEstimateWarnings, B as setDefaultMeasurer, C as Node, F as isEstimatingMeasurer, G as glow, H as FilterValidationError, I as measureWrappedText, J as IDENTITY, K as validateFilters, L as quantize, M as breakLines, N as estimatingMeasurer, Q as matEquals, R as segmentGraphemes, S as validateSketch, T as resolveAnchor, U as createDisplayListBuilder, W as filtersToCanvasFilter, X as fromTRS, Y as applyToPoint, Z as invert, _ 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 assertFiniteFontSize, k as MEASURE_QUANTUM_PX, l as coercePathData, m as arcLength, n as Custom, o as Rect, p as SketchValidationError, q as collapseReplacer, r as Group, s as Text, t as Circle, u as pathFromSegs, v as resolveSketch, w as NodeConstructionError, x as validateHachure, y as roughen, z as segmentWords } 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 { i as echo, n as motionBlur, r as Echo, t as MotionBlur } from "./motionBlur.js";
@@ -1,4 +1,4 @@
1
- import { r as Group, w as fallbackMeasurer } from "./nodes.js";
1
+ import { P as fallbackMeasurer, r as Group } 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/nodes.js CHANGED
@@ -252,388 +252,101 @@ function createDisplayListBuilder(size) {
252
252
  };
253
253
  }
254
254
  //#endregion
255
- //#region src/sketch.ts
255
+ //#region src/text.ts
256
256
  /**
257
- * Hand-drawn stroke styles via GEOMETRIC roughening not raster textures. A
258
- * shape's outline is flattened to polylines, then each segment is redrawn as a
259
- * slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
260
- * path math seeded by a stable per-shape seed, the result is byte-identical on
261
- * both backends and re-evaluates deterministically (the seed is consumed fresh
262
- * each draw, never as a shared stateful stream).
257
+ * Text measurement + line breaking (DESIGN.md §3.6). Shaping is delegated to
258
+ * the canvas implementation; line breaking is OURS, driven by the injected
259
+ * TextMeasurer so the breaker always measures with the rasterizer that will
260
+ * draw. Layout-feeding measurements are quantized (0.5px) so sub-pixel
261
+ * advance drift between Skia/HarfBuzz versions cannot move whole layouts.
263
262
  */
264
- const KINDS = [
265
- "marker",
266
- "crayon",
267
- "pencil",
268
- "ink",
269
- "chalk"
270
- ];
271
- var SketchValidationError = class extends Error {
272
- constructor(message) {
273
- super(message);
274
- this.name = "SketchValidationError";
275
- }
276
- };
277
- /** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
278
- function validateSketch(s) {
279
- if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
280
- if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
281
- if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
282
- 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)}`);
283
- 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");
263
+ /**
264
+ * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
265
+ * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
266
+ * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
267
+ * whole layout. The single source of truth for the grid; `quantize` rounds to
268
+ * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
269
+ */
270
+ const MEASURE_QUANTUM_PX = .5;
271
+ /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
272
+ function quantize(v) {
273
+ return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
284
274
  }
285
- /** Per-kind defaults — the character of each look. */
286
- function resolveSketch(s) {
287
- switch (s.kind) {
288
- case "marker": return {
289
- width: s.width ?? 8,
290
- roughness: s.roughness ?? 1.2,
291
- passes: 2
292
- };
293
- case "crayon": return {
294
- width: s.width ?? 4,
295
- roughness: s.roughness ?? 2.4,
296
- passes: s.passes ?? 3
297
- };
298
- case "pencil": return {
299
- width: s.width ?? 1.5,
300
- roughness: s.roughness ?? 1,
301
- passes: s.passes ?? 2
302
- };
303
- case "ink": return {
304
- width: s.width ?? 2.5,
305
- roughness: s.roughness ?? .8,
306
- passes: 1
307
- };
308
- case "chalk": return {
309
- width: s.width ?? 3,
310
- roughness: s.roughness ?? 1.6,
311
- passes: 1,
312
- dash: s.dash ?? [6, 5]
313
- };
314
- }
275
+ /**
276
+ * FAIL LOUD on a non-measurable FontSpec (§0.24 fail-loud sweep). A `size` that
277
+ * isn't a finite positive number silently yields NaN/0 metrics in the estimating
278
+ * measurers (and a wrong-font fallback in the real backends) → zero-height layout
279
+ * boxes, broken wrapping/reveal, all with NO error — the silent-wrong-result class
280
+ * an agent can't glance-test. The common cause is the field name: the FontSpec
281
+ * field is `size`, NOT `fontSize` (that is the Text node prop). The single guard
282
+ * every measurement entry point (breakLines, measureWrappedText, the backend
283
+ * `measureText`s) routes through, so the contract is enforced uniformly.
284
+ */
285
+ function assertFiniteFontSize(font, where) {
286
+ 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 }\`.`);
315
287
  }
316
- const cubic = (p0, c1, c2, p1, t) => {
317
- const mt = 1 - t;
318
- const a = mt * mt * mt;
319
- const b = 3 * mt * mt * t;
320
- const c = 3 * mt * t * t;
321
- const d = t * t * t;
322
- 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]];
323
- };
324
- const quad = (p0, c, p1, t) => {
325
- const mt = 1 - t;
326
- 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]];
327
- };
328
- const ellipse = (cx, cy, rx, ry, rot, ang) => {
329
- const ex = rx * Math.cos(ang);
330
- const ey = ry * Math.sin(ang);
331
- const cos = Math.cos(rot);
332
- const sin = Math.sin(rot);
333
- return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
334
- };
335
288
  /**
336
- * Flatten a path to polylines de Casteljau for C/Q, arc sampling for E
337
- * (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
338
- * or those shapes roughen wrong). `steps` is the samples per curved segment.
289
+ * Estimating fallback measurerused only when no backend has been injected
290
+ * (e.g. evaluating for IR-level tests). Deterministic but not metrically
291
+ * faithful; mount(), the CLI, and exporters always inject the real one.
339
292
  */
340
- function flatten(segs, steps = 16) {
341
- const polys = [];
342
- let cur = null;
343
- let px = 0;
344
- let py = 0;
345
- let sx = 0;
346
- let sy = 0;
347
- const ensure = (x, y) => {
348
- if (!cur) {
349
- cur = {
350
- points: [[x, y]],
351
- closed: false
352
- };
353
- polys.push(cur);
354
- sx = x;
355
- sy = y;
356
- }
357
- return cur;
293
+ let defaultMeasurer = null;
294
+ /**
295
+ * Process-wide fallback measurer for FACTORY-TIME measurement — component
296
+ * factories run before any scene exists, so Text pulls (measuredSize,
297
+ * lineBoxes, wordBoxes) and createScene fall back here before the estimator.
298
+ * Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
299
+ * @glissade/backend-skia gives factory code the rasterizer's real metrics.
300
+ * Scene-injected measurers (mount/CLI/golden harness) always win.
301
+ */
302
+ function setDefaultMeasurer(m) {
303
+ defaultMeasurer = m;
304
+ }
305
+ /** The default-or-estimating chain end; internal fallback for measurer pulls. */
306
+ function fallbackMeasurer() {
307
+ return defaultMeasurer ?? estimatingMeasurer;
308
+ }
309
+ const estimatingMeasurer = { measureText(text, font) {
310
+ return {
311
+ width: text.length * font.size * .52,
312
+ ascent: font.size * .8,
313
+ descent: font.size * .2
358
314
  };
359
- for (const s of segs) switch (s[0]) {
360
- case "M":
361
- cur = {
362
- points: [[s[1], s[2]]],
363
- closed: false
364
- };
365
- polys.push(cur);
366
- px = sx = s[1];
367
- py = sy = s[2];
368
- break;
369
- case "L":
370
- ensure(px, py).points.push([s[1], s[2]]);
371
- px = s[1];
372
- py = s[2];
373
- break;
374
- case "C": {
375
- const c = ensure(px, py);
376
- 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));
377
- px = s[5];
378
- py = s[6];
379
- break;
380
- }
381
- case "Q": {
382
- const c = ensure(px, py);
383
- for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
384
- px = s[3];
385
- py = s[4];
386
- break;
387
- }
388
- case "E": {
389
- const [, cx, cy, rx, ry, rot, a0, a1] = s;
390
- const begin = ellipse(cx, cy, rx, ry, rot, a0);
391
- const c = ensure(begin[0], begin[1]);
392
- for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
393
- const end = ellipse(cx, cy, rx, ry, rot, a1);
394
- px = end[0];
395
- py = end[1];
396
- break;
397
- }
398
- case "Z":
399
- if (cur) {
400
- cur.points.push([sx, sy]);
401
- cur.closed = true;
402
- px = sx;
403
- py = sy;
404
- }
405
- break;
406
- }
407
- return polys.filter((p) => p.points.length > 0);
315
+ } };
316
+ /**
317
+ * True when `m` is the per-character ESTIMATING fallback (the module singleton)
318
+ * i.e. no real backend measurer and no registered `defaultMeasurer` was
319
+ * available. Identity-compare so a real backend or a `setDefaultMeasurer`-
320
+ * registered measurer never trips it.
321
+ */
322
+ function isEstimatingMeasurer(m) {
323
+ return m === estimatingMeasurer;
408
324
  }
409
- /** Total length of a flattened polyline (for draw-on dashing). */
410
- function arcLength(poly) {
411
- let len = 0;
412
- 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]);
413
- return len;
325
+ const warnedEstimate = /* @__PURE__ */ new Set();
326
+ /**
327
+ * One-shot dev-warning when a build-time geometry getter resolved its measurer
328
+ * to the rough per-character estimate (no backend, no `setDefaultMeasurer`).
329
+ * `site` keys the de-dupe so each distinct caller warns at most once. Silent
330
+ * when a real measurer is in play — the estimate is the only footgun here.
331
+ */
332
+ function warnIfEstimating(m, site) {
333
+ if (!isEstimatingMeasurer(m)) return;
334
+ if (warnedEstimate.has(site)) return;
335
+ warnedEstimate.add(site);
336
+ emitDevWarning(`${site}: no text measurer available — using a rough per-character estimate; pass { measurer } or call after setTextMeasurer()/setDefaultMeasurer() for exact layout.`);
414
337
  }
415
- function validateHachure(h) {
416
- if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
417
- if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
338
+ /**
339
+ * Test-only: clear the one-shot de-dupe so the estimate warning can re-assert.
340
+ * @internal
341
+ */
342
+ function __resetEstimateWarnings() {
343
+ warnedEstimate.clear();
418
344
  }
345
+ let wordSegmenter;
419
346
  /**
420
- * Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
421
- * `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
422
- * clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
423
- */
424
- function hachureLines(segs, spec, rng) {
425
- const polys = flatten(segs);
426
- let minX = Infinity;
427
- let minY = Infinity;
428
- let maxX = -Infinity;
429
- let maxY = -Infinity;
430
- for (const p of polys) for (const [x, y] of p.points) {
431
- if (x < minX) minX = x;
432
- if (y < minY) minY = y;
433
- if (x > maxX) maxX = x;
434
- if (y > maxY) maxY = y;
435
- }
436
- if (!Number.isFinite(minX)) return [];
437
- const cx = (minX + maxX) / 2;
438
- const cy = (minY + maxY) / 2;
439
- const ca = Math.cos(spec.angleRad);
440
- const sa = Math.sin(spec.angleRad);
441
- const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
442
- const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
443
- let rMinX = Infinity;
444
- let rMinY = Infinity;
445
- let rMaxX = -Infinity;
446
- let rMaxY = -Infinity;
447
- const corners = [
448
- [minX, minY],
449
- [maxX, minY],
450
- [maxX, maxY],
451
- [minX, maxY]
452
- ];
453
- for (const [x, y] of corners) {
454
- const [rx, ry] = toRot(x, y);
455
- if (rx < rMinX) rMinX = rx;
456
- if (ry < rMinY) rMinY = ry;
457
- if (rx > rMaxX) rMaxX = rx;
458
- if (ry > rMaxY) rMaxY = ry;
459
- }
460
- const rough = spec.roughness ?? 1;
461
- const jit = () => (rng() * 2 - 1) * rough;
462
- const out = [];
463
- for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
464
- const a = fromRot(rMinX, y + jit());
465
- const b = fromRot(rMaxX, y + jit());
466
- out.push([
467
- "M",
468
- a[0],
469
- a[1]
470
- ], [
471
- "L",
472
- b[0],
473
- b[1]
474
- ]);
475
- }
476
- return out;
477
- }
478
- /**
479
- * Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
480
- * jittered quadratic; `passes` overlay slightly different jitters for the
481
- * built-up look. `rng` must be a freshly seeded generator (the caller reseeds
482
- * per draw from a stable seed, so evaluate() stays pure).
483
- */
484
- function roughen(segs, style, rng) {
485
- const resolved = resolveSketch(style);
486
- const polys = flatten(segs);
487
- const jit = () => (rng() * 2 - 1) * resolved.roughness;
488
- const strokes = [];
489
- for (let pass = 0; pass < resolved.passes; pass++) {
490
- const out = [];
491
- for (const poly of polys) {
492
- const pts = poly.points;
493
- if (pts.length < 2) continue;
494
- let ax = pts[0][0] + jit();
495
- let ay = pts[0][1] + jit();
496
- out.push([
497
- "M",
498
- ax,
499
- ay
500
- ]);
501
- for (let i = 1; i < pts.length; i++) {
502
- const bx = pts[i][0] + jit();
503
- const by = pts[i][1] + jit();
504
- const dx = bx - ax;
505
- const dy = by - ay;
506
- const len = Math.hypot(dx, dy) || 1;
507
- const bow = jit() * .5;
508
- const mx = (ax + bx) / 2 + -dy / len * bow;
509
- const my = (ay + by) / 2 + dx / len * bow;
510
- out.push([
511
- "Q",
512
- mx,
513
- my,
514
- bx,
515
- by
516
- ]);
517
- ax = bx;
518
- ay = by;
519
- }
520
- }
521
- strokes.push(out);
522
- }
523
- return {
524
- strokes,
525
- resolved
526
- };
527
- }
528
- /** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
529
- function hashStr(s) {
530
- let h = 2166136261;
531
- for (let i = 0; i < s.length; i++) {
532
- h ^= s.charCodeAt(i);
533
- h = Math.imul(h, 16777619);
534
- }
535
- return h >>> 0;
536
- }
537
- /** Convenience: the rough stroke passes for a path at a given seed. */
538
- function sketchStrokes(segs, style, seed) {
539
- return roughen(segs, style, random(seed >>> 0)).strokes;
540
- }
541
- //#endregion
542
- //#region src/text.ts
543
- /**
544
- * Text measurement + line breaking (DESIGN.md §3.6). Shaping is delegated to
545
- * the canvas implementation; line breaking is OURS, driven by the injected
546
- * TextMeasurer so the breaker always measures with the rasterizer that will
547
- * draw. Layout-feeding measurements are quantized (0.5px) so sub-pixel
548
- * advance drift between Skia/HarfBuzz versions cannot move whole layouts.
549
- */
550
- /**
551
- * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
552
- * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
553
- * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
554
- * whole layout. The single source of truth for the grid; `quantize` rounds to
555
- * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
556
- */
557
- const MEASURE_QUANTUM_PX = .5;
558
- /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
559
- function quantize(v) {
560
- return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
561
- }
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
- /**
576
- * Estimating fallback measurer — used only when no backend has been injected
577
- * (e.g. evaluating for IR-level tests). Deterministic but not metrically
578
- * faithful; mount(), the CLI, and exporters always inject the real one.
579
- */
580
- let defaultMeasurer = null;
581
- /**
582
- * Process-wide fallback measurer for FACTORY-TIME measurement — component
583
- * factories run before any scene exists, so Text pulls (measuredSize,
584
- * lineBoxes, wordBoxes) and createScene fall back here before the estimator.
585
- * Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
586
- * @glissade/backend-skia gives factory code the rasterizer's real metrics.
587
- * Scene-injected measurers (mount/CLI/golden harness) always win.
588
- */
589
- function setDefaultMeasurer(m) {
590
- defaultMeasurer = m;
591
- }
592
- /** The default-or-estimating chain end; internal fallback for measurer pulls. */
593
- function fallbackMeasurer() {
594
- return defaultMeasurer ?? estimatingMeasurer;
595
- }
596
- const estimatingMeasurer = { measureText(text, font) {
597
- return {
598
- width: text.length * font.size * .52,
599
- ascent: font.size * .8,
600
- descent: font.size * .2
601
- };
602
- } };
603
- /**
604
- * True when `m` is the per-character ESTIMATING fallback (the module singleton)
605
- * — i.e. no real backend measurer and no registered `defaultMeasurer` was
606
- * available. Identity-compare so a real backend or a `setDefaultMeasurer`-
607
- * registered measurer never trips it.
608
- */
609
- function isEstimatingMeasurer(m) {
610
- return m === estimatingMeasurer;
611
- }
612
- const warnedEstimate = /* @__PURE__ */ new Set();
613
- /**
614
- * One-shot dev-warning when a build-time geometry getter resolved its measurer
615
- * to the rough per-character estimate (no backend, no `setDefaultMeasurer`).
616
- * `site` keys the de-dupe so each distinct caller warns at most once. Silent
617
- * when a real measurer is in play — the estimate is the only footgun here.
618
- */
619
- function warnIfEstimating(m, site) {
620
- if (!isEstimatingMeasurer(m)) return;
621
- if (warnedEstimate.has(site)) return;
622
- warnedEstimate.add(site);
623
- emitDevWarning(`${site}: no text measurer available — using a rough per-character estimate; pass { measurer } or call after setTextMeasurer()/setDefaultMeasurer() for exact layout.`);
624
- }
625
- /**
626
- * Test-only: clear the one-shot de-dupe so the estimate warning can re-assert.
627
- * @internal
628
- */
629
- function __resetEstimateWarnings() {
630
- warnedEstimate.clear();
631
- }
632
- let wordSegmenter;
633
- /**
634
- * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
635
- * glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
636
- * units the breaker flows.
347
+ * The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
348
+ * glued to its predecessor) exported so Text.wordBoxes() boxes EXACTLY the
349
+ * units the breaker flows.
637
350
  */
638
351
  function segmentWords(text) {
639
352
  if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
@@ -1128,6 +841,293 @@ var Node = class {
1128
841
  }
1129
842
  };
1130
843
  //#endregion
844
+ //#region src/sketch.ts
845
+ /**
846
+ * Hand-drawn stroke styles via GEOMETRIC roughening — not raster textures. A
847
+ * shape's outline is flattened to polylines, then each segment is redrawn as a
848
+ * slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
849
+ * path math seeded by a stable per-shape seed, the result is byte-identical on
850
+ * both backends and re-evaluates deterministically (the seed is consumed fresh
851
+ * each draw, never as a shared stateful stream).
852
+ */
853
+ const KINDS = [
854
+ "marker",
855
+ "crayon",
856
+ "pencil",
857
+ "ink",
858
+ "chalk"
859
+ ];
860
+ var SketchValidationError = class extends Error {
861
+ constructor(message) {
862
+ super(message);
863
+ this.name = "SketchValidationError";
864
+ }
865
+ };
866
+ /** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
867
+ function validateSketch(s) {
868
+ if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
869
+ if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
870
+ if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
871
+ 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)}`);
872
+ 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");
873
+ }
874
+ /** Per-kind defaults — the character of each look. */
875
+ function resolveSketch(s) {
876
+ switch (s.kind) {
877
+ case "marker": return {
878
+ width: s.width ?? 8,
879
+ roughness: s.roughness ?? 1.2,
880
+ passes: 2
881
+ };
882
+ case "crayon": return {
883
+ width: s.width ?? 4,
884
+ roughness: s.roughness ?? 2.4,
885
+ passes: s.passes ?? 3
886
+ };
887
+ case "pencil": return {
888
+ width: s.width ?? 1.5,
889
+ roughness: s.roughness ?? 1,
890
+ passes: s.passes ?? 2
891
+ };
892
+ case "ink": return {
893
+ width: s.width ?? 2.5,
894
+ roughness: s.roughness ?? .8,
895
+ passes: 1
896
+ };
897
+ case "chalk": return {
898
+ width: s.width ?? 3,
899
+ roughness: s.roughness ?? 1.6,
900
+ passes: 1,
901
+ dash: s.dash ?? [6, 5]
902
+ };
903
+ }
904
+ }
905
+ const cubic = (p0, c1, c2, p1, t) => {
906
+ const mt = 1 - t;
907
+ const a = mt * mt * mt;
908
+ const b = 3 * mt * mt * t;
909
+ const c = 3 * mt * t * t;
910
+ const d = t * t * t;
911
+ 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]];
912
+ };
913
+ const quad = (p0, c, p1, t) => {
914
+ const mt = 1 - t;
915
+ 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]];
916
+ };
917
+ const ellipse = (cx, cy, rx, ry, rot, ang) => {
918
+ const ex = rx * Math.cos(ang);
919
+ const ey = ry * Math.sin(ang);
920
+ const cos = Math.cos(rot);
921
+ const sin = Math.sin(rot);
922
+ return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
923
+ };
924
+ /**
925
+ * Flatten a path to polylines — de Casteljau for C/Q, arc sampling for E
926
+ * (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
927
+ * or those shapes roughen wrong). `steps` is the samples per curved segment.
928
+ */
929
+ function flatten(segs, steps = 16) {
930
+ const polys = [];
931
+ let cur = null;
932
+ let px = 0;
933
+ let py = 0;
934
+ let sx = 0;
935
+ let sy = 0;
936
+ const ensure = (x, y) => {
937
+ if (!cur) {
938
+ cur = {
939
+ points: [[x, y]],
940
+ closed: false
941
+ };
942
+ polys.push(cur);
943
+ sx = x;
944
+ sy = y;
945
+ }
946
+ return cur;
947
+ };
948
+ for (const s of segs) switch (s[0]) {
949
+ case "M":
950
+ cur = {
951
+ points: [[s[1], s[2]]],
952
+ closed: false
953
+ };
954
+ polys.push(cur);
955
+ px = sx = s[1];
956
+ py = sy = s[2];
957
+ break;
958
+ case "L":
959
+ ensure(px, py).points.push([s[1], s[2]]);
960
+ px = s[1];
961
+ py = s[2];
962
+ break;
963
+ case "C": {
964
+ const c = ensure(px, py);
965
+ 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));
966
+ px = s[5];
967
+ py = s[6];
968
+ break;
969
+ }
970
+ case "Q": {
971
+ const c = ensure(px, py);
972
+ for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
973
+ px = s[3];
974
+ py = s[4];
975
+ break;
976
+ }
977
+ case "E": {
978
+ const [, cx, cy, rx, ry, rot, a0, a1] = s;
979
+ const begin = ellipse(cx, cy, rx, ry, rot, a0);
980
+ const c = ensure(begin[0], begin[1]);
981
+ for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
982
+ const end = ellipse(cx, cy, rx, ry, rot, a1);
983
+ px = end[0];
984
+ py = end[1];
985
+ break;
986
+ }
987
+ case "Z":
988
+ if (cur) {
989
+ cur.points.push([sx, sy]);
990
+ cur.closed = true;
991
+ px = sx;
992
+ py = sy;
993
+ }
994
+ break;
995
+ }
996
+ return polys.filter((p) => p.points.length > 0);
997
+ }
998
+ /** Total length of a flattened polyline (for draw-on dashing). */
999
+ function arcLength(poly) {
1000
+ let len = 0;
1001
+ 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]);
1002
+ return len;
1003
+ }
1004
+ function validateHachure(h) {
1005
+ if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
1006
+ if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
1007
+ }
1008
+ /**
1009
+ * Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
1010
+ * `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
1011
+ * clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
1012
+ */
1013
+ function hachureLines(segs, spec, rng) {
1014
+ const polys = flatten(segs);
1015
+ let minX = Infinity;
1016
+ let minY = Infinity;
1017
+ let maxX = -Infinity;
1018
+ let maxY = -Infinity;
1019
+ for (const p of polys) for (const [x, y] of p.points) {
1020
+ if (x < minX) minX = x;
1021
+ if (y < minY) minY = y;
1022
+ if (x > maxX) maxX = x;
1023
+ if (y > maxY) maxY = y;
1024
+ }
1025
+ if (!Number.isFinite(minX)) return [];
1026
+ const cx = (minX + maxX) / 2;
1027
+ const cy = (minY + maxY) / 2;
1028
+ const ca = Math.cos(spec.angleRad);
1029
+ const sa = Math.sin(spec.angleRad);
1030
+ const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
1031
+ const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
1032
+ let rMinX = Infinity;
1033
+ let rMinY = Infinity;
1034
+ let rMaxX = -Infinity;
1035
+ let rMaxY = -Infinity;
1036
+ const corners = [
1037
+ [minX, minY],
1038
+ [maxX, minY],
1039
+ [maxX, maxY],
1040
+ [minX, maxY]
1041
+ ];
1042
+ for (const [x, y] of corners) {
1043
+ const [rx, ry] = toRot(x, y);
1044
+ if (rx < rMinX) rMinX = rx;
1045
+ if (ry < rMinY) rMinY = ry;
1046
+ if (rx > rMaxX) rMaxX = rx;
1047
+ if (ry > rMaxY) rMaxY = ry;
1048
+ }
1049
+ const rough = spec.roughness ?? 1;
1050
+ const jit = () => (rng() * 2 - 1) * rough;
1051
+ const out = [];
1052
+ for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
1053
+ const a = fromRot(rMinX, y + jit());
1054
+ const b = fromRot(rMaxX, y + jit());
1055
+ out.push([
1056
+ "M",
1057
+ a[0],
1058
+ a[1]
1059
+ ], [
1060
+ "L",
1061
+ b[0],
1062
+ b[1]
1063
+ ]);
1064
+ }
1065
+ return out;
1066
+ }
1067
+ /**
1068
+ * Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
1069
+ * jittered quadratic; `passes` overlay slightly different jitters for the
1070
+ * built-up look. `rng` must be a freshly seeded generator (the caller reseeds
1071
+ * per draw from a stable seed, so evaluate() stays pure).
1072
+ */
1073
+ function roughen(segs, style, rng) {
1074
+ const resolved = resolveSketch(style);
1075
+ const polys = flatten(segs);
1076
+ const jit = () => (rng() * 2 - 1) * resolved.roughness;
1077
+ const strokes = [];
1078
+ for (let pass = 0; pass < resolved.passes; pass++) {
1079
+ const out = [];
1080
+ for (const poly of polys) {
1081
+ const pts = poly.points;
1082
+ if (pts.length < 2) continue;
1083
+ let ax = pts[0][0] + jit();
1084
+ let ay = pts[0][1] + jit();
1085
+ out.push([
1086
+ "M",
1087
+ ax,
1088
+ ay
1089
+ ]);
1090
+ for (let i = 1; i < pts.length; i++) {
1091
+ const bx = pts[i][0] + jit();
1092
+ const by = pts[i][1] + jit();
1093
+ const dx = bx - ax;
1094
+ const dy = by - ay;
1095
+ const len = Math.hypot(dx, dy) || 1;
1096
+ const bow = jit() * .5;
1097
+ const mx = (ax + bx) / 2 + -dy / len * bow;
1098
+ const my = (ay + by) / 2 + dx / len * bow;
1099
+ out.push([
1100
+ "Q",
1101
+ mx,
1102
+ my,
1103
+ bx,
1104
+ by
1105
+ ]);
1106
+ ax = bx;
1107
+ ay = by;
1108
+ }
1109
+ }
1110
+ strokes.push(out);
1111
+ }
1112
+ return {
1113
+ strokes,
1114
+ resolved
1115
+ };
1116
+ }
1117
+ /** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
1118
+ function hashStr(s) {
1119
+ let h = 2166136261;
1120
+ for (let i = 0; i < s.length; i++) {
1121
+ h ^= s.charCodeAt(i);
1122
+ h = Math.imul(h, 16777619);
1123
+ }
1124
+ return h >>> 0;
1125
+ }
1126
+ /** Convenience: the rough stroke passes for a path at a given seed. */
1127
+ function sketchStrokes(segs, style, seed) {
1128
+ return roughen(segs, style, random(seed >>> 0)).strokes;
1129
+ }
1130
+ //#endregion
1131
1131
  //#region src/nodes.ts
1132
1132
  /**
1133
1133
  * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
@@ -2302,4 +2302,4 @@ function revealSchedule(text, reveal, measurer) {
2302
2302
  return marks;
2303
2303
  }
2304
2304
  //#endregion
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 };
2305
+ export { multiply as $, __resetEstimateWarnings as A, setDefaultMeasurer as B, Node as C, NODE_CONSTRUCTION_PROP_NAMES as D, BASE_CONSTRUCTION_PROP_NAMES as E, isEstimatingMeasurer as F, glow as G, FilterValidationError as H, measureWrappedText as I, IDENTITY as J, validateFilters as K, quantize as L, breakLines as M, estimatingMeasurer as N, isConstructionProp as O, fallbackMeasurer as P, matEquals as Q, segmentGraphemes as R, validateSketch as S, resolveAnchor as T, createDisplayListBuilder as U, warnIfEstimating as V, filtersToCanvasFilter as W, fromTRS as X, applyToPoint as Y, invert as Z, 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, assertFiniteFontSize as j, MEASURE_QUANTUM_PX as k, coercePathData as l, arcLength as m, Custom as n, Rect as o, SketchValidationError as p, collapseReplacer as q, Group as r, Text as s, Circle as t, pathFromSegs as u, resolveSketch as v, NodeConstructionError as w, validateHachure as x, roughen as y, segmentWords as z };
package/dist/orient.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Y as applyToPoint, a as Path, p as Node } from "./nodes.js";
1
+ import { C as Node, Y as applyToPoint, a as Path } from "./nodes.js";
2
2
  import { signal } from "@glissade/core";
3
3
  //#region src/motionPath.ts
4
4
  /**
package/dist/scene.js CHANGED
@@ -1,4 +1,4 @@
1
- import { E as measureWrappedText, U as createDisplayListBuilder, r as Group, v as isConstructionProp, w as fallbackMeasurer } from "./nodes.js";
1
+ import { I as measureWrappedText, O as isConstructionProp, P as fallbackMeasurer, U as createDisplayListBuilder, r as Group } 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 { J as IDENTITY, Q as matEquals, f as roundedRectSegs, p as Node } from "./nodes.js";
1
+ import { C as Node, J as IDENTITY, Q as matEquals, f as roundedRectSegs } 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 { D as quantize, j as warnIfEstimating, r as Group, s as Text, w as fallbackMeasurer } from "./nodes.js";
1
+ import { L as quantize, P as fallbackMeasurer, V as warnIfEstimating, r as Group, s as Text } 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.31.0-pre.1",
3
+ "version": "0.32.0-pre.0",
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": {
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/grid.d.ts",
28
28
  "default": "./dist/grid.js"
29
29
  },
30
+ "./chart": {
31
+ "types": "./dist/chart.d.ts",
32
+ "default": "./dist/chart.js"
33
+ },
30
34
  "./path": {
31
35
  "types": "./dist/path.d.ts",
32
36
  "default": "./dist/path.js"
@@ -65,7 +69,7 @@
65
69
  ],
66
70
  "dependencies": {
67
71
  "yoga-layout": "^3.2.1",
68
- "@glissade/core": "0.31.0-pre.1"
72
+ "@glissade/core": "0.32.0-pre.0"
69
73
  },
70
74
  "repository": {
71
75
  "type": "git",