@glissade/scene 0.19.0 → 0.20.0-pre.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { $ as multiply, A as hashStr, B as validateFilters, C as segmentWords, D as arcLength, E as SketchValidationError, F as validateSketch, G as formatDisplayDiff, H as DlSnapshotError, I as FilterValidationError, J as IDENTITY, K as parseDisplaySnapshot, L as createDisplayListBuilder, M as roughen, N as sketchStrokes, O as flatten, P as validateHachure, Q as matEquals, R as filtersToCanvasFilter, S as segmentGraphemes, U as collapseReplacer, V as DL_SNAPSHOT_VERSION, W as diffDisplayLists, X as fromTRS, Y as applyToPoint, Z as invert, _ as breakLines, a as Path, b as isEstimatingMeasurer, c as Video, d as revealSchedule, f as roundedRectSegs, g as __resetEstimateWarnings, h as MEASURE_QUANTUM_PX, i as ImageNode, j as resolveSketch, k as hachureLines, l as coercePathData, m as resolveAnchor, n as Custom, o as Rect, p as Node, q as serializeDisplayList, r as Group, s as Text, t as Circle, u as pathFromSegs, v as estimatingMeasurer, w as setDefaultMeasurer, x as quantize, y as fallbackMeasurer, z as glow } from "./nodes.js";
1
+ import { A as hashStr, B as validateFilters, C as segmentWords, D as arcLength, E as SketchValidationError, F as validateSketch, G as invert, H as IDENTITY, I as FilterValidationError, K as matEquals, L as createDisplayListBuilder, M as roughen, N as sketchStrokes, O as flatten, P as validateHachure, R as filtersToCanvasFilter, S as segmentGraphemes, U as applyToPoint, V as collapseReplacer, W as fromTRS, _ as breakLines, a as Path, b as isEstimatingMeasurer, c as Video, d as revealSchedule, f as roundedRectSegs, g as __resetEstimateWarnings, h as MEASURE_QUANTUM_PX, i as ImageNode, j as resolveSketch, k as hachureLines, l as coercePathData, m as resolveAnchor, n as Custom, o as Rect, p as Node, q as multiply, r as Group, s as Text, t as Circle, u as pathFromSegs, v as estimatingMeasurer, w as setDefaultMeasurer, x as quantize, z as glow } from "./nodes.js";
2
+ import { a as evaluate, i as createScene, n as ReservedNodeIdError, r as bindScene, t as DuplicateNodeIdError } from "./scene.js";
2
3
  import { i as setLayoutEngine, n as getLayoutEngine, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
3
- import { bindTimeline, buildFontRegistry, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts, vec2Signal } from "@glissade/core";
4
+ import { buildFontRegistry, emitDevWarning, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts } from "@glissade/core";
4
5
  //#region src/taxonomy.ts
5
6
  /**
6
7
  * The CLOSED node taxonomy (DESIGN.md §3.1): exactly nine built-in node TYPES.
@@ -45,8 +46,8 @@ var Highlight = class extends Node {
45
46
  constructor(props) {
46
47
  super(props);
47
48
  this.target = props.text;
48
- this.color = init$2(signal("#ffe066"), props.color);
49
- this.progress = init$2(signal(1), props.progress);
49
+ this.color = init$1(signal("#ffe066"), props.color);
50
+ this.progress = init$1(signal(1), props.progress);
50
51
  this.padding = props.padding ?? [4, 2];
51
52
  this.cornerRadius = props.cornerRadius ?? 4;
52
53
  this.registerTarget("progress", this.progress, "number");
@@ -99,7 +100,7 @@ function highlight(text, props = {}) {
99
100
  text
100
101
  });
101
102
  }
102
- function init$2(sig, v) {
103
+ function init$1(sig, v) {
103
104
  if (typeof v === "function") sig.bindSource(v);
104
105
  else if (v !== void 0) sig.set(v);
105
106
  return sig;
@@ -132,7 +133,7 @@ var TextCursor = class extends Node {
132
133
  this.blinkPhase = props.blinkPhase ?? 0;
133
134
  this.solidWhileTyping = props.solidWhileTyping ?? true;
134
135
  this.caretWidth = props.width ?? 2;
135
- this.fill = init$1(signal(""), props.fill);
136
+ this.fill = init(signal(""), props.fill);
136
137
  this.registerTarget("fill", this.fill, "color");
137
138
  }
138
139
  draw(out, ctx) {
@@ -173,7 +174,7 @@ function textCursor(text, props = {}) {
173
174
  text
174
175
  });
175
176
  }
176
- function init$1(sig, v) {
177
+ function init(sig, v) {
177
178
  if (typeof v === "function") sig.bindSource(v);
178
179
  else if (v !== void 0) sig.set(v);
179
180
  return sig;
@@ -528,164 +529,6 @@ function withDeterminismGuards(mode, fn) {
528
529
  }
529
530
  }
530
531
  //#endregion
531
- //#region src/scene.ts
532
- /**
533
- * Scene assembly + the canonical evaluate(scene, timeline, t) (DESIGN.md §2.5,
534
- * §3.1): pure, total, deterministic — driver writes the playhead (sanctioned
535
- * entry write), then a pull-only read phase collects the DisplayList.
536
- */
537
- var DuplicateNodeIdError = class extends Error {
538
- constructor(id) {
539
- super(`duplicate node id '${id}' — explicit ids must be unique (§3.1/§6.5)`);
540
- this.name = "DuplicateNodeIdError";
541
- }
542
- };
543
- var ReservedNodeIdError = class extends Error {
544
- constructor(id) {
545
- super(`node id '${id}' uses the reserved '~' prefix — that namespace is for structural fallback ids (§6.5), which are inspection-only and never track targets; choose another id`);
546
- this.name = "ReservedNodeIdError";
547
- }
548
- };
549
- function indexNodes(root, into, measurerSource) {
550
- root.measurerSource = measurerSource;
551
- if (root.id !== void 0) {
552
- if (root.id.startsWith("~")) throw new ReservedNodeIdError(root.id);
553
- if (into.has(root.id)) throw new DuplicateNodeIdError(root.id);
554
- into.set(root.id, root);
555
- }
556
- if (root instanceof Group) for (const child of root.children) indexNodes(child, into, measurerSource);
557
- }
558
- function createScene(init) {
559
- const root = new Group({
560
- id: "__root",
561
- children: init.children
562
- });
563
- const nodes = /* @__PURE__ */ new Map();
564
- const playhead = createPlayhead();
565
- const measurer = signal(null);
566
- indexNodes(root, nodes, () => measurer() ?? fallbackMeasurer());
567
- return {
568
- root,
569
- nodes,
570
- size: init.size,
571
- playhead,
572
- resolveTarget: (target) => {
573
- for (let slash = target.lastIndexOf("/"); slash > 0; slash = target.lastIndexOf("/", slash - 1)) {
574
- const node = nodes.get(target.slice(0, slash));
575
- if (node) return node.resolveTarget(target.slice(slash + 1));
576
- }
577
- },
578
- setTextMeasurer: (m) => {
579
- measurer.set(m);
580
- },
581
- get textMeasurer() {
582
- return measurer.peek() ?? fallbackMeasurer();
583
- }
584
- };
585
- }
586
- const bindings = /* @__PURE__ */ new WeakMap();
587
- function bindScene(scene, doc) {
588
- let perScene = bindings.get(scene);
589
- if (!perScene) {
590
- perScene = /* @__PURE__ */ new WeakMap();
591
- bindings.set(scene, perScene);
592
- }
593
- let entry = perScene.get(doc);
594
- if (!entry) {
595
- const compiled = compileTimeline(doc);
596
- entry = {
597
- compiled,
598
- bound: bindTimeline(compiled, scene.resolveTarget, scene.playhead)
599
- };
600
- perScene.set(doc, entry);
601
- }
602
- return entry;
603
- }
604
- /**
605
- * Empty timeline — zero tracks, so binding installs ZERO computed sources and
606
- * any imperative `node.set(...)` value survives evaluate untouched. Shared and
607
- * frozen so the WeakMap binding cache keys on a single stable document across
608
- * the whole controlled-drive loop (one bind, reused every frame).
609
- */
610
- const EMPTY_TIMELINE = Object.freeze({
611
- version: 1,
612
- tracks: []
613
- });
614
- function evaluate(scene, doc, t = 0) {
615
- if (doc === void 0) return evaluate(scene, EMPTY_TIMELINE, scene.playhead.peek());
616
- bindScene(scene, doc);
617
- const fps = doc.fps;
618
- const ctx = {
619
- time: t,
620
- frame: fps !== void 0 ? Math.round(t * fps) : -1,
621
- measurer: scene.textMeasurer
622
- };
623
- return evaluateAt(scene.playhead, t, () => {
624
- const out = createDisplayListBuilder(scene.size);
625
- scene.root.emit(out, ctx);
626
- return out.finish();
627
- });
628
- }
629
- //#endregion
630
- //#region src/cacheColdAudit.ts
631
- /** Stable string of a DisplayList for comparison (opaque resources collapse to a marker). */
632
- function hashDisplayList(dl) {
633
- return JSON.stringify(dl, collapseReplacer);
634
- }
635
- /**
636
- * Evaluate two fresh scenes from `createScene` at `t` and confirm the
637
- * DisplayLists are byte-identical. Returns `{ ok: true }` for a pure scene, or
638
- * `{ ok: false, node }` naming the first divergent node. DEV-only — never on
639
- * the render hot path.
640
- */
641
- function auditCacheCold(createScene, doc, t) {
642
- const warm = createScene();
643
- const cold = createScene();
644
- const a = evaluate(warm, doc, t);
645
- const b = evaluate(cold, doc, t);
646
- if (hashDisplayList(a) === hashDisplayList(b)) return { ok: true };
647
- const frame = doc.fps !== void 0 ? Math.round(t * doc.fps) : -1;
648
- const ctxA = {
649
- time: t,
650
- frame,
651
- measurer: warm.textMeasurer
652
- };
653
- const ctxB = {
654
- time: t,
655
- frame,
656
- measurer: cold.textMeasurer
657
- };
658
- let groupFallback;
659
- for (const [id, nodeA] of warm.nodes) {
660
- const nodeB = cold.nodes.get(id);
661
- if (!nodeB) return {
662
- ok: false,
663
- node: id
664
- };
665
- const ea = createDisplayListBuilder(warm.size);
666
- nodeA.emit(ea, ctxA);
667
- const eb = createDisplayListBuilder(cold.size);
668
- nodeB.emit(eb, ctxB);
669
- const dlA = ea.finish();
670
- const dlB = eb.finish();
671
- if (hashDisplayList(dlA) === hashDisplayList(dlB)) continue;
672
- if (nodeA instanceof Group) {
673
- groupFallback ??= id;
674
- continue;
675
- }
676
- const first = diffDisplayLists(dlA, dlB).deltas[0];
677
- return {
678
- ok: false,
679
- node: id,
680
- ...first !== void 0 ? { delta: first } : {}
681
- };
682
- }
683
- return {
684
- ok: false,
685
- ...groupFallback !== void 0 ? { node: groupFallback } : {}
686
- };
687
- }
688
- //#endregion
689
532
  //#region src/renderBackend.ts
690
533
  /** Every filter kind the shared Raster2D interpreter rasterizes (§3.4). */
691
534
  const ALL_FILTER_KINDS = new Set([
@@ -696,363 +539,6 @@ const ALL_FILTER_KINDS = new Set([
696
539
  "saturate"
697
540
  ]);
698
541
  //#endregion
699
- //#region src/motionPath.ts
700
- /**
701
- * Motion along a path: sample a point (and tangent) at an arc-length position on
702
- * a PathValue, and drive a node along it over time. The Path node draws/morphs
703
- * geometry; this is the companion that makes another node — a cursor, a dot, an
704
- * arrow — *travel* that geometry.
705
- *
706
- * Sampling is arc-length parameterized (constant speed), so progress 0→1 moves
707
- * evenly instead of bunching at the control points. Pure and deterministic: the
708
- * table is built once from a static PathValue and `atProgress` is a pure
709
- * function of progress, so evaluate() stays pure and goldens are byte-stable.
710
- */
711
- const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
712
- function cubicPoint(p0, p1, p2, p3, t) {
713
- const mt = 1 - t;
714
- const a = mt * mt * mt;
715
- const b = 3 * mt * mt * t;
716
- const c = 3 * mt * t * t;
717
- const d = t * t * t;
718
- return [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]];
719
- }
720
- /** PathValue contours → cubic segments, same v/in/out math as Path.pathSegs. */
721
- function toCubics(path) {
722
- const out = [];
723
- for (const ct of path) {
724
- const n = ct.v.length;
725
- for (let i = 0; i < n - 1; i++) out.push([
726
- ct.v[i],
727
- [ct.v[i][0] + ct.out[i][0], ct.v[i][1] + ct.out[i][1]],
728
- [ct.v[i + 1][0] + ct.in[i + 1][0], ct.v[i + 1][1] + ct.in[i + 1][1]],
729
- ct.v[i + 1]
730
- ]);
731
- if (ct.closed && n > 1) out.push([
732
- ct.v[n - 1],
733
- [ct.v[n - 1][0] + ct.out[n - 1][0], ct.v[n - 1][1] + ct.out[n - 1][1]],
734
- [ct.v[0][0] + ct.in[0][0], ct.v[0][1] + ct.in[0][1]],
735
- ct.v[0]
736
- ]);
737
- }
738
- return out;
739
- }
740
- /**
741
- * Build a reusable arc-length sampler. Densely samples each cubic into a
742
- * cumulative-length polyline (samplesPerSegment, default 32) so `at`/`tangent`
743
- * are simple span lerps — smooth enough for motion, no per-call bezier solve.
744
- */
745
- function motionPath(path, opts = {}) {
746
- const steps = Math.max(1, Math.floor(opts.samplesPerSegment ?? 32));
747
- const cubics = toCubics(path);
748
- const pts = [];
749
- const cum = [];
750
- if (cubics.length > 0) {
751
- let prev = cubicPoint(...cubics[0], 0);
752
- pts.push(prev);
753
- cum.push(0);
754
- let acc = 0;
755
- for (const cub of cubics) for (let k = 1; k <= steps; k++) {
756
- const p = cubicPoint(...cub, k / steps);
757
- acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
758
- pts.push(p);
759
- cum.push(acc);
760
- prev = p;
761
- }
762
- } else {
763
- const first = path[0]?.v[0];
764
- pts.push(first ? [first[0], first[1]] : [0, 0]);
765
- cum.push(0);
766
- }
767
- const total = cum[cum.length - 1];
768
- const locate = (s) => {
769
- if (total <= 0 || s <= 0) return {
770
- i: 0,
771
- f: 0
772
- };
773
- if (s >= total) return {
774
- i: pts.length - 2,
775
- f: 1
776
- };
777
- let i = 0;
778
- while (i < cum.length - 1 && cum[i + 1] < s) i++;
779
- const span = cum[i + 1] - cum[i];
780
- return {
781
- i,
782
- f: span > 0 ? (s - cum[i]) / span : 0
783
- };
784
- };
785
- const at = (s) => {
786
- if (pts.length === 1) return [pts[0][0], pts[0][1]];
787
- const { i, f } = locate(s);
788
- const a = pts[i];
789
- const b = pts[i + 1];
790
- return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
791
- };
792
- const tangentAt = (s) => {
793
- if (pts.length === 1) return [1, 0];
794
- const { i } = locate(s);
795
- const a = pts[i];
796
- const b = pts[i + 1];
797
- const dx = b[0] - a[0];
798
- const dy = b[1] - a[1];
799
- const len = Math.hypot(dx, dy);
800
- return len > 0 ? [dx / len, dy / len] : [1, 0];
801
- };
802
- return {
803
- length: total,
804
- at,
805
- tangentAt,
806
- atProgress: (u) => at(clamp01(u) * total),
807
- tangentAtProgress: (u) => tangentAt(clamp01(u) * total)
808
- };
809
- }
810
- /** Total arc length of a path. */
811
- function pathLength(path) {
812
- return motionPath(path).length;
813
- }
814
- /** Point at arc-length s along a path (clamped to [0, length]). */
815
- function pointAtLength(path, s) {
816
- return motionPath(path).at(s);
817
- }
818
- /**
819
- * A companion node that drives `target` along `path` as `progress` animates.
820
- * Owns the target's `position` (and `rotation` when `orient`) via pull-based
821
- * binding, so there's no eval-order side effect. Add it to the scene (its
822
- * `progress` is the animatable target); it draws nothing itself.
823
- */
824
- var FollowPath = class extends Node {
825
- target;
826
- progress;
827
- constructor(props) {
828
- super(props);
829
- this.target = props.target;
830
- this.progress = signal(1);
831
- if (typeof props.progress === "function") this.progress.bindSource(props.progress);
832
- else if (props.progress !== void 0) this.progress.set(props.progress);
833
- this.registerTarget("progress", this.progress, "number");
834
- const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
835
- const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
836
- let cachedPath = getPath();
837
- let cachedSampler = motionPath(cachedPath, sOpts);
838
- const sampler = () => {
839
- const pv = getPath();
840
- if (pv !== cachedPath) {
841
- cachedPath = pv;
842
- cachedSampler = motionPath(pv, sOpts);
843
- }
844
- return cachedSampler;
845
- };
846
- props.target.position.bindSource(() => sampler().atProgress(this.progress()));
847
- if (props.orient) {
848
- const offset = props.orientOffset ?? 0;
849
- props.target.rotation.bindSource(() => {
850
- const t = sampler().tangentAtProgress(this.progress());
851
- return Math.atan2(t[1], t[0]) * 180 / Math.PI + offset;
852
- });
853
- }
854
- }
855
- draw() {}
856
- };
857
- /** `children: [route, cursor, followPath(cursor, route, { orient: true })]` — cursor traces the route.
858
- * Pass the Path *node* to follow it as it morphs; pass a PathValue for a fixed route. */
859
- function followPath(target, path, props = {}) {
860
- return new FollowPath({
861
- ...props,
862
- target,
863
- path
864
- });
865
- }
866
- //#endregion
867
- //#region src/tokenHighlight.ts
868
- /**
869
- * Multi-range token highlight: sub-line ranges over a Text node's wordBoxes,
870
- * each with its OWN animatable fill/opacity/progress/scale — four-color
871
- * category passes, per-token flips, karaoke-with-color. Design answers from
872
- * downstream production (the NNDL verification ritual):
873
- * - ranges VALIDATE at construction and THROW on copy drift at draw — the
874
- * throw is load-bearing (it catches edited copy that no longer matches);
875
- * `rematch: true` opts animated text into per-frame re-resolution.
876
- * - a range spanning a wrap produces one rect per line segment.
877
- * - string matches are whitespace-stripped consecutive box runs and must end
878
- * boundary-exact (mid-segment end = error listing the actual segments);
879
- * `[wordIndex, wordIndex]` ranges sidestep matching entirely.
880
- */
881
- var TokenMatchError = class extends Error {
882
- constructor(message) {
883
- super(message);
884
- this.name = "TokenMatchError";
885
- }
886
- };
887
- const stripWs = (s) => s.replace(/\s+/g, "");
888
- /**
889
- * Find the Nth whitespace-stripped consecutive run of boxes equal to token.
890
- * Boundary-exact: a run that diverges mid-segment is not a match; ending
891
- * mid-segment throws (with the real segment list) rather than half-boxing.
892
- */
893
- function matchTokenRun(boxes, token, occurrence = 1) {
894
- const want = stripWs(token);
895
- if (want.length === 0) throw new TokenMatchError("empty token");
896
- let seen = 0;
897
- for (let i = 0; i < boxes.length; i++) {
898
- let acc = "";
899
- for (let j = i; j < boxes.length; j++) {
900
- acc += stripWs(boxes[j].text);
901
- if (acc === want) {
902
- seen++;
903
- if (seen === occurrence) return [i, j];
904
- break;
905
- }
906
- if (!want.startsWith(acc)) break;
907
- }
908
- }
909
- throw new TokenMatchError(`no match for token '${token}'${occurrence > 1 ? ` (occurrence ${occurrence})` : ""} — segments are [${boxes.map((b) => `'${b.text}'`).join(", ")}]`);
910
- }
911
- var TokenHighlight = class extends Node {
912
- target;
913
- padding;
914
- cornerRadius;
915
- rematch;
916
- ranges;
917
- constructor(props) {
918
- super(props);
919
- this.target = props.text;
920
- this.padding = props.padding ?? [4, 2];
921
- this.cornerRadius = props.cornerRadius ?? 4;
922
- this.rematch = props.rematch ?? false;
923
- const boxes = this.target.wordBoxes();
924
- this.ranges = props.ranges.map((spec, index) => {
925
- const run = this.resolveRun(boxes, spec);
926
- const id = spec.id ?? `r${index}`;
927
- const r = {
928
- spec,
929
- fill: init(signal("#ffe066"), spec.fill),
930
- opacity: init(signal(1), spec.opacity),
931
- progress: init(signal(1), spec.progress),
932
- scale: init(signal(1), spec.scale),
933
- offset: initVec(vec2Signal([0, 0]), spec.offset),
934
- run,
935
- bound: runText(boxes, run)
936
- };
937
- this.registerTarget(`${id}/fill`, r.fill, "color");
938
- this.registerTarget(`${id}/opacity`, r.opacity, "number");
939
- this.registerTarget(`${id}/progress`, r.progress, "number");
940
- this.registerTarget(`${id}/scale`, r.scale, "number");
941
- this.registerTarget(`${id}/offset`, r.offset, "vec2");
942
- this.registerTarget(`${id}/offset.x`, r.offset.x, "number");
943
- this.registerTarget(`${id}/offset.y`, r.offset.y, "number");
944
- return r;
945
- });
946
- }
947
- resolveRun(boxes, spec) {
948
- if (typeof spec.match !== "string") {
949
- const [from, to] = spec.match;
950
- if (from < 0 || to >= boxes.length || from > to) throw new TokenMatchError(`word index range [${from}, ${to}] out of bounds (${boxes.length} boxes)`);
951
- return [from, to];
952
- }
953
- return matchTokenRun(boxes, spec.match, spec.occurrence ?? 1);
954
- }
955
- draw(out, ctx) {
956
- const boxes = this.target.wordBoxes(ctx.measurer);
957
- if (boxes.length === 0) return;
958
- const tm = this.target.localMatrix();
959
- const [px, py] = this.padding;
960
- let pushedTransform = false;
961
- for (const r of this.ranges) {
962
- const opacity = r.opacity();
963
- if (opacity <= 0) continue;
964
- const progress = Math.min(1, Math.max(0, r.progress()));
965
- if (progress <= 0) continue;
966
- let run = r.run;
967
- if (this.rematch && typeof r.spec.match === "string") run = matchTokenRun(boxes, r.spec.match, r.spec.occurrence ?? 1);
968
- else if (runText(boxes, run) !== r.bound) throw new TokenMatchError(`bound token '${r.bound}' no longer matches boxes [${run[0]}, ${run[1]}] — the text changed (segments now [${boxes.slice(run[0], run[1] + 1).map((b) => `'${b.text}'`).join(", ")}]); pass rematch: true for animated text`);
969
- const lineRects = [];
970
- for (let i = run[0]; i <= run[1]; i++) {
971
- const b = boxes[i];
972
- const last = lineRects[lineRects.length - 1];
973
- const cur = i > run[0] && boxes[i - 1].line === b.line ? last : void 0;
974
- if (cur) {
975
- cur.w = b.x + b.w + px - cur.x;
976
- cur.y = Math.min(cur.y, b.y - py);
977
- cur.h = Math.max(cur.h, b.y + b.h + py - cur.y);
978
- } else lineRects.push({
979
- x: b.x - px,
980
- y: b.y - py,
981
- w: b.w + 2 * px,
982
- h: b.h + 2 * py
983
- });
984
- }
985
- if (!pushedTransform && !matEquals(tm, IDENTITY)) {
986
- out.push({
987
- op: "transform",
988
- m: tm
989
- });
990
- pushedTransform = true;
991
- }
992
- const grouped = opacity < 1;
993
- if (grouped) out.push({
994
- op: "pushGroup",
995
- opacity,
996
- blend: "source-over",
997
- filters: []
998
- });
999
- const fill = r.fill();
1000
- const scale = r.scale();
1001
- const [ox, oy] = r.offset();
1002
- let remaining = progress * lineRects.reduce((sum, q) => sum + q.w, 0);
1003
- for (const q of lineRects) {
1004
- const fillW = Math.min(q.w, remaining);
1005
- remaining -= fillW;
1006
- if (fillW <= 0) break;
1007
- const cx = q.x + q.w / 2 + ox;
1008
- const cy = q.y + q.h / 2 + oy;
1009
- const w = fillW * scale;
1010
- const h = q.h * scale;
1011
- const x = cx - q.w / 2 * scale;
1012
- const y = cy - h / 2;
1013
- const rad = Math.min(this.cornerRadius, w / 2, h / 2);
1014
- const path = out.resource({
1015
- kind: "path",
1016
- segs: roundedRectSegs(x, y, w, h, rad)
1017
- });
1018
- out.push({
1019
- op: "fillPath",
1020
- path,
1021
- paint: {
1022
- kind: "color",
1023
- color: fill
1024
- }
1025
- });
1026
- if (remaining <= 0) break;
1027
- }
1028
- if (grouped) out.push({ op: "popGroup" });
1029
- }
1030
- }
1031
- };
1032
- function runText(boxes, run) {
1033
- return boxes.slice(run[0], run[1] + 1).map((b) => stripWs(b.text)).join("");
1034
- }
1035
- /**
1036
- * `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
1037
- * — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
1038
- */
1039
- function tokenHighlight(text, props) {
1040
- return new TokenHighlight({
1041
- ...props,
1042
- text
1043
- });
1044
- }
1045
- function init(sig, v) {
1046
- if (typeof v === "function") sig.bindSource(v);
1047
- else if (v !== void 0) sig.set(v);
1048
- return sig;
1049
- }
1050
- function initVec(sig, v) {
1051
- if (typeof v === "function") sig.bindSource(v);
1052
- else if (v !== void 0) sig.set(v);
1053
- return sig;
1054
- }
1055
- //#endregion
1056
542
  //#region src/fontUsage.ts
1057
543
  /**
1058
544
  * Scene → font-validation bridge (DESIGN.md §3.6). Core owns the AssetRef,
@@ -1488,6 +974,7 @@ var Raster2D = class {
1488
974
  images = /* @__PURE__ */ new Map();
1489
975
  videos = /* @__PURE__ */ new Map();
1490
976
  warnedShaders = false;
977
+ warnedFontVariation = false;
1491
978
  /**
1492
979
  * §3.5 bitmap LRU: device-transform-qualified cacheKey → rasterized layer.
1493
980
  * A Map preserves insertion order, so the oldest key is `keys().next()` —
@@ -1756,10 +1243,19 @@ var Raster2D = class {
1756
1243
  case "fillText": {
1757
1244
  const ctx = ctxOf();
1758
1245
  ctx.font = fontString(cmd.font);
1246
+ const axes = cmd.font.fontVariationSettings;
1247
+ if (axes !== void 0) {
1248
+ if ("fontVariationSettings" in ctx) ctx.fontVariationSettings = axes;
1249
+ else if (!this.warnedFontVariation) {
1250
+ this.warnedFontVariation = true;
1251
+ emitDevWarning("fontVariationSettings ('" + axes + "') is not applied here: this 2D context has no fontVariationSettings property (the browser DOM canvas) — variable-font axes render on the Skia/export path; use the discrete fontWeight named instances for a browser weight (§3.6)");
1252
+ }
1253
+ }
1759
1254
  ctx.fillStyle = resolveFill(ctx, cmd.paint, null);
1760
1255
  ctx.textBaseline = "alphabetic";
1761
1256
  ctx.textAlign = cmd.align ?? "left";
1762
1257
  ctx.fillText(cmd.text, cmd.x, cmd.y);
1258
+ if (axes !== void 0 && "fontVariationSettings" in ctx) ctx.fontVariationSettings = "normal";
1763
1259
  try {
1764
1260
  const width = ctx.measureText(cmd.text).width;
1765
1261
  const align = cmd.align ?? "left";
@@ -1857,4 +1353,4 @@ var Raster2D = class {
1857
1353
  }
1858
1354
  };
1859
1355
  //#endregion
1860
- export { ALL_FILTER_KINDS, Circle, ColdAssetError, Custom, DL_SNAPSHOT_VERSION, DeterminismViolationError, DlSnapshotError, DuplicateNodeIdError, EachError, FilterValidationError, FollowPath, Group, Highlight, IDENTITY, ImageNode as Image, ImageNode, LayoutEngineMissingError, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, NODE_TAXONOMY, Node, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, TokenHighlight, TokenMatchError, Video, __resetEstimateWarnings, applyToPoint, arcLength, auditCacheCold, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, diffDisplayLists, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, followPath, fontString, formatDisplayDiff, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, matchTokenRun, meshRasterSize, motionPath, multiply, parseDisplaySnapshot, pathFromSegs, pathLength, pointAtLength, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, serializeDisplayList, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, tokenHighlight, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
1356
+ export { ALL_FILTER_KINDS, Circle, ColdAssetError, Custom, DeterminismViolationError, DuplicateNodeIdError, EachError, FilterValidationError, Group, Highlight, IDENTITY, ImageNode as Image, ImageNode, LayoutEngineMissingError, MEASURE_QUANTUM_PX, MESH_DOWNSCALE, MESH_SHEPARD_POWER, MESH_SIGMA, NODE_TAXONOMY, Node, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, Video, __resetEstimateWarnings, applyToPoint, arcLength, bindScene, breakLines, coercePathData, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, each, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, isEstimatingMeasurer, matEquals, meshRasterSize, multiply, pathFromSegs, quantize, rasterizeMesh, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };