@idle-screens/schema 3.12.1 → 3.13.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.
package/dist/index.js CHANGED
@@ -290,6 +290,10 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
290
290
  if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
291
291
  return lerpHex(a, b, kk);
292
292
  }
293
+ if (key === "path" && typeof a === "string" && typeof b === "string" && a !== b) {
294
+ const m = lerpPathD(a, b, kk);
295
+ if (m !== null) return m;
296
+ }
293
297
  if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
294
298
  return b.map((bv, i) => walk(a[i], bv, i));
295
299
  }
@@ -306,6 +310,33 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
306
310
  };
307
311
  return walk(from, to, rootKey, specRoot);
308
312
  }
313
+ var PATH_TOKEN = /[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g;
314
+ function lerpPathD(a, b, k) {
315
+ const ta = a.match(PATH_TOKEN) ?? [];
316
+ const tb = b.match(PATH_TOKEN) ?? [];
317
+ if (ta.length !== tb.length) return null;
318
+ const out = [];
319
+ let cmd = "";
320
+ let argIndex = 0;
321
+ for (let i = 0; i < ta.length; i++) {
322
+ const x = ta[i], y = tb[i];
323
+ const isCmdA = /^[a-zA-Z]$/.test(x), isCmdB = /^[a-zA-Z]$/.test(y);
324
+ if (isCmdA !== isCmdB) return null;
325
+ if (isCmdA) {
326
+ if (x !== y) return null;
327
+ cmd = x.toUpperCase();
328
+ argIndex = 0;
329
+ out.push(x);
330
+ continue;
331
+ }
332
+ const va = Number(x), vb = Number(y);
333
+ const flag = cmd === "A" && (argIndex % 7 === 3 || argIndex % 7 === 4);
334
+ argIndex++;
335
+ const v = va + (vb - va) * k;
336
+ out.push(flag ? String(Math.round(v)) : String(+v.toFixed(5)));
337
+ }
338
+ return out.join(" ");
339
+ }
309
340
  function textStringsOf(sprite) {
310
341
  if (sprite.kind === "text") return sprite.strings;
311
342
  if (sprite.kind === "textBlock") return [sprite.text];
@@ -362,7 +393,7 @@ function steerablePaths(spec) {
362
393
  if (!spec || typeof spec !== "object") return [];
363
394
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer", "group", "origin"]);
364
395
  const SKIP_ROOT = /* @__PURE__ */ new Set(["timeline"]);
365
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
396
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands", "parts"]);
366
397
  const layers = spec.layers;
367
398
  const shadowedRootKeys = new Set(
368
399
  Array.isArray(layers) ? layers.map((l) => l && typeof l === "object" ? l.key : void 0).filter((k) => typeof k === "string" && STEERABLE_ROOT_KEYS.has(k)) : []
@@ -674,6 +705,654 @@ function timelineSampleTimesAfter(spec, now, dense = false, only) {
674
705
  return local.filter((s) => s > now);
675
706
  }
676
707
 
708
+ // src/shapes.ts
709
+ function isShapedSprite(s) {
710
+ return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
711
+ }
712
+ function barFraction(s, index) {
713
+ const n = s.values.length;
714
+ if (n === 0) return 0;
715
+ const v = s.values[(index % n + n) % n] ?? 0;
716
+ const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
717
+ if (!(max > 0)) return 0;
718
+ return Math.max(0, Math.min(1, v / max));
719
+ }
720
+ function barBox(direction, len, thick) {
721
+ switch (direction) {
722
+ case "left":
723
+ return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
724
+ case "up":
725
+ return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
726
+ case "down":
727
+ return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
728
+ default:
729
+ return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
730
+ }
731
+ }
732
+ var DEFAULT_POLYGON_SIDES = 6;
733
+ var STROKE_SAMPLES = 24;
734
+ function polygonPoints(s, radius) {
735
+ if (isPathPolygon(s)) {
736
+ const unit = flattenSprite(s);
737
+ if (unit.points.length >= 3) return unit.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
738
+ }
739
+ if (s.points && s.points.length >= 3) {
740
+ return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
741
+ }
742
+ const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
743
+ const out = [];
744
+ for (let k = 0; k < n; k++) {
745
+ const a = -Math.PI / 2 + 2 * Math.PI * k / n;
746
+ out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
747
+ }
748
+ return out;
749
+ }
750
+ function polygonArea(pts) {
751
+ let a = 0;
752
+ for (let i = 0; i < pts.length; i++) {
753
+ const p = pts[i];
754
+ const q = pts[(i + 1) % pts.length];
755
+ a += p.x * q.y - q.x * p.y;
756
+ }
757
+ return Math.abs(a) / 2;
758
+ }
759
+ function polygonFill(s, radius) {
760
+ if (radius <= 0) return 0;
761
+ const disc = Math.PI * radius * radius;
762
+ return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
763
+ }
764
+ function catmullRom(p0, p1, p2, p3, u) {
765
+ const u2 = u * u;
766
+ const u3 = u2 * u;
767
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
768
+ }
769
+ function strokeSamples(s, halfSize, n) {
770
+ const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
771
+ const m = pts.length;
772
+ if (m < 2) return pts;
773
+ const smooth = s.curve !== "linear" && m >= 3;
774
+ const segs = m - 1;
775
+ n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
776
+ const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
777
+ const out = [];
778
+ for (let i = 0; i < n; i++) {
779
+ const u = i / (n - 1) * segs;
780
+ const seg = Math.min(segs - 1, Math.floor(u));
781
+ const local = u - seg;
782
+ const p1 = at(seg);
783
+ const p2 = at(seg + 1);
784
+ if (!smooth) {
785
+ out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
786
+ } else {
787
+ const p0 = at(seg - 1);
788
+ const p3 = at(seg + 2);
789
+ out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
790
+ }
791
+ }
792
+ return out;
793
+ }
794
+ function pathLength(pts) {
795
+ let l = 0;
796
+ for (let i = 1; i < pts.length; i++) {
797
+ const dx = pts[i].x - pts[i - 1].x;
798
+ const dy = pts[i].y - pts[i - 1].y;
799
+ l += Math.sqrt(dx * dx + dy * dy);
800
+ }
801
+ return l;
802
+ }
803
+ function strokeTaper(u) {
804
+ return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
805
+ }
806
+ function strokeWidthPx(s, scale) {
807
+ return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
808
+ }
809
+ function featherRamp(u, feather) {
810
+ const band = Math.max(0, Math.min(1, feather)) / 2;
811
+ const d = Math.min(u, 1 - u);
812
+ if (band <= 0) return d >= 0 ? 1 : 0;
813
+ return Math.max(0, Math.min(1, d / band));
814
+ }
815
+ var FEATHER_MASK_SIZE = 128;
816
+ function ribbonSides(pts, half) {
817
+ const n = pts.length;
818
+ const left = [];
819
+ const right = [];
820
+ if (n < 2) return { left, right };
821
+ const dir = (a, b) => {
822
+ const dx = b.x - a.x;
823
+ const dy = b.y - a.y;
824
+ const l = Math.hypot(dx, dy);
825
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
826
+ };
827
+ for (let i = 0; i < n; i++) {
828
+ const tIn = i > 0 ? dir(pts[i - 1], pts[i]) : dir(pts[0], pts[1]);
829
+ const tOut = i < n - 1 ? dir(pts[i], pts[i + 1]) : tIn;
830
+ let tx = tIn.x + tOut.x;
831
+ let ty = tIn.y + tOut.y;
832
+ const tl = Math.hypot(tx, ty);
833
+ if (tl < 1e-9) {
834
+ tx = tOut.x;
835
+ ty = tOut.y;
836
+ } else {
837
+ tx /= tl;
838
+ ty /= tl;
839
+ }
840
+ const hw = half[i] / Math.max(0.5, tx * tOut.x + ty * tOut.y);
841
+ const p = pts[i];
842
+ left.push({ x: p.x - ty * hw, y: p.y + tx * hw });
843
+ right.push({ x: p.x + ty * hw, y: p.y - tx * hw });
844
+ }
845
+ return { left, right };
846
+ }
847
+ function strokeOutline(pts, lw, taper = true) {
848
+ const n = pts.length;
849
+ if (n < 2) return [];
850
+ const half = (i) => Math.max(0.5, taper ? lw * strokeTaper(i / (n - 1)) : lw) / 2;
851
+ const dir = (a, b) => {
852
+ const dx = b.x - a.x;
853
+ const dy = b.y - a.y;
854
+ const l = Math.hypot(dx, dy);
855
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
856
+ };
857
+ const { left, right } = ribbonSides(pts, Array.from({ length: n }, (_, i) => half(i)));
858
+ const cap = (c, r, from) => {
859
+ const out = [];
860
+ const steps = 6;
861
+ for (let k = 1; k < steps; k++) {
862
+ const a = from - Math.PI * k / steps;
863
+ out.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
864
+ }
865
+ return out;
866
+ };
867
+ const endT = dir(pts[n - 2], pts[n - 1]);
868
+ const startT = dir(pts[0], pts[1]);
869
+ const endA = Math.atan2(endT.y, endT.x) + Math.PI / 2;
870
+ const startA = Math.atan2(startT.y, startT.x) - Math.PI / 2;
871
+ return [
872
+ ...left,
873
+ ...cap(pts[n - 1], half(n - 1), endA),
874
+ ...right.reverse(),
875
+ ...cap(pts[0], half(0), startA)
876
+ ];
877
+ }
878
+ var MAX_PATH_POINTS = 4096;
879
+ var MAX_SCENE_PATH_POINTS = 16384;
880
+ var PATH_TOLERANCE = 5e-4;
881
+ var pathCache = { auto: /* @__PURE__ */ new Map(), nonzero: /* @__PURE__ */ new Map(), evenodd: /* @__PURE__ */ new Map() };
882
+ function flattenPath(d, rule = "auto") {
883
+ const cache = pathCache[rule] ?? pathCache.auto;
884
+ const hit = cache.get(d);
885
+ if (hit) return hit;
886
+ let out;
887
+ try {
888
+ const parsed = parseAndFlatten(d);
889
+ const rings = parsed.rings.map(simplifyRing).filter((r) => r.length >= 3);
890
+ const lines = parsed.lines.map((l) => ({ points: l.closed ? simplifyRing(l.points) : simplifyLine(l.points), closed: l.closed }));
891
+ out = { points: rule === "auto" ? joinRings(rings) : bridge(rings), subpaths: rings.length, lines };
892
+ } catch (e) {
893
+ out = { points: [], subpaths: 0, lines: [], error: e instanceof Error ? e.message : String(e) };
894
+ }
895
+ if (cache.size > 256) cache.clear();
896
+ cache.set(d, out);
897
+ return out;
898
+ }
899
+ var spriteFlat = /* @__PURE__ */ new WeakMap();
900
+ function flattenSprite(s) {
901
+ const d = s.parts ? s.parts.filter((p) => p.fill !== false).map((p) => p.path).join(" ") : s.path ?? "";
902
+ const rule = s.parts ? "auto" : s.fillRule ?? "auto";
903
+ const hit = spriteFlat.get(s);
904
+ if (hit && hit.d === d && hit.rule === rule) return hit.flat;
905
+ const flat = flattenPath(d, rule);
906
+ spriteFlat.set(s, { d, rule, flat });
907
+ return flat;
908
+ }
909
+ function polygonFillRule(s) {
910
+ return typeof s.path === "string" && s.fillRule === "evenodd" ? "evenodd" : "nonzero";
911
+ }
912
+ function isPathPolygon(s) {
913
+ return s.kind === "polygon" && (typeof s.path === "string" || Array.isArray(s.parts));
914
+ }
915
+ var spriteParts = /* @__PURE__ */ new WeakMap();
916
+ function pathParts(s) {
917
+ const key = s.parts ?? s.path;
918
+ const hit = spriteParts.get(s);
919
+ if (hit && hit.key === key) return hit.parts;
920
+ const src = s.parts ?? (typeof s.path === "string" ? [{ path: s.path, fillRule: s.fillRule, fill: s.fill, gradient: s.gradient, stroke: s.stroke }] : []);
921
+ const parts = src.map((p) => {
922
+ const flat = flattenPath(p.path, p.fillRule ?? "auto");
923
+ return {
924
+ flat,
925
+ color: s.parts ? p.color ?? null : null,
926
+ fill: p.fill !== false,
927
+ rule: p.fillRule === "evenodd" ? "evenodd" : "nonzero",
928
+ gradient: p.gradient,
929
+ stroke: p.stroke,
930
+ opacity: p.opacity ?? 1,
931
+ box: boxOfFlat(flat)
932
+ };
933
+ });
934
+ spriteParts.set(s, { key, parts });
935
+ return parts;
936
+ }
937
+ function pathFrame(s, r, w, h) {
938
+ const span = s.span ?? "box";
939
+ const sx = span === "width" || span === "screen" ? w / 2 : r;
940
+ const sy = span === "height" || span === "screen" ? h / 2 : r;
941
+ const [ox, oy] = s.origin ?? [0, 0];
942
+ return { sx, sy, ox, oy, uniform: sx === sy };
943
+ }
944
+ var flatBoxes = /* @__PURE__ */ new WeakMap();
945
+ function boxOfFlat(flat) {
946
+ const hit = flatBoxes.get(flat);
947
+ if (hit) return hit;
948
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
949
+ for (const [x, y] of flat.points.length ? flat.points : flat.lines.flatMap((l) => l.points)) {
950
+ x0 = Math.min(x0, x);
951
+ x1 = Math.max(x1, x);
952
+ y0 = Math.min(y0, y);
953
+ y1 = Math.max(y1, y);
954
+ }
955
+ if (!Number.isFinite(x0)) {
956
+ x0 = -1;
957
+ x1 = 1;
958
+ y0 = -1;
959
+ y1 = 1;
960
+ }
961
+ const box = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, hw: Math.max((x1 - x0) / 2, 1e-6), hh: Math.max((y1 - y0) / 2, 1e-6) };
962
+ flatBoxes.set(flat, box);
963
+ return box;
964
+ }
965
+ var pathIds = /* @__PURE__ */ new Map();
966
+ var pathSeq = 0;
967
+ var paintSigs = /* @__PURE__ */ new WeakMap();
968
+ function pathPaintSig(s) {
969
+ const hit = paintSigs.get(s);
970
+ if (hit !== void 0) return hit;
971
+ const sig = JSON.stringify(s, (k, v) => {
972
+ if (k !== "path" || typeof v !== "string") return v;
973
+ let id = pathIds.get(v);
974
+ if (id === void 0) {
975
+ if (pathIds.size > 4096) pathIds.clear();
976
+ id = ++pathSeq;
977
+ pathIds.set(v, id);
978
+ }
979
+ return `#${id}`;
980
+ });
981
+ paintSigs.set(s, sig);
982
+ return sig;
983
+ }
984
+ function gradientSpace(part, u, v) {
985
+ if (part.gradient?.units !== "part") return [u, v];
986
+ return [(u - part.box.cx) / part.box.hw, (v - part.box.cy) / part.box.hh];
987
+ }
988
+ function segmentsFor(chord, curvature) {
989
+ const n = Math.ceil(Math.sqrt(Math.max(curvature, chord * 0.05) / (4 * PATH_TOLERANCE)));
990
+ return Math.max(2, Math.min(64, n));
991
+ }
992
+ function parseAndFlatten(d) {
993
+ const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
994
+ let i = 0;
995
+ const num = () => {
996
+ const t = tokens[i++];
997
+ if (t === void 0 || /^[a-zA-Z]$/.test(t)) throw new Error("expected a number");
998
+ return Number(t);
999
+ };
1000
+ const flag = () => {
1001
+ const t = tokens[i];
1002
+ if (t !== void 0 && /^[01]{2,}/.test(t)) {
1003
+ tokens[i] = t.slice(1);
1004
+ return Number(t[0]);
1005
+ }
1006
+ return num();
1007
+ };
1008
+ const rings = [];
1009
+ let ring = [];
1010
+ let cx = 0, cy = 0, sx = 0, sy = 0;
1011
+ let lastCtrl = null;
1012
+ let lastCmd = "";
1013
+ const push = (x, y) => {
1014
+ ring.push([x, y]);
1015
+ cx = x;
1016
+ cy = y;
1017
+ };
1018
+ const lines = [];
1019
+ const close = (closed = false) => {
1020
+ if (ring.length >= 2) lines.push({ points: ring.slice(), closed });
1021
+ if (ring.length >= 3) rings.push(ring);
1022
+ ring = [];
1023
+ };
1024
+ const cubic = (x1, y1, x2, y2, x, y) => {
1025
+ const dev = Math.hypot(x1 - cx, y1 - cy) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x - x2, y - y2) - Math.hypot(x - cx, y - cy);
1026
+ const n = segmentsFor(Math.hypot(x - cx, y - cy), dev * 4);
1027
+ const x0 = cx, y0 = cy;
1028
+ for (let k = 1; k <= n; k++) {
1029
+ const t = k / n, u = 1 - t;
1030
+ push(
1031
+ u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x,
1032
+ u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y
1033
+ );
1034
+ }
1035
+ lastCtrl = [x2, y2];
1036
+ };
1037
+ const quad = (x1, y1, x, y) => {
1038
+ const dev = Math.hypot(x1 - cx, y1 - cy) + Math.hypot(x - x1, y - y1) - Math.hypot(x - cx, y - cy);
1039
+ const n = segmentsFor(Math.hypot(x - cx, y - cy), dev * 4);
1040
+ const x0 = cx, y0 = cy;
1041
+ for (let k = 1; k <= n; k++) {
1042
+ const t = k / n, u = 1 - t;
1043
+ push(u * u * x0 + 2 * u * t * x1 + t * t * x, u * u * y0 + 2 * u * t * y1 + t * t * y);
1044
+ }
1045
+ lastCtrl = [x1, y1];
1046
+ };
1047
+ const arc = (rx, ry, phiDeg, large, sweep, x, y) => {
1048
+ const x0 = cx, y0 = cy;
1049
+ if (rx === 0 || ry === 0 || x0 === x && y0 === y) {
1050
+ push(x, y);
1051
+ return;
1052
+ }
1053
+ rx = Math.abs(rx);
1054
+ ry = Math.abs(ry);
1055
+ const phi = phiDeg * Math.PI / 180, cp = Math.cos(phi), sp = Math.sin(phi);
1056
+ const dx = (x0 - x) / 2, dy = (y0 - y) / 2;
1057
+ const x1p = cp * dx + sp * dy, y1p = -sp * dx + cp * dy;
1058
+ const lam = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
1059
+ if (lam > 1) {
1060
+ rx *= Math.sqrt(lam);
1061
+ ry *= Math.sqrt(lam);
1062
+ }
1063
+ const num2 = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
1064
+ const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
1065
+ let co = Math.sqrt(Math.max(0, num2 / den));
1066
+ if (large === sweep) co = -co;
1067
+ const cxp = co * rx * y1p / ry, cyp = -co * ry * x1p / rx;
1068
+ const ccx = cp * cxp - sp * cyp + (x0 + x) / 2, ccy = sp * cxp + cp * cyp + (y0 + y) / 2;
1069
+ const ang = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
1070
+ const t1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
1071
+ let dt = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
1072
+ if (!sweep && dt > 0) dt -= 2 * Math.PI;
1073
+ if (sweep && dt < 0) dt += 2 * Math.PI;
1074
+ const r = Math.max(rx, ry);
1075
+ const n = Math.max(2, Math.min(96, Math.ceil(Math.abs(dt) / (2 * Math.acos(Math.max(0, 1 - PATH_TOLERANCE / r))))));
1076
+ for (let k = 1; k <= n; k++) {
1077
+ const t = t1 + dt * k / n;
1078
+ const ex = rx * Math.cos(t), ey = ry * Math.sin(t);
1079
+ push(cp * ex - sp * ey + ccx, sp * ex + cp * ey + ccy);
1080
+ }
1081
+ };
1082
+ while (i < tokens.length) {
1083
+ let cmd = tokens[i];
1084
+ if (/^[a-zA-Z]$/.test(cmd)) i++;
1085
+ else if (lastCmd) cmd = lastCmd === "M" ? "L" : lastCmd === "m" ? "l" : lastCmd;
1086
+ else throw new Error("path must start with M");
1087
+ const rel = cmd === cmd.toLowerCase() && cmd !== "z" ? true : false;
1088
+ const ox = rel ? cx : 0, oy = rel ? cy : 0;
1089
+ const C = cmd.toUpperCase();
1090
+ const prevCmd = lastCmd.toUpperCase();
1091
+ if (C !== "S" && C !== "T") lastCtrl = null;
1092
+ switch (C) {
1093
+ case "M": {
1094
+ close();
1095
+ const x = num() + ox, y = num() + oy;
1096
+ push(x, y);
1097
+ sx = x;
1098
+ sy = y;
1099
+ break;
1100
+ }
1101
+ case "L":
1102
+ push(num() + ox, num() + oy);
1103
+ break;
1104
+ case "H":
1105
+ push(num() + ox, cy);
1106
+ break;
1107
+ case "V":
1108
+ push(cx, num() + oy);
1109
+ break;
1110
+ case "C": {
1111
+ const a = num() + ox, b = num() + oy, c = num() + ox, e = num() + oy;
1112
+ cubic(a, b, c, e, num() + ox, num() + oy);
1113
+ break;
1114
+ }
1115
+ case "S": {
1116
+ const r = lastCtrl && (prevCmd === "C" || prevCmd === "S") ? [2 * cx - lastCtrl[0], 2 * cy - lastCtrl[1]] : [cx, cy];
1117
+ const c = num() + ox, e = num() + oy;
1118
+ cubic(r[0], r[1], c, e, num() + ox, num() + oy);
1119
+ break;
1120
+ }
1121
+ case "Q": {
1122
+ const a = num() + ox, b = num() + oy;
1123
+ quad(a, b, num() + ox, num() + oy);
1124
+ break;
1125
+ }
1126
+ case "T": {
1127
+ const r = lastCtrl && (prevCmd === "Q" || prevCmd === "T") ? [2 * cx - lastCtrl[0], 2 * cy - lastCtrl[1]] : [cx, cy];
1128
+ quad(r[0], r[1], num() + ox, num() + oy);
1129
+ break;
1130
+ }
1131
+ case "A": {
1132
+ const rx = num(), ry = num(), ph = num(), la = flag(), sw = flag();
1133
+ arc(rx, ry, ph, la, sw, num() + ox, num() + oy);
1134
+ break;
1135
+ }
1136
+ case "Z":
1137
+ cx = sx;
1138
+ cy = sy;
1139
+ close(true);
1140
+ break;
1141
+ default:
1142
+ throw new Error(`unsupported path command '${cmd}'`);
1143
+ }
1144
+ lastCmd = cmd;
1145
+ }
1146
+ close();
1147
+ for (const r of rings) {
1148
+ const a = r[0], b = r[r.length - 1];
1149
+ if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9) r.pop();
1150
+ }
1151
+ for (const l of lines) {
1152
+ const a = l.points[0], b = l.points[l.points.length - 1];
1153
+ if (l.closed && l.points.length > 2 && Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9) l.points.pop();
1154
+ }
1155
+ return { rings, lines };
1156
+ }
1157
+ function simplifyLine(r) {
1158
+ if (r.length <= 2) return r;
1159
+ const keep = new Uint8Array(r.length);
1160
+ keep[0] = 1;
1161
+ keep[r.length - 1] = 1;
1162
+ const stack = [[0, r.length - 1]];
1163
+ while (stack.length) {
1164
+ const [a, b] = stack.pop();
1165
+ const A = r[a], B = r[b];
1166
+ const dx = B[0] - A[0], dy = B[1] - A[1], len = Math.hypot(dx, dy);
1167
+ let best = -1, bd = 0;
1168
+ for (let k = a + 1; k < b; k++) {
1169
+ const P = r[k];
1170
+ const d = len > 1e-12 ? Math.abs(dx * (A[1] - P[1]) - dy * (A[0] - P[0])) / len : Math.hypot(P[0] - A[0], P[1] - A[1]);
1171
+ if (d > bd) {
1172
+ bd = d;
1173
+ best = k;
1174
+ }
1175
+ }
1176
+ if (best >= 0 && bd > PATH_TOLERANCE) {
1177
+ keep[best] = 1;
1178
+ stack.push([a, best], [best, b]);
1179
+ }
1180
+ }
1181
+ return r.filter((_, k) => keep[k] === 1);
1182
+ }
1183
+ function simplifyRing(r) {
1184
+ if (r.length <= 4) return r;
1185
+ let far = 0, fd = -1;
1186
+ for (let k = 1; k < r.length; k++) {
1187
+ const d = (r[k][0] - r[0][0]) ** 2 + (r[k][1] - r[0][1]) ** 2;
1188
+ if (d > fd) {
1189
+ fd = d;
1190
+ far = k;
1191
+ }
1192
+ }
1193
+ const keep = new Uint8Array(r.length);
1194
+ keep[0] = 1;
1195
+ keep[far] = 1;
1196
+ const at = (k) => r[k % r.length];
1197
+ const stack = [[0, far], [far, r.length]];
1198
+ while (stack.length) {
1199
+ const [a, b] = stack.pop();
1200
+ const A = at(a), B = at(b);
1201
+ const dx = B[0] - A[0], dy = B[1] - A[1], len = Math.hypot(dx, dy);
1202
+ let best = -1, bd = 0;
1203
+ for (let k = a + 1; k < b; k++) {
1204
+ const P = at(k);
1205
+ const d = len > 1e-12 ? Math.abs(dx * (A[1] - P[1]) - dy * (A[0] - P[0])) / len : Math.hypot(P[0] - A[0], P[1] - A[1]);
1206
+ if (d > bd) {
1207
+ bd = d;
1208
+ best = k;
1209
+ }
1210
+ }
1211
+ if (best >= 0 && bd > PATH_TOLERANCE) {
1212
+ keep[best % r.length] = 1;
1213
+ stack.push([a, best], [best, b]);
1214
+ }
1215
+ }
1216
+ return r.filter((_, k) => keep[k] === 1);
1217
+ }
1218
+ function signedArea(r) {
1219
+ let a = 0;
1220
+ for (let k = 0; k < r.length; k++) {
1221
+ const p = r[k], q = r[(k + 1) % r.length];
1222
+ a += p[0] * q[1] - q[0] * p[1];
1223
+ }
1224
+ return a / 2;
1225
+ }
1226
+ function inside(pt, r) {
1227
+ let c = false;
1228
+ for (let k = 0, j = r.length - 1; k < r.length; j = k++) {
1229
+ const a = r[k], b = r[j];
1230
+ if (a[1] > pt[1] !== b[1] > pt[1] && pt[0] < (b[0] - a[0]) * (pt[1] - a[1]) / (b[1] - a[1]) + a[0]) c = !c;
1231
+ }
1232
+ return c;
1233
+ }
1234
+ function containsRing(r, rb, sub, sb) {
1235
+ if (sb.x0 < rb.x0 || sb.x1 > rb.x1 || sb.y0 < rb.y0 || sb.y1 > rb.y1) return false;
1236
+ for (const p of sub) if (!inside(p, r)) return false;
1237
+ return true;
1238
+ }
1239
+ function boxOf(r) {
1240
+ const b = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity };
1241
+ for (const [x, y] of r) {
1242
+ b.x0 = Math.min(b.x0, x);
1243
+ b.x1 = Math.max(b.x1, x);
1244
+ b.y0 = Math.min(b.y0, y);
1245
+ b.y1 = Math.max(b.y1, y);
1246
+ }
1247
+ return b;
1248
+ }
1249
+ function joinRings(rings) {
1250
+ if (rings.length === 0) return [];
1251
+ const boxes = rings.map(boxOf);
1252
+ const areas = rings.map((r) => Math.abs(signedArea(r)));
1253
+ const order = rings.map((_, i) => i).sort((p, q) => areas[q] - areas[p]);
1254
+ const mult = new Array(rings.length).fill(0);
1255
+ const net = new Array(rings.length).fill(0);
1256
+ const done = [];
1257
+ for (const n of order) {
1258
+ const containers = done.filter((m) => areas[m] > areas[n] && containsRing(rings[m], boxes[m], rings[n], boxes[n]));
1259
+ const around = containers.reduce((sum, m) => sum + mult[m], 0);
1260
+ let want = 1;
1261
+ if (containers.length) {
1262
+ const parent = containers.reduce((p, m) => areas[m] < areas[p] ? m : p);
1263
+ want = net[parent] !== 0 ? 0 : 1;
1264
+ }
1265
+ net[n] = want;
1266
+ mult[n] = want - around;
1267
+ done.push(n);
1268
+ }
1269
+ const traced = [];
1270
+ const trace = (r, m) => {
1271
+ const o = Math.sign(signedArea(r)) === Math.sign(m) ? r : r.slice().reverse();
1272
+ for (let k = 0; k < Math.abs(m); k++) traced.push(o);
1273
+ };
1274
+ for (let n = 0; n < rings.length; n++) {
1275
+ if (mult[n] !== 0) trace(rings[n], mult[n]);
1276
+ }
1277
+ const holes = rings.map((_, n) => n).filter((n) => net[n] === 0 && mult[n] !== 0 && isConvex(rings[n]));
1278
+ for (let m = 0; m < rings.length; m++) {
1279
+ if (mult[m] <= 0) continue;
1280
+ const bm = boxes[m];
1281
+ const crossing = holes.filter((n) => {
1282
+ const bn = boxes[n];
1283
+ if (n === m || bm.x1 <= bn.x0 || bm.x0 >= bn.x1 || bm.y1 <= bn.y0 || bm.y0 >= bn.y1) return false;
1284
+ return !containsRing(rings[m], bm, rings[n], bn) && !containsRing(rings[n], bn, rings[m], bm);
1285
+ });
1286
+ for (const n of crossing) {
1287
+ if (crossing.some((o) => o !== n && areas[o] > areas[n] && containsRing(rings[o], boxes[o], rings[n], boxes[n]))) continue;
1288
+ const piece = clipToConvex(rings[m], rings[n]);
1289
+ if (piece.length >= 3 && Math.abs(signedArea(piece)) > 1e-9) trace(piece, -mult[m]);
1290
+ }
1291
+ }
1292
+ return bridge(traced);
1293
+ }
1294
+ function isConvex(r) {
1295
+ let sign = 0;
1296
+ for (let k = 0; k < r.length; k++) {
1297
+ const a = r[k], b = r[(k + 1) % r.length], c = r[(k + 2) % r.length];
1298
+ const z = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
1299
+ if (Math.abs(z) < 1e-12) continue;
1300
+ if (sign === 0) sign = Math.sign(z);
1301
+ else if (Math.sign(z) !== sign) return false;
1302
+ }
1303
+ return true;
1304
+ }
1305
+ function clipToConvex(subject, clip) {
1306
+ const o = Math.sign(signedArea(clip)) || 1;
1307
+ let out = subject;
1308
+ for (let k = 0; k < clip.length && out.length; k++) {
1309
+ const a = clip[k], b = clip[(k + 1) % clip.length];
1310
+ const side = (p) => o * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
1311
+ const cut = (p, q) => {
1312
+ const sp = side(p), sq = side(q), t = sp / (sp - sq);
1313
+ return [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
1314
+ };
1315
+ const next = [];
1316
+ for (let i = 0; i < out.length; i++) {
1317
+ const p = out[i], q = out[(i + 1) % out.length];
1318
+ const pin = side(p) >= 0, qin = side(q) >= 0;
1319
+ if (pin) next.push(p);
1320
+ if (pin !== qin) next.push(cut(p, q));
1321
+ }
1322
+ out = next;
1323
+ }
1324
+ return out;
1325
+ }
1326
+ function bridge(rings) {
1327
+ if (rings.length === 0) return [];
1328
+ let ring = rings[0].slice();
1329
+ for (let n = 1; n < rings.length; n++) {
1330
+ const sub = rings[n];
1331
+ let bi = 0, bj = 0, best = Infinity;
1332
+ for (let a = 0; a < ring.length; a++) {
1333
+ for (let b = 0; b < sub.length; b++) {
1334
+ const dd = (ring[a][0] - sub[b][0]) ** 2 + (ring[a][1] - sub[b][1]) ** 2;
1335
+ if (dd < best) {
1336
+ best = dd;
1337
+ bi = a;
1338
+ bj = b;
1339
+ }
1340
+ }
1341
+ }
1342
+ const rot = sub.slice(bj).concat(sub.slice(0, bj));
1343
+ ring = ring.slice(0, bi + 1).concat(rot, [rot[0], ring[bi]], ring.slice(bi + 1));
1344
+ }
1345
+ return ring;
1346
+ }
1347
+ function pathDrawCost(s) {
1348
+ let c = 0;
1349
+ for (const p of pathParts(s)) {
1350
+ if (p.fill) c += p.flat.points.length * (p.gradient ? 2 : 1);
1351
+ if (p.stroke) for (const l of p.flat.lines) c += l.points.length * 3;
1352
+ }
1353
+ return c;
1354
+ }
1355
+
677
1356
  // src/validate.ts
678
1357
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
679
1358
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -719,7 +1398,8 @@ var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "c
719
1398
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
720
1399
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
721
1400
  var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
722
- var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
1401
+ var KNOWN_PATH_PART = /* @__PURE__ */ new Set(["path", "color", "fillRule", "fill", "gradient", "stroke", "opacity"]);
1402
+ var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "path", "fillRule", "fill", "gradient", "stroke", "parts", "span", "origin", "soft", "colors", "colorWeights"]);
723
1403
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
724
1404
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
725
1405
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
@@ -859,6 +1539,23 @@ function validateSpecCore(spec) {
859
1539
  err("layers", "must be a non-empty array");
860
1540
  } else {
861
1541
  if (spec.layers.length > LIMITS.maxLayers) err("layers", `at most ${LIMITS.maxLayers} layers`);
1542
+ {
1543
+ let pathPoints = 0;
1544
+ for (const l of spec.layers) {
1545
+ const sp = isObj(l) ? l.sprite : void 0;
1546
+ if (!sp || sp.kind !== "polygon") continue;
1547
+ const pieces = Array.isArray(sp.parts) ? sp.parts : typeof sp.path === "string" ? [sp] : [];
1548
+ const n = typeof l.count === "number" && l.count > 0 ? l.count : 1;
1549
+ for (const pc of pieces) {
1550
+ if (!isObj(pc) || typeof pc.path !== "string") continue;
1551
+ const fl = flattenPath(pc.path, pc.fillRule ?? "auto");
1552
+ pathPoints += ((pc.fill === false ? 0 : fl.points.length) + (isObj(pc.stroke) ? fl.lines.reduce((m, x) => m + x.points.length, 0) : 0)) * n;
1553
+ }
1554
+ }
1555
+ if (pathPoints > MAX_SCENE_PATH_POINTS) {
1556
+ err("layers", `path outlines total ${pathPoints} points (flattened points \xD7 count); the scene budget is ${MAX_SCENE_PATH_POINTS}`);
1557
+ }
1558
+ }
862
1559
  let total = 0;
863
1560
  spec.layers.forEach((layer, i) => {
864
1561
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
@@ -1232,10 +1929,20 @@ function validateLayer(layer, path, err, warn, spec) {
1232
1929
  if (layer.links !== void 0) {
1233
1930
  if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
1234
1931
  else {
1235
- if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
1236
- err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
1932
+ const chain = layer.links.mode === "chain";
1933
+ if (!chain || layer.links.k !== void 0) {
1934
+ if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
1935
+ err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
1936
+ }
1937
+ }
1938
+ if (!chain || layer.links.maxDist !== void 0 || layer.links.falloff === true) {
1939
+ positiveScalar(
1940
+ layer.links.maxDist,
1941
+ `${path}.links.maxDist`,
1942
+ err,
1943
+ chain && layer.links.maxDist === void 0 ? "is required (> 0) when a chain sets falloff: true \u2014 it is the fade distance" : void 0
1944
+ );
1237
1945
  }
1238
- positiveScalar(layer.links.maxDist, `${path}.links.maxDist`, err);
1239
1946
  if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
1240
1947
  if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
1241
1948
  err(`${path}.links.alpha`, "must be 0..1");
@@ -1476,6 +2183,34 @@ function validateSprite(sprite, path, err, warn, spec) {
1476
2183
  err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
1477
2184
  }
1478
2185
  if (sprite.points !== void 0) validateShapePoints(sprite.points, `${path}.points`, 3, err);
2186
+ if (sprite.parts !== void 0) {
2187
+ for (const k of ["path", "points", "sides", "gradient", "stroke", "fill", "fillRule"]) {
2188
+ if (sprite[k] !== void 0) err(`${path}.${k}`, `with parts, put ${k} on each part (or drop parts)`);
2189
+ }
2190
+ if (!Array.isArray(sprite.parts) || sprite.parts.length === 0 || sprite.parts.length > MAX_PATH_PARTS) {
2191
+ err(`${path}.parts`, `must be 1..${MAX_PATH_PARTS} parts, each { path, color?, gradient?, stroke?, fill?, fillRule?, opacity? }`);
2192
+ } else {
2193
+ sprite.parts.forEach((part, i) => {
2194
+ const pp = `${path}.parts[${i}]`;
2195
+ if (!isObj(part)) return err(pp, "must be { path, color?, gradient?, stroke?, fill?, fillRule?, opacity? }");
2196
+ validatePathPiece(part, pp, err, warn);
2197
+ if (part.color !== void 0) color(part.color, `${pp}.color`, err);
2198
+ if (part.opacity !== void 0 && (!isNum(part.opacity) || part.opacity < 0 || part.opacity > 1)) err(`${pp}.opacity`, "must be 0..1");
2199
+ for (const k of unknownKeys(part, KNOWN_PATH_PART)) warn(`${pp}.${k}`, "unknown-property", `unknown part property '${k}' \u2014 will be ignored`);
2200
+ });
2201
+ }
2202
+ } else if (sprite.path !== void 0) {
2203
+ if (sprite.points !== void 0 || sprite.sides !== void 0) err(`${path}.path`, "use path, points or sides, not more than one");
2204
+ validatePathPiece(sprite, path, err, warn);
2205
+ } else {
2206
+ for (const k of ["gradient", "stroke", "fill"]) if (sprite[k] !== void 0) err(`${path}.${k}`, `${k} applies only to a polygon with a path (or parts)`);
2207
+ if (sprite.fillRule !== void 0) warn(`${path}.fillRule`, "unknown-property", "fillRule applies only to a path \u2014 ignored");
2208
+ }
2209
+ if (sprite.span !== void 0 || sprite.origin !== void 0) {
2210
+ if (sprite.path === void 0 && sprite.parts === void 0) err(`${path}.span`, "span and origin apply only to a polygon with a path (or parts)");
2211
+ if (sprite.span !== void 0 && !["box", "width", "height", "screen"].includes(sprite.span)) err(`${path}.span`, "must be 'box' | 'width' | 'height' | 'screen'");
2212
+ if (sprite.origin !== void 0 && !(Array.isArray(sprite.origin) && sprite.origin.length === 2 && sprite.origin.every((v) => isNum(v) && Math.abs(v) <= 1))) err(`${path}.origin`, "must be [x, y] in the unit box (-1..1): the point placed at the position");
2213
+ }
1479
2214
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
1480
2215
  color(sprite.color, `${path}.color`, err);
1481
2216
  validatePalette(sprite, path, err);
@@ -1732,6 +2467,58 @@ function validateMotion(motion, path, err, warn, spec) {
1732
2467
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
1733
2468
  }
1734
2469
  }
2470
+ var isPair = (v, lim = 2) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && Math.abs(v[0]) <= lim && Math.abs(v[1]) <= lim;
2471
+ var MAX_PATH_PARTS = 256;
2472
+ function validatePathPiece(piece, path, err, warn) {
2473
+ if (typeof piece.path !== "string" || piece.path.length === 0 || piece.path.length > 65536) {
2474
+ err(`${path}.path`, "must be an SVG path d string (1..65536 chars) in the -1..1 unit box");
2475
+ } else {
2476
+ const flat = flattenPath(piece.path, piece.fillRule ?? "auto");
2477
+ const filled = piece.fill !== false;
2478
+ if (flat.error) err(`${path}.path`, `not a usable path: ${flat.error}`);
2479
+ else if (filled && flat.points.length < 3) err(`${path}.path`, "must enclose an area (at least 3 points), or set fill: false and a stroke for line art");
2480
+ else if (!filled && flat.lines.length === 0) err(`${path}.path`, "must draw at least one line (2+ points)");
2481
+ else if (flat.points.length > MAX_PATH_POINTS) err(`${path}.path`, `flattens to ${flat.points.length} points; at most ${MAX_PATH_POINTS}`);
2482
+ else if ([...flat.points, ...flat.lines.flatMap((l) => l.points)].some(([x, y]) => Math.abs(x) > 1.0001 || Math.abs(y) > 1.0001)) err(`${path}.path`, "must stay inside the -1..1 unit box (curves included)");
2483
+ }
2484
+ if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2485
+ if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
2486
+ if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err);
2487
+ if (piece.stroke !== void 0) validatePathStroke(piece.stroke, `${path}.stroke`, err);
2488
+ if (piece.fillRule !== void 0 && !["auto", "nonzero", "evenodd"].includes(piece.fillRule)) err(`${path}.fillRule`, "must be 'auto' | 'nonzero' | 'evenodd'");
2489
+ void warn;
2490
+ }
2491
+ function validatePathGradient(g, path, err) {
2492
+ if (!isObj(g)) return err(path, "must be { type: 'linear', from, to, stops } or { type: 'radial', center, radius, stops }");
2493
+ if (g.type === "linear") {
2494
+ if (!isPair(g.from)) err(`${path}.from`, "must be [x, y] in unit-box coordinates (-2..2)");
2495
+ if (!isPair(g.to)) err(`${path}.to`, "must be [x, y] in unit-box coordinates (-2..2)");
2496
+ } else if (g.type === "radial") {
2497
+ if (!isPair(g.center)) err(`${path}.center`, "must be [x, y] in unit-box coordinates (-2..2)");
2498
+ if (!isNum(g.radius) || g.radius <= 0 || g.radius > 4) err(`${path}.radius`, "must be > 0 and <= 4 (unit-box units)");
2499
+ if (g.focus !== void 0 && !isPair(g.focus)) err(`${path}.focus`, "must be [x, y] in unit-box coordinates (-2..2)");
2500
+ } else {
2501
+ return err(`${path}.type`, "must be 'linear' | 'radial'");
2502
+ }
2503
+ if (g.units !== void 0 && g.units !== "box" && g.units !== "part") err(`${path}.units`, "must be 'box' (the drawing's unit box, default) | 'part' (the part's own box, -1..1)");
2504
+ if (!Array.isArray(g.stops) || g.stops.length < 2 || g.stops.length > 8) return err(`${path}.stops`, "must be 2..8 stops");
2505
+ let last = -Infinity;
2506
+ g.stops.forEach((st, i) => {
2507
+ if (!isObj(st)) return err(`${path}.stops[${i}]`, "must be { at, color, opacity? }");
2508
+ if (!isNum(st.at) || st.at < 0 || st.at > 1 || st.at < last) err(`${path}.stops[${i}].at`, "must be 0..1, not decreasing");
2509
+ else last = st.at;
2510
+ color(st.color, `${path}.stops[${i}].color`, err);
2511
+ if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.stops[${i}].opacity`, "must be 0..1");
2512
+ });
2513
+ }
2514
+ function validatePathStroke(st, path, err) {
2515
+ if (!isObj(st)) return err(path, "must be { color, width, opacity?, join?, cap? }");
2516
+ color(st.color, `${path}.color`, err);
2517
+ if (!isNum(st.width) || st.width <= 0 || st.width > 0.5) err(`${path}.width`, "must be > 0 and <= 0.5 (unit-box units: 0.01 is 1% of the drawing's half-size)");
2518
+ if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.opacity`, "must be 0..1");
2519
+ if (st.join !== void 0 && !["round", "miter", "bevel"].includes(st.join)) err(`${path}.join`, "must be 'round' | 'miter' | 'bevel'");
2520
+ if (st.cap !== void 0 && !["round", "butt", "square"].includes(st.cap)) err(`${path}.cap`, "must be 'round' | 'butt' | 'square'");
2521
+ }
1735
2522
  function validateShapePoints(points, path, min, err) {
1736
2523
  if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
1737
2524
  return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
@@ -1893,206 +2680,39 @@ function validateSequence(seq) {
1893
2680
  path: `segments[${i}].transition`,
1894
2681
  code: "morph-structural-mismatch",
1895
2682
  message: `segments ${i}\u2192${i + 1} differ structurally: morph will fall back to cut`
1896
- });
1897
- } else if (s.scene.timeline === void 0 && next.scene.timeline === void 0 && morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" || s.transition.text === "dip" })) {
1898
- warnings.push({
1899
- path: `segments[${i}].transition`,
1900
- code: "morph-nothing-morphable",
1901
- message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
1902
- });
1903
- }
1904
- }
1905
- }
1906
- if (errors.length === 0 && seq.wrapMorph === true) {
1907
- const sq = seq;
1908
- const last = sq.segments.length - 1;
1909
- const lastTr = sq.segments[last]?.transition;
1910
- let why = null;
1911
- if (sq.loop !== true) why = "the sequence does not loop";
1912
- else if (last < 1) why = "there is only one segment";
1913
- else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
1914
- else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
1915
- else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
1916
- if (why || !canWrapMorph(sq)) {
1917
- warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
1918
- }
1919
- }
1920
- return { valid: errors.length === 0, errors, warnings };
1921
- }
1922
- function assertValidSequence(seq) {
1923
- const r = validateSequence(seq);
1924
- if (!r.valid) {
1925
- throw new Error(`invalid sequence:
1926
- ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
1927
- }
1928
- return seq;
1929
- }
1930
-
1931
- // src/shapes.ts
1932
- function isShapedSprite(s) {
1933
- return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
1934
- }
1935
- function barFraction(s, index) {
1936
- const n = s.values.length;
1937
- if (n === 0) return 0;
1938
- const v = s.values[(index % n + n) % n] ?? 0;
1939
- const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
1940
- if (!(max > 0)) return 0;
1941
- return Math.max(0, Math.min(1, v / max));
1942
- }
1943
- function barBox(direction, len, thick) {
1944
- switch (direction) {
1945
- case "left":
1946
- return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1947
- case "up":
1948
- return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
1949
- case "down":
1950
- return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
1951
- default:
1952
- return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1953
- }
1954
- }
1955
- var DEFAULT_POLYGON_SIDES = 6;
1956
- var STROKE_SAMPLES = 24;
1957
- function polygonPoints(s, radius) {
1958
- if (s.points && s.points.length >= 3) {
1959
- return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
1960
- }
1961
- const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
1962
- const out = [];
1963
- for (let k = 0; k < n; k++) {
1964
- const a = -Math.PI / 2 + 2 * Math.PI * k / n;
1965
- out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
1966
- }
1967
- return out;
1968
- }
1969
- function polygonArea(pts) {
1970
- let a = 0;
1971
- for (let i = 0; i < pts.length; i++) {
1972
- const p = pts[i];
1973
- const q = pts[(i + 1) % pts.length];
1974
- a += p.x * q.y - q.x * p.y;
1975
- }
1976
- return Math.abs(a) / 2;
1977
- }
1978
- function polygonFill(s, radius) {
1979
- if (radius <= 0) return 0;
1980
- const disc = Math.PI * radius * radius;
1981
- return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
1982
- }
1983
- function catmullRom(p0, p1, p2, p3, u) {
1984
- const u2 = u * u;
1985
- const u3 = u2 * u;
1986
- return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
1987
- }
1988
- function strokeSamples(s, halfSize, n) {
1989
- const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
1990
- const m = pts.length;
1991
- if (m < 2) return pts;
1992
- const smooth = s.curve !== "linear" && m >= 3;
1993
- const segs = m - 1;
1994
- n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
1995
- const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
1996
- const out = [];
1997
- for (let i = 0; i < n; i++) {
1998
- const u = i / (n - 1) * segs;
1999
- const seg = Math.min(segs - 1, Math.floor(u));
2000
- const local = u - seg;
2001
- const p1 = at(seg);
2002
- const p2 = at(seg + 1);
2003
- if (!smooth) {
2004
- out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
2005
- } else {
2006
- const p0 = at(seg - 1);
2007
- const p3 = at(seg + 2);
2008
- out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
2683
+ });
2684
+ } else if (s.scene.timeline === void 0 && next.scene.timeline === void 0 && morphNothingMorphable(s.scene, next.scene, { textCrossfade: s.transition.text === "crossfade" || s.transition.text === "dip" })) {
2685
+ warnings.push({
2686
+ path: `segments[${i}].transition`,
2687
+ code: "morph-nothing-morphable",
2688
+ message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
2689
+ });
2690
+ }
2009
2691
  }
2010
2692
  }
2011
- return out;
2012
- }
2013
- function pathLength(pts) {
2014
- let l = 0;
2015
- for (let i = 1; i < pts.length; i++) {
2016
- const dx = pts[i].x - pts[i - 1].x;
2017
- const dy = pts[i].y - pts[i - 1].y;
2018
- l += Math.sqrt(dx * dx + dy * dy);
2019
- }
2020
- return l;
2021
- }
2022
- function strokeTaper(u) {
2023
- return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
2024
- }
2025
- function strokeWidthPx(s, scale) {
2026
- return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2027
- }
2028
- function featherRamp(u, feather) {
2029
- const band = Math.max(0, Math.min(1, feather)) / 2;
2030
- const d = Math.min(u, 1 - u);
2031
- if (band <= 0) return d >= 0 ? 1 : 0;
2032
- return Math.max(0, Math.min(1, d / band));
2033
- }
2034
- var FEATHER_MASK_SIZE = 128;
2035
- function ribbonSides(pts, half) {
2036
- const n = pts.length;
2037
- const left = [];
2038
- const right = [];
2039
- if (n < 2) return { left, right };
2040
- const dir = (a, b) => {
2041
- const dx = b.x - a.x;
2042
- const dy = b.y - a.y;
2043
- const l = Math.hypot(dx, dy);
2044
- return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2045
- };
2046
- for (let i = 0; i < n; i++) {
2047
- const tIn = i > 0 ? dir(pts[i - 1], pts[i]) : dir(pts[0], pts[1]);
2048
- const tOut = i < n - 1 ? dir(pts[i], pts[i + 1]) : tIn;
2049
- let tx = tIn.x + tOut.x;
2050
- let ty = tIn.y + tOut.y;
2051
- const tl = Math.hypot(tx, ty);
2052
- if (tl < 1e-9) {
2053
- tx = tOut.x;
2054
- ty = tOut.y;
2055
- } else {
2056
- tx /= tl;
2057
- ty /= tl;
2693
+ if (errors.length === 0 && seq.wrapMorph === true) {
2694
+ const sq = seq;
2695
+ const last = sq.segments.length - 1;
2696
+ const lastTr = sq.segments[last]?.transition;
2697
+ let why = null;
2698
+ if (sq.loop !== true) why = "the sequence does not loop";
2699
+ else if (last < 1) why = "there is only one segment";
2700
+ else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
2701
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
2702
+ else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
2703
+ if (why || !canWrapMorph(sq)) {
2704
+ warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
2058
2705
  }
2059
- const hw = half[i] / Math.max(0.5, tx * tOut.x + ty * tOut.y);
2060
- const p = pts[i];
2061
- left.push({ x: p.x - ty * hw, y: p.y + tx * hw });
2062
- right.push({ x: p.x + ty * hw, y: p.y - tx * hw });
2063
2706
  }
2064
- return { left, right };
2707
+ return { valid: errors.length === 0, errors, warnings };
2065
2708
  }
2066
- function strokeOutline(pts, lw, taper = true) {
2067
- const n = pts.length;
2068
- if (n < 2) return [];
2069
- const half = (i) => Math.max(0.5, taper ? lw * strokeTaper(i / (n - 1)) : lw) / 2;
2070
- const dir = (a, b) => {
2071
- const dx = b.x - a.x;
2072
- const dy = b.y - a.y;
2073
- const l = Math.hypot(dx, dy);
2074
- return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2075
- };
2076
- const { left, right } = ribbonSides(pts, Array.from({ length: n }, (_, i) => half(i)));
2077
- const cap = (c, r, from) => {
2078
- const out = [];
2079
- const steps = 6;
2080
- for (let k = 1; k < steps; k++) {
2081
- const a = from - Math.PI * k / steps;
2082
- out.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
2083
- }
2084
- return out;
2085
- };
2086
- const endT = dir(pts[n - 2], pts[n - 1]);
2087
- const startT = dir(pts[0], pts[1]);
2088
- const endA = Math.atan2(endT.y, endT.x) + Math.PI / 2;
2089
- const startA = Math.atan2(startT.y, startT.x) - Math.PI / 2;
2090
- return [
2091
- ...left,
2092
- ...cap(pts[n - 1], half(n - 1), endA),
2093
- ...right.reverse(),
2094
- ...cap(pts[0], half(0), startA)
2095
- ];
2709
+ function assertValidSequence(seq) {
2710
+ const r = validateSequence(seq);
2711
+ if (!r.valid) {
2712
+ throw new Error(`invalid sequence:
2713
+ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
2714
+ }
2715
+ return seq;
2096
2716
  }
2097
2717
 
2098
2718
  // src/simulate.ts
@@ -2616,9 +3236,13 @@ function wrapDist(pi, pj, doWrap, w, h) {
2616
3236
  }
2617
3237
  return Math.sqrt(dx * dx + dy * dy);
2618
3238
  }
3239
+ function linkMaxDistPx(links, scale) {
3240
+ return links.maxDist === void 0 ? Infinity : links.maxDist * scale;
3241
+ }
2619
3242
  function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2620
3243
  const n = positions.length;
2621
3244
  const mode = links.mode ?? "nearest";
3245
+ const k = links.k ?? 1;
2622
3246
  if (mode === "chain") {
2623
3247
  const out = [];
2624
3248
  for (let i = 0; i < n - 1; i++) {
@@ -2632,7 +3256,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2632
3256
  const edges = /* @__PURE__ */ new Set();
2633
3257
  const out = [];
2634
3258
  for (let i = 0; i < n; i++) {
2635
- for (let mIdx = 1; mIdx <= links.k; mIdx++) {
3259
+ for (let mIdx = 1; mIdx <= k; mIdx++) {
2636
3260
  const j = (i + mIdx * stride) % n;
2637
3261
  if (j === i) continue;
2638
3262
  const dist = wrapDist(positions[i], positions[j], doWrap, w, h);
@@ -2646,7 +3270,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2646
3270
  }
2647
3271
  return out;
2648
3272
  }
2649
- return linkPairs(positions, links.k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
3273
+ return linkPairs(positions, k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
2650
3274
  i,
2651
3275
  j,
2652
3276
  dist: wrapDist(positions[i], positions[j], doWrap, w, h)
@@ -3325,6 +3949,45 @@ function overridesStayValid(authored, overrides, now) {
3325
3949
  }
3326
3950
  return true;
3327
3951
  }
3952
+ var PATH_BLIT_MIN_POINTS = 128;
3953
+ var PLATE_MIN_POINTS = 2500;
3954
+ var PATH_BLIT_PER_SPRITE = 8;
3955
+ var PATH_SLOT_MAX = 96;
3956
+ var PATH_SPRITE_PER_SPRITE = 96;
3957
+ var PATH_SPRITE_MAX_PIXELS = 512 * 512;
3958
+ var PATH_BLIT_MIN_DENSITY = 1500;
3959
+ var PATH_BLIT_MAX_PIXELS = 16e6;
3960
+ function ringPath2D(lines) {
3961
+ const p2 = new Path2D();
3962
+ for (const l of lines) {
3963
+ if (l.points.length < 2) continue;
3964
+ p2.moveTo(l.points[0][0], l.points[0][1]);
3965
+ for (let i = 1; i < l.points.length; i++) p2.lineTo(l.points[i][0], l.points[i][1]);
3966
+ if (l.closed) p2.closePath();
3967
+ }
3968
+ return p2;
3969
+ }
3970
+ function traceLines(g, lines) {
3971
+ g.beginPath();
3972
+ for (const l of lines) {
3973
+ if (l.points.length < 2) continue;
3974
+ g.moveTo(l.points[0][0], l.points[0][1]);
3975
+ for (let i = 1; i < l.points.length; i++) g.lineTo(l.points[i][0], l.points[i][1]);
3976
+ if (l.closed) g.closePath();
3977
+ }
3978
+ }
3979
+ function pathGradientFill(g, gr) {
3980
+ const grad = gr.type === "linear" ? g.createLinearGradient(gr.from[0], gr.from[1], gr.to[0], gr.to[1]) : g.createRadialGradient((gr.focus ?? gr.center)[0], (gr.focus ?? gr.center)[1], 0, gr.center[0], gr.center[1], gr.radius);
3981
+ for (const st of gr.stops) grad.addColorStop(st.at, st.opacity === void 0 ? st.color : hexToRgba(st.color, st.opacity));
3982
+ return grad;
3983
+ }
3984
+ function softFill(g, color2, r) {
3985
+ const grad = g.createRadialGradient(0, 0, 0, 0, 0, r);
3986
+ grad.addColorStop(0, color2);
3987
+ grad.addColorStop(0.35, hexToRgba(color2, 0.75));
3988
+ grad.addColorStop(1, hexToRgba(color2, 0));
3989
+ return grad;
3990
+ }
3328
3991
  var SpecInstance = class {
3329
3992
  constructor(spec, ctx, opts = {}) {
3330
3993
  this.spec = spec;
@@ -3435,6 +4098,20 @@ var SpecInstance = class {
3435
4098
  paintSeq = 0;
3436
4099
  /** Per layer: ribbon area painted this frame (px²), and whether its trails ribbon (see ribbonAllowed). */
3437
4100
  ribbonBudget = /* @__PURE__ */ new WeakMap();
4101
+ /**
4102
+ * Render-once cache for path polygons (polygon.path). A detailed drawing is
4103
+ * rasterised once per (colour, device size, rotation, sub-pixel offset)
4104
+ * into its own bitmap and copied 1:1 at a whole-pixel offset every frame
4105
+ * after — so a static 7,000-point drawing costs a blit, not a re-fill.
4106
+ * Keyed by the sprite object, so a steer or republish (a new spec object)
4107
+ * starts clean. A drawing whose key keeps changing (spinning, trailing,
4108
+ * growing) stops being cached and draws from a reused Path2D instead.
4109
+ */
4110
+ pathBlits = /* @__PURE__ */ new Map();
4111
+ /** Test/bench switch: `globalThis.__idleNoPathCache = true` before compiling draws every path directly. */
4112
+ noPathCache = globalThis.__idleNoPathCache === true;
4113
+ /** Device pixels held by every pathBlits bitmap — capped so a TV never pays more than PATH_BLIT_MAX_PIXELS of memory. */
4114
+ pathBlitPixels = 0;
3438
4115
  featherTints = /* @__PURE__ */ new Map();
3439
4116
  /**
3440
4117
  * The presentation pass when this instance applies a `finish`: the
@@ -3713,6 +4390,277 @@ var SpecInstance = class {
3713
4390
  return rb.ribbon;
3714
4391
  }
3715
4392
  /** A fresh 2D canvas (DOM or Offscreen), or null where none can be made. */
4393
+ /**
4394
+ * A drawing's cache slot, keyed by what it draws (pathPaintSig), not by the
4395
+ * sprite object — glides and timelines replace that object every frame.
4396
+ * Least-recently-used slots are evicted past PATH_SLOT_MAX, and their
4397
+ * bitmaps' pixels returned to the budget.
4398
+ */
4399
+ pathSlot(sprite) {
4400
+ const sig = pathPaintSig(sprite);
4401
+ let slot = this.pathBlits.get(sig);
4402
+ if (slot) {
4403
+ this.pathBlits.delete(sig);
4404
+ this.pathBlits.set(sig, slot);
4405
+ return slot;
4406
+ }
4407
+ if (this.pathBlits.size >= PATH_SLOT_MAX) {
4408
+ const [oldSig, old] = this.pathBlits.entries().next().value;
4409
+ for (const ent of old.entries.values()) this.pathBlitPixels -= ent.px;
4410
+ this.pathBlits.delete(oldSig);
4411
+ }
4412
+ slot = { entries: /* @__PURE__ */ new Map(), flushes: 0, off: false };
4413
+ this.pathBlits.set(sig, slot);
4414
+ return slot;
4415
+ }
4416
+ /**
4417
+ * Whether a layer's path drawing looks the same as last frame — the caches
4418
+ * serve only steady drawings; one that is gliding (colour, gradient, shape)
4419
+ * draws directly until it settles, instead of rasterising a bitmap a frame.
4420
+ */
4421
+ // Keyed by the layer's place in the scene, never the per-layer record: a
4422
+ // scene that animates a baked field (alpha, size) rebuilds every record
4423
+ // every frame, and a drawing beside it is still steady.
4424
+ pathSteady = /* @__PURE__ */ new Map();
4425
+ drawingSteady(built, sprite) {
4426
+ const sig = pathPaintSig(sprite);
4427
+ const li = this.layers.indexOf(built);
4428
+ const prev = this.pathSteady.get(li);
4429
+ if (prev && prev.seq === this.paintSeq && prev.sig === sig) return prev.steady;
4430
+ const steady = prev !== void 0 && prev.sig === sig;
4431
+ this.pathSteady.set(li, { sig, seq: this.paintSeq, steady });
4432
+ return steady;
4433
+ }
4434
+ /**
4435
+ * Draw a path polygon in its own unit box: the caller has moved to the
4436
+ * entity and rotated; this scales by r. Parts paint back to front (a single
4437
+ * `path` is one part). Each fill is its gradient (drawing-box coordinates,
4438
+ * or with units 'part' the part's own box, filled under that box's
4439
+ * transform so a radial reads as the part's ellipse), the soft radial
4440
+ * falloff, or flat colour; each stroke traces the part's subpaths as drawn,
4441
+ * width in unit-box units. Outlines are Path2Ds built once per sprite.
4442
+ */
4443
+ drawPathUnit(g, sprite, color2, fr, r) {
4444
+ const slot = this.pathSlot(sprite);
4445
+ const parts = pathParts(sprite);
4446
+ if (!slot.parts2d) slot.parts2d = parts.map(() => ({}));
4447
+ if (fr.ox || fr.oy) g.translate(-fr.ox * fr.sx, -fr.oy * fr.sy);
4448
+ g.scale(fr.sx, fr.sy);
4449
+ const a0 = g.globalAlpha;
4450
+ for (let i = 0; i < parts.length; i++) {
4451
+ const part = parts[i];
4452
+ const cache = slot.parts2d[i];
4453
+ const pc = part.color ?? color2;
4454
+ if (part.fill && part.flat.points.length >= 3) {
4455
+ g.globalAlpha = a0 * part.opacity;
4456
+ if (part.gradient?.units === "part") {
4457
+ const { cx, cy, hw, hh } = part.box;
4458
+ if (cache.fillPart === void 0) {
4459
+ cache.fillPart = typeof Path2D === "undefined" ? null : ringPath2D([{ points: part.flat.points.map(([x, y]) => [(x - cx) / hw, (y - cy) / hh]), closed: true }]);
4460
+ }
4461
+ g.save();
4462
+ g.transform(hw, 0, 0, hh, cx, cy);
4463
+ g.fillStyle = pathGradientFill(g, part.gradient);
4464
+ if (cache.fillPart) g.fill(cache.fillPart, part.rule);
4465
+ else {
4466
+ traceLines(g, [{ points: part.flat.points.map(([x, y]) => [(x - cx) / hw, (y - cy) / hh]), closed: true }]);
4467
+ g.fill(part.rule);
4468
+ }
4469
+ g.restore();
4470
+ } else {
4471
+ g.fillStyle = part.gradient ? pathGradientFill(g, part.gradient) : sprite.soft && !sprite.parts ? softFill(g, pc, 1) : pc;
4472
+ if (cache.fill === void 0) cache.fill = typeof Path2D === "undefined" ? null : ringPath2D([{ points: part.flat.points, closed: true }]);
4473
+ if (cache.fill) g.fill(cache.fill, part.rule);
4474
+ else {
4475
+ traceLines(g, [{ points: part.flat.points, closed: true }]);
4476
+ g.fill(part.rule);
4477
+ }
4478
+ }
4479
+ }
4480
+ const st = part.stroke;
4481
+ if (st && part.flat.lines.length) {
4482
+ g.globalAlpha = a0 * part.opacity * (st.opacity ?? 1);
4483
+ g.strokeStyle = st.color;
4484
+ g.lineJoin = st.join ?? "round";
4485
+ g.lineCap = st.cap ?? "round";
4486
+ if (fr.uniform) {
4487
+ g.lineWidth = st.width * r / fr.sx;
4488
+ if (cache.stroke === void 0) cache.stroke = typeof Path2D === "undefined" ? null : ringPath2D(part.flat.lines);
4489
+ if (cache.stroke) g.stroke(cache.stroke);
4490
+ else {
4491
+ traceLines(g, part.flat.lines);
4492
+ g.stroke();
4493
+ }
4494
+ } else {
4495
+ const scaled = part.flat.lines.map((l) => ({ closed: l.closed, points: l.points.map(([x, y]) => [x * fr.sx, y * fr.sy]) }));
4496
+ g.save();
4497
+ g.scale(1 / fr.sx, 1 / fr.sy);
4498
+ g.lineWidth = st.width * r;
4499
+ traceLines(g, scaled);
4500
+ g.stroke();
4501
+ g.restore();
4502
+ }
4503
+ }
4504
+ }
4505
+ g.globalAlpha = a0;
4506
+ }
4507
+ /**
4508
+ * Draw a path polygon from its render-once bitmap. False when it cannot be
4509
+ * blitted exactly — a rotated or skewed layer transform, a bitmap past the
4510
+ * memory cap, a drawing too sparse for its area, or one that keeps
4511
+ * changing — and the caller draws it directly.
4512
+ */
4513
+ blitPath(sprite, p, r, rot, color2, fr) {
4514
+ const ctx = this.ctx;
4515
+ const slot = this.pathSlot(sprite);
4516
+ if (slot.off) return false;
4517
+ if (slot.sprite) return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4518
+ const m = ctx.getTransform();
4519
+ if (Math.abs(m.b) > 1e-9 || Math.abs(m.c) > 1e-9 || Math.abs(m.a - m.d) > 1e-9 || !(m.a > 0)) {
4520
+ slot.sprite = true;
4521
+ return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4522
+ }
4523
+ const k = m.a;
4524
+ const dx = k * p.x + m.e;
4525
+ const dy = k * p.y + m.f;
4526
+ const ix = Math.floor(dx);
4527
+ const iy = Math.floor(dy);
4528
+ const fx = Math.round((dx - ix) * 8) / 8;
4529
+ const fy = Math.round((dy - iy) * 8) / 8;
4530
+ const key = `${color2}|${(r * k).toFixed(3)}|${(fr.sx * k).toFixed(3)}|${(fr.sy * k).toFixed(3)}|${fr.ox},${fr.oy}|${rot.toFixed(5)}|${fx}|${fy}`;
4531
+ let hit = slot.entries.get(key);
4532
+ if (!hit) {
4533
+ if (slot.entries.size >= PATH_BLIT_PER_SPRITE) {
4534
+ for (const old of slot.entries.values()) this.pathBlitPixels -= old.px;
4535
+ slot.entries.clear();
4536
+ if (++slot.flushes > 1) {
4537
+ slot.sprite = true;
4538
+ slot.flushes = 0;
4539
+ return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4540
+ }
4541
+ }
4542
+ const c = Math.cos(rot);
4543
+ const sn = Math.sin(rot);
4544
+ const s = r * k;
4545
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
4546
+ const grow = (u) => {
4547
+ const ux = (u[0] - fr.ox) * fr.sx * k, uy = (u[1] - fr.oy) * fr.sy * k;
4548
+ const x = ux * c - uy * sn;
4549
+ const y = ux * sn + uy * c;
4550
+ if (x < x0) x0 = x;
4551
+ if (x > x1) x1 = x;
4552
+ if (y < y0) y0 = y;
4553
+ if (y > y1) y1 = y;
4554
+ };
4555
+ let sw = 0;
4556
+ for (const part of pathParts(sprite)) {
4557
+ if (part.fill) for (const u of part.flat.points) grow(u);
4558
+ if (part.stroke) {
4559
+ sw = Math.max(sw, part.stroke.width);
4560
+ for (const l of part.flat.lines) for (const u of l.points) grow(u);
4561
+ }
4562
+ }
4563
+ const pad = 2 + Math.ceil(sw * s);
4564
+ const bx = Math.floor(fx + x0) - pad;
4565
+ const by = Math.floor(fy + y0) - pad;
4566
+ const w = Math.ceil(fx + x1) - bx + pad;
4567
+ const h = Math.ceil(fy + y1) - by + pad;
4568
+ if (!(w > 0 && h > 0) || !Number.isFinite(w + h)) return false;
4569
+ if (pathDrawCost(sprite) / (w * h / 1e6) < PATH_BLIT_MIN_DENSITY) {
4570
+ slot.off = true;
4571
+ return false;
4572
+ }
4573
+ if (this.pathBlitPixels + w * h > PATH_BLIT_MAX_PIXELS) return false;
4574
+ const made = this.makeCanvas(1);
4575
+ if (!made) return false;
4576
+ made.canvas.width = w;
4577
+ made.canvas.height = h;
4578
+ const g = made.ctx;
4579
+ g.setTransform(k, 0, 0, k, fx - bx, fy - by);
4580
+ if (rot) g.rotate(rot);
4581
+ this.drawPathUnit(g, sprite, color2, fr, r);
4582
+ hit = { canvas: made.canvas, x0: bx, y0: by, px: w * h };
4583
+ this.pathBlitPixels += hit.px;
4584
+ slot.entries.set(key, hit);
4585
+ }
4586
+ ctx.save();
4587
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4588
+ ctx.drawImage(hit.canvas, ix + hit.x0, iy + hit.y0);
4589
+ ctx.restore();
4590
+ return true;
4591
+ }
4592
+ /**
4593
+ * The moving-sprite cache: for a drawing whose exact key keeps changing. It
4594
+ * is rasterised once per colour, UNROTATED, at a device radius rounded UP to
4595
+ * the next 1/8-octave step (so it is only ever shrunk, never enlarged), then
4596
+ * drawn every frame at its true sub-pixel position, rotation and size under
4597
+ * the context's own transform, with smoothing. Not pixel-exact like the
4598
+ * still cache (edges resample by well under a pixel), but a 7-part drifting
4599
+ * balloon costs one image draw instead of seven fills and strokes.
4600
+ */
4601
+ spriteBlit(sprite, slot, p, r, rot, color2, fr) {
4602
+ const ctx = this.ctx;
4603
+ if (!fr.uniform || fr.sx !== r) return false;
4604
+ const m = ctx.getTransform();
4605
+ const k = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c));
4606
+ const s = r * k;
4607
+ if (!(s >= 1)) return false;
4608
+ const bucket = 2 ** (Math.ceil(Math.log2(s) * 8) / 8);
4609
+ const key = `S|${color2}|${bucket.toFixed(3)}`;
4610
+ let hit = slot.entries.get(key);
4611
+ if (!hit) {
4612
+ if (slot.entries.size >= PATH_SPRITE_PER_SPRITE) {
4613
+ for (const old of slot.entries.values()) this.pathBlitPixels -= old.px;
4614
+ slot.entries.clear();
4615
+ if (++slot.flushes > 2) {
4616
+ slot.off = true;
4617
+ return false;
4618
+ }
4619
+ }
4620
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, sw = 0;
4621
+ const grow = (u2) => {
4622
+ if (u2[0] < x0) x0 = u2[0];
4623
+ if (u2[0] > x1) x1 = u2[0];
4624
+ if (u2[1] < y0) y0 = u2[1];
4625
+ if (u2[1] > y1) y1 = u2[1];
4626
+ };
4627
+ for (const part of pathParts(sprite)) {
4628
+ if (part.fill) for (const u2 of part.flat.points) grow([u2[0] - fr.ox, u2[1] - fr.oy]);
4629
+ if (part.stroke) {
4630
+ sw = Math.max(sw, part.stroke.width);
4631
+ for (const l of part.flat.lines) for (const u2 of l.points) grow([u2[0] - fr.ox, u2[1] - fr.oy]);
4632
+ }
4633
+ }
4634
+ const pad = 2 + Math.ceil(sw * bucket);
4635
+ const ox = Math.ceil(-x0 * bucket) + pad;
4636
+ const oy = Math.ceil(-y0 * bucket) + pad;
4637
+ const w = ox + Math.ceil(x1 * bucket) + pad;
4638
+ const h = oy + Math.ceil(y1 * bucket) + pad;
4639
+ if (!(w > 0 && h > 0) || !Number.isFinite(w + h)) return false;
4640
+ if (w * h > PATH_SPRITE_MAX_PIXELS) {
4641
+ slot.off = true;
4642
+ return false;
4643
+ }
4644
+ if (this.pathBlitPixels + w * h > PATH_BLIT_MAX_PIXELS) return false;
4645
+ const made = this.makeCanvas(1);
4646
+ if (!made) return false;
4647
+ made.canvas.width = w;
4648
+ made.canvas.height = h;
4649
+ made.ctx.setTransform(1, 0, 0, 1, ox, oy);
4650
+ this.drawPathUnit(made.ctx, sprite, color2, { sx: bucket, sy: bucket, ox: fr.ox, oy: fr.oy, uniform: true }, bucket);
4651
+ hit = { canvas: made.canvas, x0: ox, y0: oy, px: w * h };
4652
+ this.pathBlitPixels += hit.px;
4653
+ slot.entries.set(key, hit);
4654
+ }
4655
+ const u = r / bucket;
4656
+ ctx.save();
4657
+ ctx.translate(p.x, p.y);
4658
+ if (rot) ctx.rotate(rot);
4659
+ ctx.imageSmoothingEnabled = true;
4660
+ ctx.drawImage(hit.canvas, -hit.x0 * u, -hit.y0 * u, hit.canvas.width * u, hit.canvas.height * u);
4661
+ ctx.restore();
4662
+ return true;
4663
+ }
3716
4664
  makeCanvas(n) {
3717
4665
  try {
3718
4666
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(n, n);
@@ -3944,24 +4892,26 @@ var SpecInstance = class {
3944
4892
  if (sprite.kind === "polygon") {
3945
4893
  const r = sz / 2;
3946
4894
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3947
- const pts = polygonPoints(sprite, r);
4895
+ if (isPathPolygon(sprite)) {
4896
+ const fr = pathFrame(sprite, r, this.w, this.h);
4897
+ if (pathDrawCost(sprite) >= PATH_BLIT_MIN_POINTS && !this.noPathCache && this.drawingSteady(built, sprite) && this.blitPath(sprite, p, r, rot, resolvedColor, fr)) return;
4898
+ ctx.save();
4899
+ ctx.translate(p.x, p.y);
4900
+ if (rot) ctx.rotate(rot);
4901
+ if (r > 0 && fr.sx > 0 && fr.sy > 0) this.drawPathUnit(ctx, sprite, resolvedColor, fr, r);
4902
+ ctx.restore();
4903
+ return;
4904
+ }
3948
4905
  ctx.save();
3949
4906
  ctx.translate(p.x, p.y);
3950
4907
  if (rot) ctx.rotate(rot);
3951
- if (sprite.soft) {
3952
- const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
3953
- g.addColorStop(0, resolvedColor);
3954
- g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3955
- g.addColorStop(1, hexToRgba(resolvedColor, 0));
3956
- ctx.fillStyle = g;
3957
- } else {
3958
- ctx.fillStyle = resolvedColor;
3959
- }
4908
+ ctx.fillStyle = sprite.soft ? softFill(ctx, resolvedColor, r) : resolvedColor;
4909
+ const pts = polygonPoints(sprite, r);
3960
4910
  ctx.beginPath();
3961
4911
  ctx.moveTo(pts[0].x, pts[0].y);
3962
4912
  for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
3963
4913
  ctx.closePath();
3964
- ctx.fill();
4914
+ ctx.fill(polygonFillRule(sprite));
3965
4915
  ctx.restore();
3966
4916
  return;
3967
4917
  }
@@ -4200,9 +5150,9 @@ var SpecInstance = class {
4200
5150
  const motionWraps = ["drift", "rise", "wander"].includes(built.layer.motion.type);
4201
5151
  const wrap2 = built.layer.wrap !== false && motionWraps;
4202
5152
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
4203
- const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
4204
- const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
4205
5153
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
5154
+ const maxDistPx = linkMaxDistPx(links, unitScale);
5155
+ const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
4206
5156
  const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4207
5157
  const lw = (links.width ?? defaultWidth) * unitScale;
4208
5158
  ctx.lineWidth = lw;
@@ -4219,7 +5169,7 @@ var SpecInstance = class {
4219
5169
  } else resolvedColor = "#e6e8ef";
4220
5170
  }
4221
5171
  let a = links.alpha ?? alphaAt(ei, t);
4222
- if (links.falloff) a *= Math.max(0, 1 - dist / maxDistPx);
5172
+ if (links.falloff && Number.isFinite(maxDistPx)) a *= Math.max(0, 1 - dist / maxDistPx);
4223
5173
  ctx.globalAlpha = a * lifeA;
4224
5174
  ctx.strokeStyle = resolvedColor;
4225
5175
  let dx = pj.x - pi.x;
@@ -4246,38 +5196,212 @@ var SpecInstance = class {
4246
5196
  this.drawBackground(t);
4247
5197
  }
4248
5198
  ctx.globalAlpha = 1;
4249
- for (let li = 0; li < this.layers.length; li++) {
4250
- const built = this.layers[li];
4251
- const opacity = built.layer.opacity;
4252
- const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
4253
- let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
4254
- if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
4255
- if (lifeA <= 0) continue;
4256
- const parentE = this.parentEntityFor(built);
4257
- const tf = built.layer.transform;
4258
- const gtf = group?.transform;
4259
- if (tf || gtf) {
4260
- ctx.save();
4261
- if (gtf) this.applyLayerTransform(gtf, null);
4262
- if (tf) this.applyLayerTransform(tf, built.layer);
4263
- }
4264
- ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
4265
- this.drawLinks(built, t, lifeA, parentE);
4266
- const override = this.paintOverrides?.get(li) ?? null;
4267
- for (const e of built.entities) {
4268
- this.drawTrail(built, e, t, lifeA, parentE);
4269
- if (override === null) {
4270
- this.drawEntity(built, e, t, lifeA, parentE);
5199
+ const n = this.layers.length;
5200
+ for (let li = 0; li < n; li++) {
5201
+ if (!this.noPathCache && this.plateable(li, t)) {
5202
+ let j = li;
5203
+ while (j + 1 < n && this.plateable(j + 1, t)) j++;
5204
+ if (j > li && this.runPoints(li, j) >= PLATE_MIN_POINTS && this.paintPlate(li, j, t)) {
5205
+ li = j;
4271
5206
  continue;
4272
5207
  }
4273
- const [aOut, aIn] = override.mode === "dip" ? [Math.max(0, 1 - 2 * override.k), Math.max(0, 2 * override.k - 1)] : [1 - override.k, override.k];
4274
- if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
4275
- if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
4276
5208
  }
4277
- if (tf || gtf) ctx.restore();
5209
+ this.paintLayer(li, t);
5210
+ }
5211
+ ctx.globalAlpha = 1;
5212
+ ctx.globalCompositeOperation = "source-over";
5213
+ }
5214
+ /** Paint layer `li` at time t onto this.ctx (the scene canvas, or a plate being rebuilt). */
5215
+ paintLayer(li, t) {
5216
+ const { ctx } = this;
5217
+ const built = this.layers[li];
5218
+ const opacity = built.layer.opacity;
5219
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5220
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
5221
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
5222
+ if (lifeA <= 0) return;
5223
+ const parentE = this.parentEntityFor(built);
5224
+ const tf = built.layer.transform;
5225
+ const gtf = group?.transform;
5226
+ if (tf || gtf) {
5227
+ ctx.save();
5228
+ if (gtf) this.applyLayerTransform(gtf, null);
5229
+ if (tf) this.applyLayerTransform(tf, built.layer);
5230
+ }
5231
+ ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
5232
+ this.drawLinks(built, t, lifeA, parentE);
5233
+ const override = this.paintOverrides?.get(li) ?? null;
5234
+ for (const e of built.entities) {
5235
+ this.drawTrail(built, e, t, lifeA, parentE);
5236
+ if (override === null) {
5237
+ this.drawEntity(built, e, t, lifeA, parentE);
5238
+ continue;
5239
+ }
5240
+ const [aOut, aIn] = override.mode === "dip" ? [Math.max(0, 1 - 2 * override.k), Math.max(0, 2 * override.k - 1)] : [1 - override.k, override.k];
5241
+ if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
5242
+ if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
5243
+ }
5244
+ if (tf || gtf) ctx.restore();
5245
+ }
5246
+ /**
5247
+ * Static plates. Consecutive path-polygon layers painted source-over, with
5248
+ * no trails, links or text overrides, are composited into ONE full-frame
5249
+ * bitmap and copied once per frame while nothing they draw has changed —
5250
+ * the change test is the drawn values themselves (each entity's position,
5251
+ * size, rotation, alpha and colour, the layer's life alpha and transforms),
5252
+ * so anything animated simply repaints its plate that frame. Source-over is
5253
+ * associative, so the plate over the frame equals the layers over it.
5254
+ */
5255
+ plates = /* @__PURE__ */ new Map();
5256
+ /** Outline points a run of path layers fills per frame (points × entities). */
5257
+ runPoints(a, b) {
5258
+ let n = 0;
5259
+ for (let li = a; li <= b; li++) {
5260
+ const built = this.layers[li];
5261
+ const sp = built.layer.sprite;
5262
+ if (sp.kind === "polygon" && isPathPolygon(sp)) n += pathDrawCost(sp) * built.entities.length;
4278
5263
  }
5264
+ return n;
5265
+ }
5266
+ /** Last drawn-value signature per layer, for plate stability. */
5267
+ layerSigs = /* @__PURE__ */ new Map();
5268
+ /**
5269
+ * A layer joins a plate only if it can (path polygon, source-over, no
5270
+ * trails/links/text override) AND nothing it draws changed since the last
5271
+ * frame — so a pulsing or moving layer splits the run and paints live, while
5272
+ * the static layers around it keep their plates.
5273
+ */
5274
+ plateable(li, t) {
5275
+ const built = this.layers[li];
5276
+ const l = built.layer;
5277
+ const sp = l.sprite;
5278
+ if (!isPathPolygon(sp)) return false;
5279
+ if ((l.blend ?? "source-over") !== "source-over") return false;
5280
+ if (l.trail || l.links || this.paintOverrides?.has(li)) return false;
5281
+ if (built.entities.length > 64) return false;
5282
+ const sig = this.layerSig(li, t);
5283
+ const prev = this.layerSigs.get(li);
5284
+ this.layerSigs.set(li, sig);
5285
+ return prev === sig;
5286
+ }
5287
+ layerSig(li, t) {
5288
+ const built = this.layers[li];
5289
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5290
+ const opacity = built.layer.opacity;
5291
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
5292
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
5293
+ let sig = `${pathPaintSig(built.layer.sprite)}:${lifeA.toFixed(4)}:${JSON.stringify(built.layer.transform ?? null)}:${JSON.stringify(group?.transform ?? null)}`;
5294
+ const parentE = this.parentEntityFor(built);
5295
+ for (const e of built.entities) {
5296
+ const p = this.entityPos(e, t, parentE);
5297
+ sig += `|${p.x.toFixed(2)},${p.y.toFixed(2)},${sizeAt(e, t).toFixed(3)},${rotationAt(e, t).toFixed(5)},${alphaAt(e, t).toFixed(4)},${e.colorIndex}`;
5298
+ }
5299
+ return sig;
5300
+ }
5301
+ plateSig(a, b) {
5302
+ let sig = `${a}-${b}`;
5303
+ for (let li = a; li <= b; li++) sig += `#${this.layerSigs.get(li)}`;
5304
+ return sig;
5305
+ }
5306
+ /** Max |point| of a path polygon's unit outline (its reach as a fraction of the entity radius). */
5307
+ pathReach = /* @__PURE__ */ new WeakMap();
5308
+ reachOf(sp) {
5309
+ let r = this.pathReach.get(sp);
5310
+ if (r === void 0) {
5311
+ r = 0;
5312
+ let sw = 0;
5313
+ for (const part of pathParts(sp)) {
5314
+ for (const [x, y] of part.flat.points) r = Math.max(r, Math.hypot(x, y));
5315
+ for (const l of part.flat.lines) for (const [x, y] of l.points) r = Math.max(r, Math.hypot(x, y));
5316
+ if (part.stroke) sw = Math.max(sw, part.stroke.width);
5317
+ }
5318
+ r += sw;
5319
+ this.pathReach.set(sp, r || 1);
5320
+ }
5321
+ return r || 1;
5322
+ }
5323
+ /**
5324
+ * Device-pixel box a run of layers can paint into, or null for "the whole
5325
+ * frame" (a layer/group transform moves things in ways this does not model).
5326
+ */
5327
+ runBox(a, b, t) {
5328
+ const m = this.ctx.getTransform();
5329
+ if (Math.abs(m.b) > 1e-9 || Math.abs(m.c) > 1e-9) return null;
5330
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
5331
+ for (let li = a; li <= b; li++) {
5332
+ const built = this.layers[li];
5333
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5334
+ if (built.layer.transform || group?.transform) return null;
5335
+ const reach = this.reachOf(built.layer.sprite);
5336
+ const parentE = this.parentEntityFor(built);
5337
+ for (const e of built.entities) {
5338
+ const p = this.entityPos(e, t, parentE);
5339
+ const sp = built.layer.sprite;
5340
+ const fr = pathFrame(sp, sizeAt(e, t) / 2, this.w, this.h);
5341
+ const ext = Math.max(fr.sx, fr.sy) * (reach + Math.hypot(fr.ox, fr.oy));
5342
+ x0 = Math.min(x0, p.x - ext);
5343
+ x1 = Math.max(x1, p.x + ext);
5344
+ y0 = Math.min(y0, p.y - ext);
5345
+ y1 = Math.max(y1, p.y + ext);
5346
+ }
5347
+ }
5348
+ const W = this.canvas.width, H = this.canvas.height;
5349
+ const bx = Math.max(0, Math.floor(m.a * x0 + m.e) - 2);
5350
+ const by = Math.max(0, Math.floor(m.d * y0 + m.f) - 2);
5351
+ const bw = Math.min(W, Math.ceil(m.a * x1 + m.e) + 2) - bx;
5352
+ const bh = Math.min(H, Math.ceil(m.d * y1 + m.f) + 2) - by;
5353
+ return bw > 0 && bh > 0 ? { x: bx, y: by, w: bw, h: bh } : { x: 0, y: 0, w: 0, h: 0 };
5354
+ }
5355
+ /** Paint layers a..b from their plate, rebuilding it if anything drawn changed. False = paint them normally. */
5356
+ paintPlate(a, b, t) {
5357
+ const sig = this.plateSig(a, b);
5358
+ let plate = this.plates.get(a);
5359
+ if (!plate || plate.sig !== sig || plate.end !== b || plate.frameW !== this.canvas.width || plate.frameH !== this.canvas.height) {
5360
+ const box = this.runBox(a, b, t) ?? { x: 0, y: 0, w: this.canvas.width, h: this.canvas.height };
5361
+ if (!plate) {
5362
+ if (this.plates.size >= 4) this.plates.delete(this.plates.keys().next().value);
5363
+ const made = this.makeCanvas(1);
5364
+ if (!made) return false;
5365
+ plate = { ...made, end: b, sig: "", box, frameW: 0, frameH: 0 };
5366
+ this.plates.set(a, plate);
5367
+ }
5368
+ plate.box = box;
5369
+ plate.frameW = this.canvas.width;
5370
+ plate.frameH = this.canvas.height;
5371
+ if (box.w === 0 || box.h === 0) {
5372
+ plate.sig = sig;
5373
+ plate.end = b;
5374
+ return true;
5375
+ }
5376
+ if (plate.canvas.width !== box.w) plate.canvas.width = box.w;
5377
+ if (plate.canvas.height !== box.h) plate.canvas.height = box.h;
5378
+ const pc = plate.ctx;
5379
+ pc.setTransform(1, 0, 0, 1, 0, 0);
5380
+ pc.globalAlpha = 1;
5381
+ pc.globalCompositeOperation = "source-over";
5382
+ pc.clearRect(0, 0, box.w, box.h);
5383
+ const m = this.ctx.getTransform();
5384
+ pc.setTransform(m.a, m.b, m.c, m.d, m.e - box.x, m.f - box.y);
5385
+ const self = this;
5386
+ const main = self.ctx;
5387
+ self.ctx = pc;
5388
+ try {
5389
+ for (let li = a; li <= b; li++) this.paintLayer(li, t);
5390
+ } finally {
5391
+ self.ctx = main;
5392
+ }
5393
+ plate.sig = sig;
5394
+ plate.end = b;
5395
+ }
5396
+ if (plate.box.w === 0 || plate.box.h === 0) return true;
5397
+ const ctx = this.ctx;
5398
+ ctx.save();
5399
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4279
5400
  ctx.globalAlpha = 1;
4280
5401
  ctx.globalCompositeOperation = "source-over";
5402
+ ctx.drawImage(plate.canvas, plate.box.x, plate.box.y);
5403
+ ctx.restore();
5404
+ return true;
4281
5405
  }
4282
5406
  /**
4283
5407
  * A `transform`, in min(w,h) units (px under `units: 'px'`), about the
@@ -5150,6 +6274,27 @@ function hexLuma(hex) {
5150
6274
  }
5151
6275
  function spriteLuma(layer, e) {
5152
6276
  const s = layer.sprite;
6277
+ if (s.kind === "polygon" && isPathPolygon(s)) {
6278
+ const base = s.colors?.[e.colorIndex] ?? s.color;
6279
+ let sum = 0, wsum = 0;
6280
+ for (const part of pathParts(s)) {
6281
+ const pc = hexLuma(part.color ?? base);
6282
+ let l;
6283
+ if (!part.fill) l = part.stroke ? hexLuma(part.stroke.color) : pc;
6284
+ else if (part.gradient) {
6285
+ let g = 0;
6286
+ for (const st of part.gradient.stops) {
6287
+ const o = st.opacity ?? 1;
6288
+ g += hexLuma(st.color) * o + pc * (1 - o);
6289
+ }
6290
+ l = g / part.gradient.stops.length;
6291
+ } else l = pc;
6292
+ const w = Math.max(1e-4, part.fill ? polygonArea(part.flat.points.map(([x, y]) => ({ x, y }))) : part.box.hw * part.box.hh * 0.1) * part.opacity;
6293
+ sum += l * w;
6294
+ wsum += w;
6295
+ }
6296
+ if (wsum > 0) return sum / wsum;
6297
+ }
5153
6298
  if (isShapedSprite(s)) {
5154
6299
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
5155
6300
  }
@@ -5294,7 +6439,7 @@ function polyShape(cx, cy, raw) {
5294
6439
  if (abs.length < 3) return null;
5295
6440
  let br = 0;
5296
6441
  for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
5297
- const inside = (x, y) => {
6442
+ const inside2 = (x, y) => {
5298
6443
  let hit = false;
5299
6444
  for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
5300
6445
  const a = abs[i];
@@ -5317,7 +6462,7 @@ function polyShape(cx, cy, raw) {
5317
6462
  cy,
5318
6463
  br,
5319
6464
  area,
5320
- contains: inside,
6465
+ contains: inside2,
5321
6466
  /**
5322
6467
  * `n` points spread along the perimeter by ARC LENGTH, not one share per
5323
6468
  * edge — a long skinny rectangle has two edges carrying almost all of its
@@ -5346,13 +6491,13 @@ function polyShape(cx, cy, raw) {
5346
6491
  for (let t = step; t > 0; t /= 8) {
5347
6492
  const qx1 = px - uy * t;
5348
6493
  const qy1 = py + ux * t;
5349
- if (inside(qx1, qy1)) {
6494
+ if (inside2(qx1, qy1)) {
5350
6495
  out.push({ x: qx1, y: qy1 });
5351
6496
  break;
5352
6497
  }
5353
6498
  const qx2 = px + uy * t;
5354
6499
  const qy2 = py - ux * t;
5355
- if (inside(qx2, qy2)) {
6500
+ if (inside2(qx2, qy2)) {
5356
6501
  out.push({ x: qx2, y: qy2 });
5357
6502
  break;
5358
6503
  }
@@ -5474,7 +6619,6 @@ function seamsWorthWarning(layer, c, entityCount) {
5474
6619
 
5475
6620
  // src/perceive.ts
5476
6621
  import { createRng as createRng2 } from "@idle-screens/core";
5477
- var GLOW_SPREAD = 2.4;
5478
6622
  var LINE_SALIENCE = 3.5;
5479
6623
  var GHOST_TAPS = 12;
5480
6624
  var POLYGON_OUTLINE_CELLS = 3;
@@ -5509,11 +6653,18 @@ function posOf(scene, e, t) {
5509
6653
  const map = scene.transforms?.get(e);
5510
6654
  return map ? map(p) : p;
5511
6655
  }
5512
- function textBox(s, e, p) {
6656
+ function fontScaleFor(spec, w, h) {
6657
+ return spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
6658
+ }
6659
+ function textFontPx(s, size, fontScale) {
5513
6660
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5514
- const fh = m ? Number(m[1]) : e.size;
6661
+ return m ? Number(m[1]) * fontScale : size;
6662
+ }
6663
+ function textBox(s, e, p, fontScale, unitScale) {
6664
+ const fh = textFontPx(s, e.size, fontScale);
5515
6665
  const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5516
- const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
6666
+ const est = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
6667
+ const fw = s.maxWidth ? Math.min(est, s.maxWidth * unitScale) : est;
5517
6668
  const align = s.align ?? "center";
5518
6669
  const baseline = s.baseline ?? "middle";
5519
6670
  return {
@@ -5559,7 +6710,24 @@ function textBlockRevealFraction(s, w, h, t) {
5559
6710
  }
5560
6711
  return rs.progress;
5561
6712
  }
5562
- function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
6713
+ function gradientAt(gr, u, v) {
6714
+ let t;
6715
+ if (gr.type === "linear") {
6716
+ const dx = gr.to[0] - gr.from[0], dy = gr.to[1] - gr.from[1];
6717
+ const len2 = dx * dx + dy * dy || 1;
6718
+ t = ((u - gr.from[0]) * dx + (v - gr.from[1]) * dy) / len2;
6719
+ } else {
6720
+ t = Math.hypot(u - gr.center[0], v - gr.center[1]) / gr.radius;
6721
+ }
6722
+ const st = gr.stops;
6723
+ t = Math.max(st[0].at, Math.min(st[st.length - 1].at, t));
6724
+ let k = 0;
6725
+ while (k < st.length - 2 && t > st[k + 1].at) k++;
6726
+ const A = st[k], B = st[Math.min(k + 1, st.length - 1)];
6727
+ const w = B.at > A.at ? (t - A.at) / (B.at - A.at) : 0;
6728
+ return { luma: hexLuma(A.color) * (1 - w) + hexLuma(B.color) * w, opacity: (A.opacity ?? 1) * (1 - w) + (B.opacity ?? 1) * w };
6729
+ }
6730
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1, evenodd = false) {
5563
6731
  const nc = c1 - c0 + 1;
5564
6732
  const cov = new Float64Array(nc * (r1 - r0 + 1));
5565
6733
  const xMin = c0 * cellW;
@@ -5592,30 +6760,52 @@ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5592
6760
  hits.sort((m, n) => m.x - n.x);
5593
6761
  let wind = 0;
5594
6762
  let start = 0;
6763
+ const filled = (w) => evenodd ? (w & 1) !== 0 : w !== 0;
5595
6764
  for (const hit of hits) {
5596
- const before = wind;
6765
+ const before = filled(wind);
5597
6766
  wind += hit.dir;
5598
- if (before === 0 && wind !== 0) start = hit.x;
5599
- else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
6767
+ const after = filled(wind);
6768
+ if (!before && after) start = hit.x;
6769
+ else if (before && !after) addSpan(r - r0, start, hit.x);
5600
6770
  }
5601
6771
  }
5602
6772
  }
5603
6773
  return cov;
5604
6774
  }
5605
- function distanceToOutline(pts, x, y) {
5606
- let best = Infinity;
5607
- for (let i = 0; i < pts.length; i++) {
5608
- const p = pts[i];
5609
- const q = pts[(i + 1) % pts.length];
5610
- const ex = q.x - p.x;
5611
- const ey = q.y - p.y;
5612
- const len2 = ex * ex + ey * ey;
5613
- const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5614
- const dx = x - (p.x + ex * u);
5615
- const dy = y - (p.y + ey * u);
5616
- best = Math.min(best, dx * dx + dy * dy);
5617
- }
5618
- return Math.sqrt(best);
6775
+ function softProfile(u) {
6776
+ if (u <= 0) return 1;
6777
+ if (u >= 1) return 0;
6778
+ return u <= 0.35 ? 1 - 0.25 * u / 0.35 : 0.75 * (1 - (u - 0.35) / 0.65);
6779
+ }
6780
+ var SOFT_DISC_MEAN = 0.3783;
6781
+ function discCellInk(cx, cy, rOuter, rInner, soft, x0, y0, cw, ch) {
6782
+ const nx = Math.max(x0, Math.min(cx, x0 + cw)) - cx;
6783
+ const ny = Math.max(y0, Math.min(cy, y0 + ch)) - cy;
6784
+ const near = Math.hypot(nx, ny);
6785
+ const fx = Math.max(Math.abs(x0 - cx), Math.abs(x0 + cw - cx));
6786
+ const fy = Math.max(Math.abs(y0 - cy), Math.abs(y0 + ch - cy));
6787
+ const far = Math.hypot(fx, fy);
6788
+ if (near >= rOuter || far <= rInner) return 0;
6789
+ if (far <= rOuter && near >= rInner) return soft ? softProfile(Math.hypot(x0 + cw / 2 - cx, y0 + ch / 2 - cy) / rOuter) : 1;
6790
+ if (cx - rOuter >= x0 && cx + rOuter <= x0 + cw && cy - rOuter >= y0 && cy + rOuter <= y0 + ch) {
6791
+ return Math.PI * (soft ? rOuter * rOuter * SOFT_DISC_MEAN : rOuter * rOuter - rInner * rInner) / (cw * ch);
6792
+ }
6793
+ const bx0 = Math.max(x0, cx - rOuter);
6794
+ const by0 = Math.max(y0, cy - rOuter);
6795
+ const sx = Math.min(x0 + cw, cx + rOuter) - bx0;
6796
+ const sy = Math.min(y0 + ch, cy + rOuter) - by0;
6797
+ if (sx <= 0 || sy <= 0) return 0;
6798
+ const step = Math.max(rOuter / 4, 1e-6);
6799
+ const ni = Math.max(4, Math.min(16, Math.ceil(sx / step)));
6800
+ const nj = Math.max(4, Math.min(16, Math.ceil(sy / step)));
6801
+ let ink = 0;
6802
+ for (let j = 0; j < nj; j++) {
6803
+ for (let i = 0; i < ni; i++) {
6804
+ const d = Math.hypot(bx0 + (i + 0.5) * sx / ni - cx, by0 + (j + 0.5) * sy / nj - cy);
6805
+ if (d <= rOuter && d >= rInner) ink += soft ? softProfile(d / rOuter) : 1;
6806
+ }
6807
+ }
6808
+ return ink / (ni * nj) * sx * sy / (cw * ch);
5619
6809
  }
5620
6810
  function luminanceGrid(spec, opts = {}) {
5621
6811
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
@@ -5712,11 +6902,14 @@ function luminanceGrid(spec, opts = {}) {
5712
6902
  const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5713
6903
  for (let r = r0; r <= r1; r++) {
5714
6904
  for (let c = c0; c <= c1; c++) {
5715
- const dx = (c + 0.5) * cellW - pos.x;
5716
- const dy = (r + 0.5) * cellH - pos.y;
5717
- const d = Math.sqrt(dx * dx + dy * dy);
5718
- if (d > rad + Math.min(cellW, cellH) / 2) continue;
5719
- const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
6905
+ let wgt = 1;
6906
+ if (soft) {
6907
+ wgt = discCellInk(pos.x, pos.y, rad, 0, true, c * cellW, r * cellH, cellW, cellH);
6908
+ if (wgt <= 0) continue;
6909
+ } else {
6910
+ const d = Math.hypot((c + 0.5) * cellW - pos.x, (r + 0.5) * cellH - pos.y);
6911
+ if (d > rad + Math.min(cellW, cellH) / 2) continue;
6912
+ }
5720
6913
  compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5721
6914
  }
5722
6915
  }
@@ -5727,17 +6920,12 @@ function luminanceGrid(spec, opts = {}) {
5727
6920
  const rot = rotationAt(e, tPass);
5728
6921
  const cr = Math.cos(rot);
5729
6922
  const sr = Math.sin(rot);
5730
- const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5731
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5732
- const glow = !!s.soft && additive;
5733
- let band = 0;
5734
- if (glow) {
5735
- const perimeter = pts.reduce((acc, q, i) => {
5736
- const n = pts[(i + 1) % pts.length];
5737
- return acc + Math.hypot(n.x - q.x, n.y - q.y);
5738
- }, 0);
5739
- band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5740
- }
6923
+ const fr = isPathPolygon(s) ? pathFrame(s, radius, w, h) : { sx: radius, sy: radius, ox: 0, oy: 0, uniform: true };
6924
+ const mapUnit = (u, v) => {
6925
+ const qx = (u - fr.ox) * fr.sx, qy = (v - fr.oy) * fr.sy;
6926
+ return { x: p.x + qx * cr - qy * sr, y: p.y + qx * sr + qy * cr };
6927
+ };
6928
+ const pts = isPathPolygon(s) ? flattenSprite(s).points.map(([u, v]) => mapUnit(u, v)) : polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5741
6929
  let minX = Infinity;
5742
6930
  let maxX = -Infinity;
5743
6931
  let minY = Infinity;
@@ -5748,34 +6936,97 @@ function luminanceGrid(spec, opts = {}) {
5748
6936
  minY = Math.min(minY, q.y);
5749
6937
  maxY = Math.max(maxY, q.y);
5750
6938
  }
5751
- const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5752
- const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5753
- const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5754
- const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
6939
+ const strokeQuadsPx = (part) => {
6940
+ if (!part.stroke) return [];
6941
+ const hw = part.stroke.width * radius / 2;
6942
+ const out = [];
6943
+ for (const l of part.flat.lines) {
6944
+ const m = l.points.map(([u, v]) => mapUnit(u, v));
6945
+ const n = l.closed ? m.length : m.length - 1;
6946
+ for (let i = 0; i < n; i++) {
6947
+ const A = m[i], B = m[(i + 1) % m.length];
6948
+ const dx = B.x - A.x, dy = B.y - A.y, len = Math.hypot(dx, dy) || 1;
6949
+ const nx = -dy / len * hw, ny = dx / len * hw;
6950
+ out.push([{ x: A.x + nx, y: A.y + ny }, { x: B.x + nx, y: B.y + ny }, { x: B.x - nx, y: B.y - ny }, { x: A.x - nx, y: A.y - ny }]);
6951
+ }
6952
+ }
6953
+ return out;
6954
+ };
6955
+ if (isPathPolygon(s)) {
6956
+ for (const part of pathParts(s)) for (const quad of strokeQuadsPx(part)) for (const q of quad) {
6957
+ minX = Math.min(minX, q.x);
6958
+ maxX = Math.max(maxX, q.x);
6959
+ minY = Math.min(minY, q.y);
6960
+ maxY = Math.max(maxY, q.y);
6961
+ }
6962
+ }
6963
+ const c0 = Math.max(0, Math.floor(minX / cellW));
6964
+ const c1 = Math.min(cols - 1, Math.floor(maxX / cellW));
6965
+ const r0 = Math.max(0, Math.floor(minY / cellH));
6966
+ const r1 = Math.min(rows - 1, Math.floor(maxY / cellH));
5755
6967
  if (c0 > c1 || r0 > r1) return;
5756
- const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5757
6968
  const nc = c1 - c0 + 1;
5758
- for (let r = r0; r <= r1; r++) {
5759
- for (let c = c0; c <= c1; c++) {
5760
- const f = cov[(r - r0) * nc + (c - c0)];
5761
- const x = (c + 0.5) * cellW;
5762
- const y = (r + 0.5) * cellH;
5763
- let wgt;
5764
- if (glow) {
5765
- const outX = Math.max(0, minX - x, x - maxX);
5766
- const outY = Math.max(0, minY - y, y - maxY);
5767
- if (f >= 1) wgt = 1;
5768
- else if (outX * outX + outY * outY >= band * band) wgt = f;
5769
- else {
5770
- const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5771
- wgt = f + (1 - f) * k * k;
6969
+ const unitAt = (x, y) => {
6970
+ const dx = x - p.x, dy = y - p.y;
6971
+ return [(dx * cr + dy * sr) / Math.max(fr.sx, 1e-6) + fr.ox, (-dx * sr + dy * cr) / Math.max(fr.sy, 1e-6) + fr.oy];
6972
+ };
6973
+ const parts = isPathPolygon(s) ? pathParts(s) : null;
6974
+ const fillPass = (ring, rule, partLum, partA, part) => {
6975
+ const cov = polygonCellCoverage(ring, cellW, cellH, c0, c1, r0, r1, rule);
6976
+ for (let r = r0; r <= r1; r++) {
6977
+ for (let c = c0; c <= c1; c++) {
6978
+ const f = cov[(r - r0) * nc + (c - c0)];
6979
+ if (!(f > 0)) continue;
6980
+ const x = (c + 0.5) * cellW;
6981
+ const y = (r + 0.5) * cellH;
6982
+ let cl = partLum, ca = 1;
6983
+ if (part?.gradient) {
6984
+ const [u, v] = unitAt(x, y);
6985
+ const [gu, gv] = gradientSpace(part, u, v);
6986
+ const g = gradientAt(part.gradient, gu, gv);
6987
+ cl = g.luma;
6988
+ ca = g.opacity;
6989
+ }
6990
+ const wgt = s.soft && !parts ? f * softProfile(Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6)) : f;
6991
+ if (wgt > 0) compose(r * cols + c, cl, a * partA * ca * Math.min(1, wgt), layer.blend);
6992
+ }
6993
+ }
6994
+ };
6995
+ if (!parts) {
6996
+ fillPass(pts, polygonFillRule(s) === "evenodd", lum, 1, null);
6997
+ return;
6998
+ }
6999
+ for (const part of parts) {
7000
+ const partLum = part.color ? hexLuma(part.color) : lum;
7001
+ if (part.fill && part.flat.points.length >= 3) {
7002
+ fillPass(part.flat.points.map(([u, v]) => mapUnit(u, v)), part.rule === "evenodd", partLum, part.opacity, part);
7003
+ }
7004
+ if (part.stroke) {
7005
+ const sa = (part.stroke.opacity ?? 1) * part.opacity;
7006
+ const sl = hexLuma(part.stroke.color);
7007
+ const scov = new Float64Array(nc * (r1 - r0 + 1));
7008
+ for (const qp of strokeQuadsPx(part)) {
7009
+ let qx0 = Infinity, qx1 = -Infinity, qy0 = Infinity, qy1 = -Infinity;
7010
+ for (const q of qp) {
7011
+ qx0 = Math.min(qx0, q.x);
7012
+ qx1 = Math.max(qx1, q.x);
7013
+ qy0 = Math.min(qy0, q.y);
7014
+ qy1 = Math.max(qy1, q.y);
7015
+ }
7016
+ const a0 = Math.max(c0, Math.floor(qx0 / cellW)), a1 = Math.min(c1, Math.floor(qx1 / cellW));
7017
+ const b0 = Math.max(r0, Math.floor(qy0 / cellH)), b1 = Math.min(r1, Math.floor(qy1 / cellH));
7018
+ if (a0 > a1 || b0 > b1) continue;
7019
+ const qc = polygonCellCoverage(qp, cellW, cellH, a0, a1, b0, b1);
7020
+ const qn = a1 - a0 + 1;
7021
+ for (let r = b0; r <= b1; r++) for (let c = a0; c <= a1; c++) {
7022
+ const i = (r - r0) * nc + (c - c0);
7023
+ scov[i] = Math.min(1, scov[i] + qc[(r - b0) * qn + (c - a0)]);
5772
7024
  }
5773
- } else if (s.soft) {
5774
- wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5775
- } else {
5776
- wgt = f;
5777
7025
  }
5778
- if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
7026
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
7027
+ const f = scov[(r - r0) * nc + (c - c0)];
7028
+ if (f > 0) compose(r * cols + c, sl, a * sa * f, layer.blend);
7029
+ }
5779
7030
  }
5780
7031
  }
5781
7032
  };
@@ -5791,7 +7042,7 @@ function luminanceGrid(spec, opts = {}) {
5791
7042
  const p = posOf(scene, e, tPass);
5792
7043
  const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5793
7044
  const s = layer.sprite;
5794
- if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
7045
+ if (s.kind === "polygon" && (sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH) || isPathPolygon(s) && ((s.span ?? "box") !== "box" || s.origin !== void 0))) {
5795
7046
  splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5796
7047
  continue;
5797
7048
  }
@@ -5845,7 +7096,7 @@ function luminanceGrid(spec, opts = {}) {
5845
7096
  let halfX = sz / 2;
5846
7097
  let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
5847
7098
  if (s.kind === "text") {
5848
- const box = textBox(s, e, p);
7099
+ const box = textBox(s, e, p, fontScaleFor(spec, w, h), scale);
5849
7100
  centerX = box.cx;
5850
7101
  centerY = box.cy;
5851
7102
  halfX = box.halfX;
@@ -5872,45 +7123,24 @@ function luminanceGrid(spec, opts = {}) {
5872
7123
  const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5873
7124
  const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
5874
7125
  const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5875
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5876
- const glow = soft && additive;
5877
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
5878
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
5879
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
5880
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
5881
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5882
- const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
7126
+ const c0 = Math.max(0, Math.floor((centerX - halfX) / cellW));
7127
+ const c1 = Math.min(cols - 1, Math.floor((centerX + halfX) / cellW));
7128
+ const r0 = Math.max(0, Math.floor((centerY - halfY) / cellH));
7129
+ const r1 = Math.min(rows - 1, Math.floor((centerY + halfY) / cellH));
5883
7130
  const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
5884
- const byArea = s.kind === "rect" || s.kind === "bar";
7131
+ const byArea = s.kind === "rect" || s.kind === "bar" || s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock";
5885
7132
  for (let r = r0; r <= r1; r++) {
5886
7133
  const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
5887
7134
  if (fy <= 0) continue;
5888
7135
  for (let c = c0; c <= c1; c++) {
5889
- const dx = (c + 0.5) * cellW - centerX;
5890
- const dy = (r + 0.5) * cellH - centerY;
5891
7136
  if (circular) {
5892
- const d = Math.sqrt(dx * dx + dy * dy);
5893
- const reach = glow ? reachX : halfX;
5894
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
5895
- if (s.kind === "ring") {
5896
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
5897
- if (d < halfX - strokeW) continue;
5898
- }
5899
- let wgt;
5900
- if (glow) {
5901
- const core = halfX;
5902
- if (d <= core) wgt = 1;
5903
- else {
5904
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
5905
- wgt = f * f;
5906
- }
5907
- } else {
5908
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5909
- }
7137
+ const inner = s.kind === "ring" ? Math.max(0, halfX - (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) : 0;
7138
+ const wgt = discCellInk(centerX, centerY, halfX, inner, soft, c * cellW, r * cellH, cellW, cellH);
7139
+ if (wgt <= 0) continue;
5910
7140
  compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
5911
7141
  } else if (byArea) {
5912
7142
  const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
5913
- if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
7143
+ if (fx > 0) compose(r * cols + c, lum, a * inkWeight * shapeWeight * fx * fy, layer.blend);
5914
7144
  } else {
5915
7145
  compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5916
7146
  }
@@ -5919,9 +7149,8 @@ function luminanceGrid(spec, opts = {}) {
5919
7149
  }
5920
7150
  if (layer.links) {
5921
7151
  const positions = entities.map((e) => posOf(scene, e, tPass));
5922
- const maxDistPx = layer.links.maxDist * scale;
5923
7152
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5924
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
7153
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false && motionWraps, w, h);
5925
7154
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
5926
7155
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
5927
7156
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
@@ -6062,8 +7291,7 @@ function textSprites(spec, opts = {}) {
6062
7291
  }
6063
7292
  if (layer.sprite.kind !== "text") return;
6064
7293
  const s = layer.sprite;
6065
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
6066
- const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
7294
+ const sizePx = textFontPx(s, entities[0] ? sizeAt(entities[0], t) : 0, fontScaleFor(spec, scene.w, scene.h));
6067
7295
  out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
6068
7296
  });
6069
7297
  return out;
@@ -6134,7 +7362,7 @@ function rawDominance(spec, opts = {}) {
6134
7362
  const meanTaper = s.taper ? 0.64 : 1;
6135
7363
  entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
6136
7364
  } else if (s.kind === "text") {
6137
- const box = textBox(s, e, { x: 0, y: 0 });
7365
+ const box = textBox(s, e, { x: 0, y: 0 }, fontScaleFor(spec, w, h), scale);
6138
7366
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
6139
7367
  } else if (s.kind === "textBlock") {
6140
7368
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
@@ -6154,7 +7382,7 @@ function rawDominance(spec, opts = {}) {
6154
7382
  if (layer.links && entities.length > 1) {
6155
7383
  const positions = entities.map((e) => posOf(scene, e, t));
6156
7384
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6157
- const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
7385
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false && motionWraps, w, h);
6158
7386
  const la = (layer.links.alpha ?? 0.6) * lifeA;
6159
7387
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6160
7388
  const lw = (layer.links.width ?? defaultWidth) * scale;
@@ -6608,8 +7836,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6608
7836
  }
6609
7837
  if (layer.links) {
6610
7838
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6611
- const maxDistPx = layer.links.maxDist * scale;
6612
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
7839
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h);
6613
7840
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6614
7841
  const lwPx = (layer.links.width ?? defaultWidth) * scale;
6615
7842
  const la = layer.links.alpha ?? 1;
@@ -6668,9 +7895,9 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6668
7895
  if (!layer.links || layer.links.mode === "chain") continue;
6669
7896
  const entities = allEntities[li];
6670
7897
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6671
- const maxDist = layer.links.maxDist * scale;
6672
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6673
- const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
7898
+ const k = layer.links.k ?? 1;
7899
+ const pairs = linkPairs(positions, k, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h);
7900
+ const maxPossible = Math.min(entities.length * k, entities.length * (entities.length - 1) / 2);
6674
7901
  if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
6675
7902
  warnings.push({
6676
7903
  path: `layers[${li}].links`,
@@ -6934,20 +8161,20 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6934
8161
  const c = positionAt(e, 0, w, h);
6935
8162
  const dx = pt.x - c.x;
6936
8163
  const dy = pt.y - c.y;
6937
- let inside = false;
6938
- if (sp.kind === "circle") inside = Math.hypot(dx, dy) <= e.size / 2;
8164
+ let inside2 = false;
8165
+ if (sp.kind === "circle") inside2 = Math.hypot(dx, dy) <= e.size / 2;
6939
8166
  else if (sp.kind === "rect") {
6940
8167
  const rot = ((e.rotate ?? 0) % Math.PI + Math.PI) % Math.PI;
6941
8168
  const quarter = Math.abs(rot - Math.PI / 2) < 0.01;
6942
8169
  if (rot > 0.01 && rot < Math.PI - 0.01 && !quarter) continue;
6943
8170
  const hx = (quarter ? e.size2 ?? e.size : e.size) / 2;
6944
8171
  const hy = (quarter ? e.size : e.size2 ?? e.size) / 2;
6945
- inside = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
8172
+ inside2 = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
6946
8173
  } else {
6947
8174
  if (e.rotate) continue;
6948
- inside = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
8175
+ inside2 = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
6949
8176
  }
6950
- if (!inside) continue;
8177
+ if (!inside2) continue;
6951
8178
  plate = sourceOverPlate(plate, hexRgb(hex), Math.min(1, e.alpha * a));
6952
8179
  covered = true;
6953
8180
  }
@@ -6955,13 +8182,13 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6955
8182
  return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
6956
8183
  }
6957
8184
  function pointInPolygon(x, y, pts) {
6958
- let inside = false;
8185
+ let inside2 = false;
6959
8186
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
6960
8187
  const a = pts[i];
6961
8188
  const b = pts[j];
6962
- if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
8189
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside2 = !inside2;
6963
8190
  }
6964
- return inside;
8191
+ return inside2;
6965
8192
  }
6966
8193
  function entityReachesBox(e, r, box, w, h) {
6967
8194
  if (e.motion === "static") {
@@ -7113,10 +8340,10 @@ function describeScene(spec, options = {}) {
7113
8340
  let connectedComponents = null;
7114
8341
  let isolatedNodes = null;
7115
8342
  if (layer.links) {
7116
- const maxDist = layer.links.maxDist * scale;
7117
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
8343
+ const n = entities.length;
8344
+ const pairs = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h).map(({ i, j }) => [i, j]);
7118
8345
  linksDrawn = pairs.length;
7119
- linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
8346
+ linksExpected = layer.links.mode === "chain" ? Math.max(0, n - 1) + (layer.links.closed && n > 2 ? 1 : 0) : Math.min(n * (layer.links.k ?? 1), n * (n - 1) / 2);
7120
8347
  const parent = Array.from({ length: entities.length }, (_, i) => i);
7121
8348
  const find = (x) => {
7122
8349
  while (parent[x] !== x) {
@@ -9617,6 +10844,7 @@ export {
9617
10844
  LIMITS,
9618
10845
  LOBBY_TALK_SPEC,
9619
10846
  MATRIX_RAIN_SPEC,
10847
+ MAX_PATH_POINTS,
9620
10848
  MURMUR_SPEC,
9621
10849
  NOSTALGHIA_CANDLE_SPEC,
9622
10850
  ORRERY_SPEC,
@@ -9672,6 +10900,7 @@ export {
9672
10900
  fieldRgbAt,
9673
10901
  fieldSampleTime,
9674
10902
  finishStrength,
10903
+ flattenPath,
9675
10904
  grainOffset,
9676
10905
  grainTilePixels,
9677
10906
  graphemeClusters,
@@ -9679,12 +10908,15 @@ export {
9679
10908
  headingAt,
9680
10909
  hexToRgb255,
9681
10910
  ignoredPropertyPaths,
10911
+ isPathPolygon,
9682
10912
  latticeHash,
9683
10913
  layerCohesion,
10914
+ lerpPathD,
9684
10915
  lerpSpec,
9685
10916
  lerpValue,
9686
10917
  lifeAlphaAt,
9687
10918
  linkEdges,
10919
+ linkMaxDistPx,
9688
10920
  linkPairs,
9689
10921
  luminanceGrid,
9690
10922
  manifestFor,
@@ -9693,6 +10925,8 @@ export {
9693
10925
  motionStats,
9694
10926
  nextKeyAfter,
9695
10927
  normalizeSeed,
10928
+ pathFrame,
10929
+ pathParts,
9696
10930
  perceiveScene,
9697
10931
  perceiveSequenceFrame,
9698
10932
  pickScreenOp,