@idle-screens/schema 3.12.0 → 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
@@ -279,11 +279,21 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
279
279
  const walk = (a, b, key, root = false) => {
280
280
  if (typeof a === "number" && typeof b === "number") {
281
281
  const v = a + (b - a) * kk;
282
- return key === "count" ? Math.max(1, Math.round(v)) : v;
282
+ if (key === "count") return Math.max(1, Math.round(v));
283
+ if (key === "octaves") return Math.round(v);
284
+ if (key === "quantize") {
285
+ const q = Math.round(v);
286
+ return q < 2 ? 0 : q;
287
+ }
288
+ return v;
283
289
  }
284
290
  if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
285
291
  return lerpHex(a, b, kk);
286
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
+ }
287
297
  if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
288
298
  return b.map((bv, i) => walk(a[i], bv, i));
289
299
  }
@@ -300,6 +310,33 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
300
310
  };
301
311
  return walk(from, to, rootKey, specRoot);
302
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
+ }
303
340
  function textStringsOf(sprite) {
304
341
  if (sprite.kind === "text") return sprite.strings;
305
342
  if (sprite.kind === "textBlock") return [sprite.text];
@@ -356,7 +393,7 @@ function steerablePaths(spec) {
356
393
  if (!spec || typeof spec !== "object") return [];
357
394
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer", "group", "origin"]);
358
395
  const SKIP_ROOT = /* @__PURE__ */ new Set(["timeline"]);
359
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
396
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands", "parts"]);
360
397
  const layers = spec.layers;
361
398
  const shadowedRootKeys = new Set(
362
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)) : []
@@ -668,6 +705,654 @@ function timelineSampleTimesAfter(spec, now, dense = false, only) {
668
705
  return local.filter((s) => s > now);
669
706
  }
670
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
+
671
1356
  // src/validate.ts
672
1357
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
673
1358
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -713,7 +1398,8 @@ var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "c
713
1398
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
714
1399
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
715
1400
  var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
716
- 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"]);
717
1403
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
718
1404
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
719
1405
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
@@ -853,6 +1539,23 @@ function validateSpecCore(spec) {
853
1539
  err("layers", "must be a non-empty array");
854
1540
  } else {
855
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
+ }
856
1559
  let total = 0;
857
1560
  spec.layers.forEach((layer, i) => {
858
1561
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
@@ -1226,10 +1929,20 @@ function validateLayer(layer, path, err, warn, spec) {
1226
1929
  if (layer.links !== void 0) {
1227
1930
  if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
1228
1931
  else {
1229
- if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
1230
- 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
+ );
1231
1945
  }
1232
- positiveScalar(layer.links.maxDist, `${path}.links.maxDist`, err);
1233
1946
  if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
1234
1947
  if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
1235
1948
  err(`${path}.links.alpha`, "must be 0..1");
@@ -1470,6 +2183,34 @@ function validateSprite(sprite, path, err, warn, spec) {
1470
2183
  err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
1471
2184
  }
1472
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
+ }
1473
2214
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
1474
2215
  color(sprite.color, `${path}.color`, err);
1475
2216
  validatePalette(sprite, path, err);
@@ -1726,6 +2467,58 @@ function validateMotion(motion, path, err, warn, spec) {
1726
2467
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
1727
2468
  }
1728
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
+ }
1729
2522
  function validateShapePoints(points, path, min, err) {
1730
2523
  if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
1731
2524
  return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
@@ -1922,110 +2715,6 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
1922
2715
  return seq;
1923
2716
  }
1924
2717
 
1925
- // src/shapes.ts
1926
- function isShapedSprite(s) {
1927
- return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
1928
- }
1929
- function barFraction(s, index) {
1930
- const n = s.values.length;
1931
- if (n === 0) return 0;
1932
- const v = s.values[(index % n + n) % n] ?? 0;
1933
- const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
1934
- if (!(max > 0)) return 0;
1935
- return Math.max(0, Math.min(1, v / max));
1936
- }
1937
- function barBox(direction, len, thick) {
1938
- switch (direction) {
1939
- case "left":
1940
- return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1941
- case "up":
1942
- return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
1943
- case "down":
1944
- return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
1945
- default:
1946
- return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1947
- }
1948
- }
1949
- var DEFAULT_POLYGON_SIDES = 6;
1950
- var STROKE_SAMPLES = 24;
1951
- function polygonPoints(s, radius) {
1952
- if (s.points && s.points.length >= 3) {
1953
- return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
1954
- }
1955
- const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
1956
- const out = [];
1957
- for (let k = 0; k < n; k++) {
1958
- const a = -Math.PI / 2 + 2 * Math.PI * k / n;
1959
- out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
1960
- }
1961
- return out;
1962
- }
1963
- function polygonArea(pts) {
1964
- let a = 0;
1965
- for (let i = 0; i < pts.length; i++) {
1966
- const p = pts[i];
1967
- const q = pts[(i + 1) % pts.length];
1968
- a += p.x * q.y - q.x * p.y;
1969
- }
1970
- return Math.abs(a) / 2;
1971
- }
1972
- function polygonFill(s, radius) {
1973
- if (radius <= 0) return 0;
1974
- const disc = Math.PI * radius * radius;
1975
- return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
1976
- }
1977
- function catmullRom(p0, p1, p2, p3, u) {
1978
- const u2 = u * u;
1979
- const u3 = u2 * u;
1980
- return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
1981
- }
1982
- function strokeSamples(s, halfSize, n) {
1983
- const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
1984
- const m = pts.length;
1985
- if (m < 2) return pts;
1986
- const smooth = s.curve !== "linear" && m >= 3;
1987
- const segs = m - 1;
1988
- n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
1989
- const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
1990
- const out = [];
1991
- for (let i = 0; i < n; i++) {
1992
- const u = i / (n - 1) * segs;
1993
- const seg = Math.min(segs - 1, Math.floor(u));
1994
- const local = u - seg;
1995
- const p1 = at(seg);
1996
- const p2 = at(seg + 1);
1997
- if (!smooth) {
1998
- out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
1999
- } else {
2000
- const p0 = at(seg - 1);
2001
- const p3 = at(seg + 2);
2002
- out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
2003
- }
2004
- }
2005
- return out;
2006
- }
2007
- function pathLength(pts) {
2008
- let l = 0;
2009
- for (let i = 1; i < pts.length; i++) {
2010
- const dx = pts[i].x - pts[i - 1].x;
2011
- const dy = pts[i].y - pts[i - 1].y;
2012
- l += Math.sqrt(dx * dx + dy * dy);
2013
- }
2014
- return l;
2015
- }
2016
- function strokeTaper(u) {
2017
- return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
2018
- }
2019
- function strokeWidthPx(s, scale) {
2020
- return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2021
- }
2022
- function featherAlphas(steps, additive) {
2023
- const out = [];
2024
- for (let k = 1; k <= steps; k++) out.push(additive ? 1 / steps : 1 / (steps - k + 1));
2025
- return out;
2026
- }
2027
- var FEATHER_STEPS = 6;
2028
-
2029
2718
  // src/simulate.ts
2030
2719
  function entityScales(spec, w, h) {
2031
2720
  const scale = spec.units === "px" ? 1 : Math.min(w, h);
@@ -2547,9 +3236,13 @@ function wrapDist(pi, pj, doWrap, w, h) {
2547
3236
  }
2548
3237
  return Math.sqrt(dx * dx + dy * dy);
2549
3238
  }
3239
+ function linkMaxDistPx(links, scale) {
3240
+ return links.maxDist === void 0 ? Infinity : links.maxDist * scale;
3241
+ }
2550
3242
  function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2551
3243
  const n = positions.length;
2552
3244
  const mode = links.mode ?? "nearest";
3245
+ const k = links.k ?? 1;
2553
3246
  if (mode === "chain") {
2554
3247
  const out = [];
2555
3248
  for (let i = 0; i < n - 1; i++) {
@@ -2563,7 +3256,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2563
3256
  const edges = /* @__PURE__ */ new Set();
2564
3257
  const out = [];
2565
3258
  for (let i = 0; i < n; i++) {
2566
- for (let mIdx = 1; mIdx <= links.k; mIdx++) {
3259
+ for (let mIdx = 1; mIdx <= k; mIdx++) {
2567
3260
  const j = (i + mIdx * stride) % n;
2568
3261
  if (j === i) continue;
2569
3262
  const dist = wrapDist(positions[i], positions[j], doWrap, w, h);
@@ -2577,7 +3270,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2577
3270
  }
2578
3271
  return out;
2579
3272
  }
2580
- return linkPairs(positions, links.k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
3273
+ return linkPairs(positions, k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
2581
3274
  i,
2582
3275
  j,
2583
3276
  dist: wrapDist(positions[i], positions[j], doWrap, w, h)
@@ -3088,6 +3781,79 @@ function screen(fp, pat, alpha, off, w, h) {
3088
3781
  p.restore();
3089
3782
  }
3090
3783
 
3784
+ // src/trail.ts
3785
+ function trailFillBudget(layerCount) {
3786
+ return Math.max(4, Math.min(48, Math.floor(240 / Math.max(1, layerCount))));
3787
+ }
3788
+ function trailEchoes(trail, t, headAlpha, headSize, posAt, w, h, wrap2, fillBudget, minAlpha = 0, additive = false) {
3789
+ const fade = trail.fade ?? 1;
3790
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
3791
+ const alphaAtK = (k) => headAlpha * (1 - k * fade);
3792
+ const radiusAtK = (k) => headSize / 2 * (1 - k * 0.7);
3793
+ const jumps = (a, b) => wrap2 && (Math.abs(b.x - a.x) > w / 2 || Math.abs(b.y - a.y) > h / 2);
3794
+ const head = posAt(t);
3795
+ const base = [];
3796
+ let prev = head;
3797
+ for (let s = 1; s <= n; s++) {
3798
+ const k = s / n;
3799
+ const pastT = t - k * trail.length;
3800
+ if (pastT < 0) break;
3801
+ const pos = posAt(pastT);
3802
+ if (jumps(prev, pos)) break;
3803
+ prev = pos;
3804
+ const a = alphaAtK(k);
3805
+ if (a <= minAlpha) break;
3806
+ const r = radiusAtK(k);
3807
+ if (r < 0.2) break;
3808
+ base.push({ t: pastT, x: pos.x, y: pos.y, k, alpha: a, r });
3809
+ }
3810
+ if (base.length === 0) return base;
3811
+ const need = [];
3812
+ let total = 0;
3813
+ for (let i = 0; i < base.length; i++) {
3814
+ const a = i === 0 ? head : base[i - 1];
3815
+ const b = base[i];
3816
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
3817
+ const m = d > b.r ? Math.ceil(d / b.r) - 1 : 0;
3818
+ need.push(m);
3819
+ total += m;
3820
+ }
3821
+ if (total === 0) return base;
3822
+ const scale = total > fillBudget ? fillBudget / total : 1;
3823
+ const out = [];
3824
+ let last = head;
3825
+ for (let i = 0; i < base.length; i++) {
3826
+ const b = base[i];
3827
+ const m = Math.floor(need[i] * scale);
3828
+ if (m === 0) {
3829
+ out.push(b);
3830
+ last = b;
3831
+ continue;
3832
+ }
3833
+ const a = i === 0 ? head : base[i - 1];
3834
+ const spacing = Math.hypot(b.x - a.x, b.y - a.y) / (m + 1);
3835
+ const share = (alpha, r) => {
3836
+ const k = Math.min(1, spacing / (2 * r));
3837
+ return additive ? alpha * k : 1 - Math.pow(1 - Math.min(1, alpha), k);
3838
+ };
3839
+ const t0 = i === 0 ? t : base[i - 1].t;
3840
+ const k0 = i === 0 ? 0 : base[i - 1].k;
3841
+ for (let j = 1; j <= m; j++) {
3842
+ const f = j / (m + 1);
3843
+ const tt = t0 + (b.t - t0) * f;
3844
+ const k = k0 + (b.k - k0) * f;
3845
+ const pos = posAt(tt);
3846
+ if (jumps(last, pos)) continue;
3847
+ last = pos;
3848
+ const r = radiusAtK(k);
3849
+ out.push({ t: tt, x: pos.x, y: pos.y, k, alpha: share(alphaAtK(k), r), r });
3850
+ }
3851
+ out.push({ ...b, alpha: share(b.alpha, b.r) });
3852
+ last = b;
3853
+ }
3854
+ return out;
3855
+ }
3856
+
3091
3857
  // src/compile.ts
3092
3858
  var DEFAULT_STEER_DUR = 1e3;
3093
3859
  function applyRetainedDeltas(spec, deltas) {
@@ -3183,6 +3949,45 @@ function overridesStayValid(authored, overrides, now) {
3183
3949
  }
3184
3950
  return true;
3185
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
+ }
3186
3991
  var SpecInstance = class {
3187
3992
  constructor(spec, ctx, opts = {}) {
3188
3993
  this.spec = spec;
@@ -3285,6 +4090,29 @@ var SpecInstance = class {
3285
4090
  fieldShortSide;
3286
4091
  /** The `field` background's cached raster; null until a field is first painted. */
3287
4092
  field = null;
4093
+ featherWhite = /* @__PURE__ */ new Map();
4094
+ /** Per-entity pre-rendered feathered rects (see `blitFeather`), and the device pixels they hold. */
4095
+ featherBlits = /* @__PURE__ */ new WeakMap();
4096
+ featherBlitPx = 0;
4097
+ featherBlitN = 0;
4098
+ paintSeq = 0;
4099
+ /** Per layer: ribbon area painted this frame (px²), and whether its trails ribbon (see ribbonAllowed). */
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;
4115
+ featherTints = /* @__PURE__ */ new Map();
3288
4116
  /**
3289
4117
  * The presentation pass when this instance applies a `finish`: the
3290
4118
  * visible canvas plus the cached screen tiles. When set, `this.canvas` is
@@ -3449,36 +4277,81 @@ var SpecInstance = class {
3449
4277
  const trail = built.layer.trail;
3450
4278
  if (!trail) return;
3451
4279
  const { ctx, w, h } = this;
3452
- const fade = trail.fade ?? 1;
3453
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
3454
4280
  const headAlpha = alphaAt(e, t) * lifeA;
3455
4281
  const headSize = sizeAt(e, t);
3456
4282
  const sprite = built.layer.sprite;
4283
+ const shaped = isShapedSprite(sprite) && sprite.kind !== "circle";
4284
+ const isSoft = sprite.kind === "circle" && !!sprite.soft;
4285
+ const hardCircle = sprite.kind === "circle" && !sprite.soft;
4286
+ const ribbon = hardCircle && this.ribbonAllowed(built);
4287
+ const posAt = (pt) => this.entityPos(e, pt, parentE);
4288
+ const fill = shaped || hardCircle ? 0 : trailFillBudget(built.entities.length);
4289
+ const echoes = trailEchoes(trail, t, headAlpha, headSize, posAt, w, h, built.layer.wrap !== false, fill, 0, built.layer.blend === "lighter");
4290
+ if (echoes.length === 0) return;
4291
+ const head = posAt(t);
4292
+ if (hardCircle) {
4293
+ const rb = this.ribbonBudget.get(built);
4294
+ let prev = head;
4295
+ let r0 = headSize / 2;
4296
+ for (const ec of echoes) {
4297
+ rb.area += Math.hypot(ec.x - prev.x, ec.y - prev.y) * (r0 + ec.r);
4298
+ prev = ec;
4299
+ r0 = ec.r;
4300
+ }
4301
+ }
4302
+ if (shaped) {
4303
+ for (const ec of echoes) this.drawEntity(built, e, ec.t, 1, parentE, void 0, { x: ec.x, y: ec.y, size: ec.r * 2, alpha: ec.alpha });
4304
+ return;
4305
+ }
3457
4306
  const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
3458
- const isSoft = sprite.kind === "circle" && sprite.soft;
3459
- const wrap2 = built.layer.wrap !== false;
3460
- const head = this.entityPos(e, t, parentE);
3461
- let prevX = head.x;
3462
- let prevY = head.y;
3463
- for (let s = 1; s <= n; s++) {
3464
- const k = s / n;
3465
- const pastT = t - k * trail.length;
3466
- if (pastT < 0) break;
3467
- const pos = this.entityPos(e, pastT, parentE);
3468
- if (wrap2) {
3469
- const dx = pos.x - prevX;
3470
- const dy = pos.y - prevY;
3471
- if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
3472
- }
3473
- prevX = pos.x;
3474
- prevY = pos.y;
3475
- const a = headAlpha * (1 - k * fade);
3476
- if (a <= 0) break;
3477
- const r = headSize / 2 * (1 - k * 0.7);
3478
- if (r < 0.2) break;
3479
- ctx.globalAlpha = a;
4307
+ if (ribbon) {
4308
+ const pts = [head];
4309
+ const radii = [headSize / 2];
4310
+ const alphas = [headAlpha];
4311
+ let prevT = t;
4312
+ for (const ec of echoes) {
4313
+ const p0 = pts[pts.length - 1];
4314
+ const r0 = radii[radii.length - 1];
4315
+ const a0 = alphas[alphas.length - 1];
4316
+ const sub = Math.max(1, Math.min(8, Math.ceil(Math.hypot(ec.x - p0.x, ec.y - p0.y) / 12)));
4317
+ for (let j = 1; j < sub; j++) {
4318
+ const f = j / sub;
4319
+ pts.push(posAt(prevT + (ec.t - prevT) * f));
4320
+ radii.push(r0 + (ec.r - r0) * f);
4321
+ alphas.push(a0 + (ec.alpha - a0) * f);
4322
+ }
4323
+ pts.push(ec);
4324
+ radii.push(ec.r);
4325
+ alphas.push(ec.alpha);
4326
+ prevT = ec.t;
4327
+ }
4328
+ const { left, right } = ribbonSides(pts, radii);
4329
+ ctx.fillStyle = resolvedColor;
4330
+ const tol = Math.max(4e-3, headAlpha * 0.04);
4331
+ let from = 0;
4332
+ while (from < pts.length - 1) {
4333
+ const a0 = alphas[from + 1];
4334
+ let to = from + 1;
4335
+ let sum = a0;
4336
+ while (to < pts.length - 1 && Math.abs(alphas[to + 1] - a0) <= tol) {
4337
+ sum += alphas[to + 1];
4338
+ to++;
4339
+ }
4340
+ ctx.globalAlpha = sum / (to - from);
4341
+ ctx.beginPath();
4342
+ ctx.moveTo(left[from].x, left[from].y);
4343
+ for (let i = from + 1; i <= to; i++) ctx.lineTo(left[i].x, left[i].y);
4344
+ for (let i = to; i >= from; i--) ctx.lineTo(right[i].x, right[i].y);
4345
+ ctx.closePath();
4346
+ ctx.fill();
4347
+ from = to;
4348
+ }
4349
+ return;
4350
+ }
4351
+ for (const ec of echoes) {
4352
+ ctx.globalAlpha = ec.alpha;
3480
4353
  if (isSoft) {
3481
- const g = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, r);
4354
+ const g = ctx.createRadialGradient(ec.x, ec.y, 0, ec.x, ec.y, ec.r);
3482
4355
  g.addColorStop(0, resolvedColor);
3483
4356
  g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3484
4357
  g.addColorStop(1, hexToRgba(resolvedColor, 0));
@@ -3487,18 +4360,473 @@ var SpecInstance = class {
3487
4360
  ctx.fillStyle = resolvedColor;
3488
4361
  }
3489
4362
  ctx.beginPath();
3490
- ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
4363
+ ctx.arc(ec.x, ec.y, ec.r, 0, Math.PI * 2);
3491
4364
  ctx.fill();
3492
4365
  }
3493
4366
  }
3494
- drawEntity(built, e, t, lifeA, parentE, paint) {
4367
+ /**
4368
+ * Whether this layer's hard-circle trails draw as ribbons this frame. A
4369
+ * ribbon paints its trail's whole length, so a layer of many fast, long
4370
+ * trails can paint far more pixels than its dots did — under `lighter` on
4371
+ * a CPU rasteriser that was 2.4× the frame time (60 trails, 3 s, 0.4/s).
4372
+ * Past a tenth of the viewport's area in ribbon (measured on the
4373
+ * previous frame) the layer falls back to the time-sample discs it always
4374
+ * drew; it returns to ribbons below 70 % of that, so a layer near the line
4375
+ * does not flicker between the two.
4376
+ */
4377
+ ribbonAllowed(built) {
4378
+ let rb = this.ribbonBudget.get(built);
4379
+ if (!rb) {
4380
+ rb = { seq: this.paintSeq, area: 0, ribbon: true };
4381
+ this.ribbonBudget.set(built, rb);
4382
+ }
4383
+ if (rb.seq !== this.paintSeq) {
4384
+ const budget = 0.1 * this.w * this.h;
4385
+ if (rb.ribbon && rb.area > budget) rb.ribbon = false;
4386
+ else if (!rb.ribbon && rb.area < 0.7 * budget) rb.ribbon = true;
4387
+ rb.seq = this.paintSeq;
4388
+ rb.area = 0;
4389
+ }
4390
+ return rb.ribbon;
4391
+ }
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
+ }
4664
+ makeCanvas(n) {
4665
+ try {
4666
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(n, n);
4667
+ canvas.width = n;
4668
+ canvas.height = n;
4669
+ const c = canvas.getContext("2d");
4670
+ return c ? { canvas, ctx: c } : null;
4671
+ } catch {
4672
+ return null;
4673
+ }
4674
+ }
4675
+ /**
4676
+ * A feathered rect from a per-entity bitmap of it, pre-rendered at device
4677
+ * resolution with its rotation baked in, copied 1:1 at a whole-pixel
4678
+ * offset. A smooth falloff is a sampled image draw — on a CPU rasteriser
4679
+ * (a Raspberry Pi wall) ~3× the six flat fills it replaced, ~10× when
4680
+ * rotated and stretched — while a 1:1 integer copy costs about one flat
4681
+ * fill. The bitmap is rebuilt only when the rect's size, rotation or
4682
+ * feather changes (in device pixels, to a quarter pixel / 0.06°), and
4683
+ * re-tinted (one copy + one flat fill) only when its colour changes, so a
4684
+ * drifting block or a colour glide stays cheap. A rect whose shape
4685
+ * changes every frame (`grow`, `spin`), a canvas under a rotating or
4686
+ * mirroring transform, or a scene past the 8 Mpx cache budget draws the
4687
+ * stretched mask directly instead. Returns false when it did not draw.
4688
+ */
4689
+ blitFeather(e, p, sz, rh, rot, color2, feather) {
4690
+ const ctx = this.ctx;
4691
+ const m = typeof ctx.getTransform === "function" ? ctx.getTransform() : null;
4692
+ if (!m || m.b !== 0 || m.c !== 0 || m.a <= 0 || m.d <= 0) return false;
4693
+ const cos = Math.abs(Math.cos(rot));
4694
+ const sin = Math.abs(Math.sin(rot));
4695
+ const bw = Math.ceil((cos * sz + sin * rh) * m.a) + 2;
4696
+ const bh = Math.ceil((sin * sz + cos * rh) * m.d) + 2;
4697
+ const shapeKey = `${feather}|${Math.round(sz * m.a * 4)}|${Math.round(rh * m.d * 4)}|${Math.round(rot * 1e3)}|${bw}x${bh}`;
4698
+ let b = this.featherBlits.get(e);
4699
+ if (!b || b.shapeKey !== shapeKey) {
4700
+ if (b) {
4701
+ if (b.seenKey !== shapeKey) {
4702
+ b.seenKey = shapeKey;
4703
+ b.seenAt = this.paintSeq;
4704
+ }
4705
+ if (b.seenAt === this.paintSeq) return false;
4706
+ }
4707
+ const px = bw * bh;
4708
+ const reuse = b && b.w === bw && b.h === bh;
4709
+ if (!reuse && this.featherBlitPx + px - (b ? b.w * b.h : 0) > 8e6) return false;
4710
+ const white = this.featherWhite.get(feather);
4711
+ if (!white) return false;
4712
+ const wc = reuse ? b.white : this.makeCanvas(1);
4713
+ const tc = reuse ? b.tint : this.makeCanvas(1);
4714
+ if (!wc || !tc) return false;
4715
+ if (!reuse) {
4716
+ wc.canvas.width = bw;
4717
+ wc.canvas.height = bh;
4718
+ tc.canvas.width = bw;
4719
+ tc.canvas.height = bh;
4720
+ this.featherBlitPx += px - (b ? b.w * b.h : 0);
4721
+ }
4722
+ const w2 = wc.ctx;
4723
+ w2.setTransform(1, 0, 0, 1, 0, 0);
4724
+ w2.clearRect(0, 0, bw, bh);
4725
+ w2.translate(bw / 2, bh / 2);
4726
+ w2.scale(m.a, m.d);
4727
+ if (rot) w2.rotate(rot);
4728
+ w2.imageSmoothingEnabled = true;
4729
+ w2.drawImage(white, -sz / 2, -rh / 2, sz, rh);
4730
+ w2.setTransform(1, 0, 0, 1, 0, 0);
4731
+ b = { shapeKey, w: bw, h: bh, white: wc, tint: tc, tintKey: "", tintAt: 0, stagger: this.featherBlitN++ % 8, seenKey: shapeKey, seenAt: this.paintSeq };
4732
+ this.featherBlits.set(e, b);
4733
+ }
4734
+ if (b.tintKey !== color2 && (b.tintKey === "" || this.paintSeq - b.tintAt >= 8)) {
4735
+ const t2 = b.tint.ctx;
4736
+ t2.setTransform(1, 0, 0, 1, 0, 0);
4737
+ t2.globalCompositeOperation = "copy";
4738
+ t2.drawImage(b.white.canvas, 0, 0);
4739
+ t2.globalCompositeOperation = "source-in";
4740
+ t2.fillStyle = color2;
4741
+ t2.fillRect(0, 0, b.w, b.h);
4742
+ t2.globalCompositeOperation = "source-over";
4743
+ b.tintKey = color2;
4744
+ b.tintAt = this.paintSeq - (b.stagger ?? 0);
4745
+ b.stagger = 0;
4746
+ }
4747
+ const cx = m.a * p.x + m.e;
4748
+ const cy = m.d * p.y + m.f;
4749
+ ctx.save();
4750
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4751
+ ctx.drawImage(b.tint.canvas, Math.round(cx - b.w / 2), Math.round(cy - b.h / 2));
4752
+ ctx.restore();
4753
+ return true;
4754
+ }
4755
+ /**
4756
+ * A feathered rect's mask in `color`: FEATHER_MASK_SIZE² texels of
4757
+ * `featherRamp(u) × featherRamp(v)`, drawn stretched to the rect with
4758
+ * smoothing on, so the falloff is continuous at any size (X383).
4759
+ *
4760
+ * The ramp itself is built ONCE per feather (a white alpha mask, the only
4761
+ * per-pixel work). A colour is a cheap tint of it — clear, draw the mask,
4762
+ * `source-in` fill — into a pooled canvas, cached per colour × feather.
4763
+ * A colour glide or a palette larger than the pool re-tints a pooled
4764
+ * canvas (three GPU draws) rather than rebuilding pixels in JS.
4765
+ */
4766
+ featherMask(color2, feather) {
4767
+ const key = `${color2}|${feather}`;
4768
+ const hit = this.featherTints.get(key);
4769
+ if (hit) {
4770
+ this.featherTints.delete(key);
4771
+ this.featherTints.set(key, hit);
4772
+ return hit.canvas;
4773
+ }
4774
+ const N = FEATHER_MASK_SIZE;
4775
+ let white = this.featherWhite.get(feather);
4776
+ if (!white) {
4777
+ const made = this.makeCanvas(N);
4778
+ if (!made) return null;
4779
+ const img = made.ctx.createImageData(N, N);
4780
+ const data = img.data;
4781
+ const ramp = [];
4782
+ for (let i = 0; i < N; i++) ramp.push(featherRamp((i + 0.5) / N, feather));
4783
+ let o = 0;
4784
+ for (let y = 0; y < N; y++) {
4785
+ const ry = ramp[y];
4786
+ for (let x = 0; x < N; x++) {
4787
+ data[o++] = 255;
4788
+ data[o++] = 255;
4789
+ data[o++] = 255;
4790
+ data[o++] = Math.round(255 * ry * ramp[x]);
4791
+ }
4792
+ }
4793
+ made.ctx.putImageData(img, 0, 0);
4794
+ if (this.featherWhite.size >= 8) this.featherWhite.delete(this.featherWhite.keys().next().value);
4795
+ white = made.canvas;
4796
+ this.featherWhite.set(feather, white);
4797
+ }
4798
+ let slot = null;
4799
+ if (this.featherTints.size >= 48) {
4800
+ const oldest = this.featherTints.keys().next().value;
4801
+ slot = this.featherTints.get(oldest);
4802
+ this.featherTints.delete(oldest);
4803
+ } else {
4804
+ slot = this.makeCanvas(N);
4805
+ if (!slot) return null;
4806
+ }
4807
+ const tctx = slot.ctx;
4808
+ tctx.globalCompositeOperation = "copy";
4809
+ tctx.drawImage(white, 0, 0);
4810
+ tctx.globalCompositeOperation = "source-in";
4811
+ tctx.fillStyle = color2;
4812
+ tctx.fillRect(0, 0, N, N);
4813
+ tctx.globalCompositeOperation = "source-over";
4814
+ this.featherTints.set(key, slot);
4815
+ return slot.canvas;
4816
+ }
4817
+ /**
4818
+ * Paint one entity at time `t`. `echo` paints a trail echo instead: the
4819
+ * entity as it was at `t` (rotation, heading, values), but at the echo's
4820
+ * position, size and alpha.
4821
+ */
4822
+ drawEntity(built, e, t, lifeA, parentE, paint, echo) {
3495
4823
  const { ctx } = this;
3496
- const p = this.entityPos(e, t, parentE);
4824
+ const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
3497
4825
  const sprite = paint?.sprite ?? built.layer.sprite;
3498
- const sz = sizeAt(e, t);
4826
+ const sz = echo ? echo.size : sizeAt(e, t);
3499
4827
  const rot = rotationAt(e, t);
3500
4828
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
3501
- ctx.globalAlpha = alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
4829
+ ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
3502
4830
  if (sprite.kind === "ring") {
3503
4831
  const r = sz / 2;
3504
4832
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -3529,23 +4857,18 @@ var SpecInstance = class {
3529
4857
  if (sprite.kind === "rect") {
3530
4858
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3531
4859
  const rh = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
4860
+ const feather = sprite.feather ?? 0;
4861
+ const mask = feather > 0 ? this.featherMask(resolvedColor, feather) : null;
4862
+ if (mask && this.blitFeather(e, p, sz, rh, rot, resolvedColor, feather)) return;
3532
4863
  ctx.save();
3533
4864
  ctx.translate(p.x, p.y);
3534
4865
  if (rot) ctx.rotate(rot);
3535
4866
  ctx.fillStyle = resolvedColor;
3536
- const feather = sprite.feather ?? 0;
3537
- if (feather > 0) {
3538
- const base = ctx.globalAlpha;
3539
- const additive = built.layer.blend === "lighter";
3540
- const alphas = featherAlphas(FEATHER_STEPS, additive);
3541
- for (let j = 1; j <= FEATHER_STEPS; j++) {
3542
- const f = 1 - feather * (j - 1) / FEATHER_STEPS;
3543
- const fw = sz * f;
3544
- const fh = rh * f;
3545
- ctx.globalAlpha = base * alphas[j - 1];
3546
- ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
3547
- }
3548
- ctx.globalAlpha = base;
4867
+ if (mask) {
4868
+ const prevSmooth = ctx.imageSmoothingEnabled;
4869
+ ctx.imageSmoothingEnabled = true;
4870
+ ctx.drawImage(mask, -sz / 2, -rh / 2, sz, rh);
4871
+ ctx.imageSmoothingEnabled = prevSmooth;
3549
4872
  } else {
3550
4873
  ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
3551
4874
  }
@@ -3569,24 +4892,26 @@ var SpecInstance = class {
3569
4892
  if (sprite.kind === "polygon") {
3570
4893
  const r = sz / 2;
3571
4894
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3572
- 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
+ }
3573
4905
  ctx.save();
3574
4906
  ctx.translate(p.x, p.y);
3575
4907
  if (rot) ctx.rotate(rot);
3576
- if (sprite.soft) {
3577
- const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
3578
- g.addColorStop(0, resolvedColor);
3579
- g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3580
- g.addColorStop(1, hexToRgba(resolvedColor, 0));
3581
- ctx.fillStyle = g;
3582
- } else {
3583
- ctx.fillStyle = resolvedColor;
3584
- }
4908
+ ctx.fillStyle = sprite.soft ? softFill(ctx, resolvedColor, r) : resolvedColor;
4909
+ const pts = polygonPoints(sprite, r);
3585
4910
  ctx.beginPath();
3586
4911
  ctx.moveTo(pts[0].x, pts[0].y);
3587
4912
  for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
3588
4913
  ctx.closePath();
3589
- ctx.fill();
4914
+ ctx.fill(polygonFillRule(sprite));
3590
4915
  ctx.restore();
3591
4916
  return;
3592
4917
  }
@@ -3603,13 +4928,13 @@ var SpecInstance = class {
3603
4928
  ctx.lineCap = "round";
3604
4929
  ctx.lineJoin = "round";
3605
4930
  if (sprite.taper) {
3606
- for (let i = 1; i < pts.length; i++) {
3607
- ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
3608
- ctx.beginPath();
3609
- ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
3610
- ctx.lineTo(pts[i].x, pts[i].y);
3611
- ctx.stroke();
3612
- }
4931
+ const outline = strokeOutline(pts, lw);
4932
+ ctx.fillStyle = resolvedColor;
4933
+ ctx.beginPath();
4934
+ ctx.moveTo(outline[0].x, outline[0].y);
4935
+ for (let i = 1; i < outline.length; i++) ctx.lineTo(outline[i].x, outline[i].y);
4936
+ ctx.closePath();
4937
+ ctx.fill();
3613
4938
  } else {
3614
4939
  ctx.lineWidth = lw;
3615
4940
  ctx.beginPath();
@@ -3825,9 +5150,9 @@ var SpecInstance = class {
3825
5150
  const motionWraps = ["drift", "rise", "wander"].includes(built.layer.motion.type);
3826
5151
  const wrap2 = built.layer.wrap !== false && motionWraps;
3827
5152
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
3828
- const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
3829
- const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
3830
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);
3831
5156
  const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3832
5157
  const lw = (links.width ?? defaultWidth) * unitScale;
3833
5158
  ctx.lineWidth = lw;
@@ -3844,7 +5169,7 @@ var SpecInstance = class {
3844
5169
  } else resolvedColor = "#e6e8ef";
3845
5170
  }
3846
5171
  let a = links.alpha ?? alphaAt(ei, t);
3847
- if (links.falloff) a *= Math.max(0, 1 - dist / maxDistPx);
5172
+ if (links.falloff && Number.isFinite(maxDistPx)) a *= Math.max(0, 1 - dist / maxDistPx);
3848
5173
  ctx.globalAlpha = a * lifeA;
3849
5174
  ctx.strokeStyle = resolvedColor;
3850
5175
  let dx = pj.x - pi.x;
@@ -3863,6 +5188,7 @@ var SpecInstance = class {
3863
5188
  * showing through — the ghosting smear. */
3864
5189
  paintFrame(t, bgAlpha) {
3865
5190
  const { ctx } = this;
5191
+ this.paintSeq++;
3866
5192
  if (this.authored) this.resolveAuthored(t);
3867
5193
  if (!this.transparent) {
3868
5194
  ctx.globalAlpha = bgAlpha;
@@ -3870,39 +5196,213 @@ var SpecInstance = class {
3870
5196
  this.drawBackground(t);
3871
5197
  }
3872
5198
  ctx.globalAlpha = 1;
3873
- for (let li = 0; li < this.layers.length; li++) {
3874
- const built = this.layers[li];
3875
- const opacity = built.layer.opacity;
3876
- const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
3877
- let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
3878
- if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
3879
- if (lifeA <= 0) continue;
3880
- const parentE = this.parentEntityFor(built);
3881
- const tf = built.layer.transform;
3882
- const gtf = group?.transform;
3883
- if (tf || gtf) {
3884
- ctx.save();
3885
- if (gtf) this.applyLayerTransform(gtf, null);
3886
- if (tf) this.applyLayerTransform(tf, built.layer);
3887
- }
3888
- ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
3889
- this.drawLinks(built, t, lifeA, parentE);
3890
- const override = this.paintOverrides?.get(li) ?? null;
3891
- for (const e of built.entities) {
3892
- this.drawTrail(built, e, t, lifeA, parentE);
3893
- if (override === null) {
3894
- 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;
3895
5206
  continue;
3896
5207
  }
3897
- 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];
3898
- if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
3899
- if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
3900
5208
  }
3901
- if (tf || gtf) ctx.restore();
5209
+ this.paintLayer(li, t);
3902
5210
  }
3903
5211
  ctx.globalAlpha = 1;
3904
5212
  ctx.globalCompositeOperation = "source-over";
3905
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;
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);
5400
+ ctx.globalAlpha = 1;
5401
+ ctx.globalCompositeOperation = "source-over";
5402
+ ctx.drawImage(plate.canvas, plate.box.x, plate.box.y);
5403
+ ctx.restore();
5404
+ return true;
5405
+ }
3906
5406
  /**
3907
5407
  * A `transform`, in min(w,h) units (px under `units: 'px'`), about the
3908
5408
  * viewport centre — or, with `origin: 'anchor'`, about `layer`'s own anchor
@@ -4774,6 +6274,27 @@ function hexLuma(hex) {
4774
6274
  }
4775
6275
  function spriteLuma(layer, e) {
4776
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
+ }
4777
6298
  if (isShapedSprite(s)) {
4778
6299
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
4779
6300
  }
@@ -4918,7 +6439,7 @@ function polyShape(cx, cy, raw) {
4918
6439
  if (abs.length < 3) return null;
4919
6440
  let br = 0;
4920
6441
  for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
4921
- const inside = (x, y) => {
6442
+ const inside2 = (x, y) => {
4922
6443
  let hit = false;
4923
6444
  for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
4924
6445
  const a = abs[i];
@@ -4941,7 +6462,7 @@ function polyShape(cx, cy, raw) {
4941
6462
  cy,
4942
6463
  br,
4943
6464
  area,
4944
- contains: inside,
6465
+ contains: inside2,
4945
6466
  /**
4946
6467
  * `n` points spread along the perimeter by ARC LENGTH, not one share per
4947
6468
  * edge — a long skinny rectangle has two edges carrying almost all of its
@@ -4970,13 +6491,13 @@ function polyShape(cx, cy, raw) {
4970
6491
  for (let t = step; t > 0; t /= 8) {
4971
6492
  const qx1 = px - uy * t;
4972
6493
  const qy1 = py + ux * t;
4973
- if (inside(qx1, qy1)) {
6494
+ if (inside2(qx1, qy1)) {
4974
6495
  out.push({ x: qx1, y: qy1 });
4975
6496
  break;
4976
6497
  }
4977
6498
  const qx2 = px + uy * t;
4978
6499
  const qy2 = py - ux * t;
4979
- if (inside(qx2, qy2)) {
6500
+ if (inside2(qx2, qy2)) {
4980
6501
  out.push({ x: qx2, y: qy2 });
4981
6502
  break;
4982
6503
  }
@@ -5098,7 +6619,6 @@ function seamsWorthWarning(layer, c, entityCount) {
5098
6619
 
5099
6620
  // src/perceive.ts
5100
6621
  import { createRng as createRng2 } from "@idle-screens/core";
5101
- var GLOW_SPREAD = 2.4;
5102
6622
  var LINE_SALIENCE = 3.5;
5103
6623
  var GHOST_TAPS = 12;
5104
6624
  var POLYGON_OUTLINE_CELLS = 3;
@@ -5133,11 +6653,18 @@ function posOf(scene, e, t) {
5133
6653
  const map = scene.transforms?.get(e);
5134
6654
  return map ? map(p) : p;
5135
6655
  }
5136
- 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) {
5137
6660
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5138
- 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);
5139
6665
  const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5140
- 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;
5141
6668
  const align = s.align ?? "center";
5142
6669
  const baseline = s.baseline ?? "middle";
5143
6670
  return {
@@ -5183,7 +6710,24 @@ function textBlockRevealFraction(s, w, h, t) {
5183
6710
  }
5184
6711
  return rs.progress;
5185
6712
  }
5186
- 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) {
5187
6731
  const nc = c1 - c0 + 1;
5188
6732
  const cov = new Float64Array(nc * (r1 - r0 + 1));
5189
6733
  const xMin = c0 * cellW;
@@ -5216,30 +6760,52 @@ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5216
6760
  hits.sort((m, n) => m.x - n.x);
5217
6761
  let wind = 0;
5218
6762
  let start = 0;
6763
+ const filled = (w) => evenodd ? (w & 1) !== 0 : w !== 0;
5219
6764
  for (const hit of hits) {
5220
- const before = wind;
6765
+ const before = filled(wind);
5221
6766
  wind += hit.dir;
5222
- if (before === 0 && wind !== 0) start = hit.x;
5223
- 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);
5224
6770
  }
5225
6771
  }
5226
6772
  }
5227
6773
  return cov;
5228
6774
  }
5229
- function distanceToOutline(pts, x, y) {
5230
- let best = Infinity;
5231
- for (let i = 0; i < pts.length; i++) {
5232
- const p = pts[i];
5233
- const q = pts[(i + 1) % pts.length];
5234
- const ex = q.x - p.x;
5235
- const ey = q.y - p.y;
5236
- const len2 = ex * ex + ey * ey;
5237
- const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5238
- const dx = x - (p.x + ex * u);
5239
- const dy = y - (p.y + ey * u);
5240
- best = Math.min(best, dx * dx + dy * dy);
5241
- }
5242
- 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);
5243
6809
  }
5244
6810
  function luminanceGrid(spec, opts = {}) {
5245
6811
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
@@ -5300,42 +6866,50 @@ function luminanceGrid(spec, opts = {}) {
5300
6866
  else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
5301
6867
  else cells[idx] = cur * (1 - a) + lum * a;
5302
6868
  };
5303
- const splatTrail = (layer, e, tPass, lifeA, alphaScale) => {
6869
+ const splatTrail = (layer, e, tPass, lifeA, alphaScale, layerCount) => {
5304
6870
  const trail = layer.trail;
5305
6871
  if (!trail) return;
5306
- const fade = trail.fade ?? 1;
5307
- const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
5308
- const headAlpha = alphaAt(e, tPass) * lifeA;
6872
+ const headAlpha = alphaAt(e, tPass) * lifeA * alphaScale;
5309
6873
  const headSize = sizeAt(e, tPass);
5310
6874
  const lum = spriteLuma(layer, e);
5311
6875
  const soft = layer.sprite.kind === "circle" && !!layer.sprite.soft;
5312
- const wrapOn = layer.wrap !== false;
5313
- const head = posOf(scene, e, tPass);
5314
- let prevX = head.x;
5315
- let prevY = head.y;
5316
- for (let s = 1; s <= n; s++) {
5317
- const k = s / n;
5318
- const pastT = tPass - k * trail.length;
5319
- if (pastT < 0) break;
5320
- const pos = posOf(scene, e, pastT);
5321
- if (wrapOn && (Math.abs(pos.x - prevX) > w / 2 || Math.abs(pos.y - prevY) > h / 2)) break;
5322
- prevX = pos.x;
5323
- prevY = pos.y;
5324
- const a = headAlpha * (1 - k * fade) * alphaScale;
5325
- if (a <= 4e-3) break;
5326
- const rad = headSize / 2 * (1 - k * 0.7);
5327
- if (rad < 0.2) break;
6876
+ const echoes = trailEchoes(
6877
+ trail,
6878
+ tPass,
6879
+ headAlpha,
6880
+ headSize,
6881
+ (pt) => posOf(scene, e, pt),
6882
+ w,
6883
+ h,
6884
+ layer.wrap !== false,
6885
+ // As the renderer: a shape echoes at its time samples only; circles fill
6886
+ // (a hard circle's ribbon reads, on the coarse grid, as filled discs).
6887
+ isShapedSprite(layer.sprite) && layer.sprite.kind !== "circle" ? 0 : trailFillBudget(layerCount),
6888
+ 4e-3,
6889
+ layer.blend === "lighter"
6890
+ );
6891
+ for (const ec of echoes) {
6892
+ const a = ec.alpha;
6893
+ const rad = ec.r;
6894
+ if (layer.sprite.kind === "polygon" && rad * 2 >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
6895
+ splatPolygonOutline(layer, layer.sprite, e, ec, rad * 2, Math.min(1, a), lum, ec.t);
6896
+ continue;
6897
+ }
6898
+ const pos = ec;
5328
6899
  const c0 = Math.max(0, Math.floor((pos.x - rad) / cellW));
5329
6900
  const c1 = Math.min(cols - 1, Math.floor((pos.x + rad) / cellW));
5330
6901
  const r0 = Math.max(0, Math.floor((pos.y - rad) / cellH));
5331
6902
  const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5332
6903
  for (let r = r0; r <= r1; r++) {
5333
6904
  for (let c = c0; c <= c1; c++) {
5334
- const dx = (c + 0.5) * cellW - pos.x;
5335
- const dy = (r + 0.5) * cellH - pos.y;
5336
- const d = Math.sqrt(dx * dx + dy * dy);
5337
- if (d > rad + Math.min(cellW, cellH) / 2) continue;
5338
- 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
+ }
5339
6913
  compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5340
6914
  }
5341
6915
  }
@@ -5346,17 +6920,12 @@ function luminanceGrid(spec, opts = {}) {
5346
6920
  const rot = rotationAt(e, tPass);
5347
6921
  const cr = Math.cos(rot);
5348
6922
  const sr = Math.sin(rot);
5349
- 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 }));
5350
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5351
- const glow = !!s.soft && additive;
5352
- let band = 0;
5353
- if (glow) {
5354
- const perimeter = pts.reduce((acc, q, i) => {
5355
- const n = pts[(i + 1) % pts.length];
5356
- return acc + Math.hypot(n.x - q.x, n.y - q.y);
5357
- }, 0);
5358
- band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5359
- }
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 }));
5360
6929
  let minX = Infinity;
5361
6930
  let maxX = -Infinity;
5362
6931
  let minY = Infinity;
@@ -5367,34 +6936,97 @@ function luminanceGrid(spec, opts = {}) {
5367
6936
  minY = Math.min(minY, q.y);
5368
6937
  maxY = Math.max(maxY, q.y);
5369
6938
  }
5370
- const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5371
- const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5372
- const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5373
- 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));
5374
6967
  if (c0 > c1 || r0 > r1) return;
5375
- const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5376
6968
  const nc = c1 - c0 + 1;
5377
- for (let r = r0; r <= r1; r++) {
5378
- for (let c = c0; c <= c1; c++) {
5379
- const f = cov[(r - r0) * nc + (c - c0)];
5380
- const x = (c + 0.5) * cellW;
5381
- const y = (r + 0.5) * cellH;
5382
- let wgt;
5383
- if (glow) {
5384
- const outX = Math.max(0, minX - x, x - maxX);
5385
- const outY = Math.max(0, minY - y, y - maxY);
5386
- if (f >= 1) wgt = 1;
5387
- else if (outX * outX + outY * outY >= band * band) wgt = f;
5388
- else {
5389
- const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5390
- 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;
5391
6989
  }
5392
- } else if (s.soft) {
5393
- wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5394
- } else {
5395
- wgt = f;
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)]);
7024
+ }
7025
+ }
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);
5396
7029
  }
5397
- if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
5398
7030
  }
5399
7031
  }
5400
7032
  };
@@ -5403,14 +7035,14 @@ function luminanceGrid(spec, opts = {}) {
5403
7035
  const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
5404
7036
  if (lifeA <= 0) continue;
5405
7037
  for (const e of entities) {
5406
- splatTrail(layer, e, tPass, lifeA, alphaScale);
7038
+ splatTrail(layer, e, tPass, lifeA, alphaScale, entities.length);
5407
7039
  const a = Math.min(1, alphaAt(e, tPass) * lifeA) * alphaScale;
5408
7040
  if (a <= 4e-3) continue;
5409
7041
  const lum = spriteLuma(layer, e);
5410
7042
  const p = posOf(scene, e, tPass);
5411
7043
  const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5412
7044
  const s = layer.sprite;
5413
- 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))) {
5414
7046
  splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5415
7047
  continue;
5416
7048
  }
@@ -5464,7 +7096,7 @@ function luminanceGrid(spec, opts = {}) {
5464
7096
  let halfX = sz / 2;
5465
7097
  let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
5466
7098
  if (s.kind === "text") {
5467
- const box = textBox(s, e, p);
7099
+ const box = textBox(s, e, p, fontScaleFor(spec, w, h), scale);
5468
7100
  centerX = box.cx;
5469
7101
  centerY = box.cy;
5470
7102
  halfX = box.halfX;
@@ -5491,45 +7123,24 @@ function luminanceGrid(spec, opts = {}) {
5491
7123
  const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5492
7124
  const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
5493
7125
  const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5494
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5495
- const glow = soft && additive;
5496
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
5497
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
5498
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
5499
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
5500
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5501
- 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));
5502
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);
5503
- 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";
5504
7132
  for (let r = r0; r <= r1; r++) {
5505
7133
  const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
5506
7134
  if (fy <= 0) continue;
5507
7135
  for (let c = c0; c <= c1; c++) {
5508
- const dx = (c + 0.5) * cellW - centerX;
5509
- const dy = (r + 0.5) * cellH - centerY;
5510
7136
  if (circular) {
5511
- const d = Math.sqrt(dx * dx + dy * dy);
5512
- const reach = glow ? reachX : halfX;
5513
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
5514
- if (s.kind === "ring") {
5515
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
5516
- if (d < halfX - strokeW) continue;
5517
- }
5518
- let wgt;
5519
- if (glow) {
5520
- const core = halfX;
5521
- if (d <= core) wgt = 1;
5522
- else {
5523
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
5524
- wgt = f * f;
5525
- }
5526
- } else {
5527
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5528
- }
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;
5529
7140
  compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
5530
7141
  } else if (byArea) {
5531
7142
  const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
5532
- 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);
5533
7144
  } else {
5534
7145
  compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5535
7146
  }
@@ -5538,9 +7149,8 @@ function luminanceGrid(spec, opts = {}) {
5538
7149
  }
5539
7150
  if (layer.links) {
5540
7151
  const positions = entities.map((e) => posOf(scene, e, tPass));
5541
- const maxDistPx = layer.links.maxDist * scale;
5542
7152
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5543
- 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);
5544
7154
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
5545
7155
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
5546
7156
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
@@ -5681,8 +7291,7 @@ function textSprites(spec, opts = {}) {
5681
7291
  }
5682
7292
  if (layer.sprite.kind !== "text") return;
5683
7293
  const s = layer.sprite;
5684
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5685
- 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));
5686
7295
  out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
5687
7296
  });
5688
7297
  return out;
@@ -5753,7 +7362,7 @@ function rawDominance(spec, opts = {}) {
5753
7362
  const meanTaper = s.taper ? 0.64 : 1;
5754
7363
  entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
5755
7364
  } else if (s.kind === "text") {
5756
- const box = textBox(s, e, { x: 0, y: 0 });
7365
+ const box = textBox(s, e, { x: 0, y: 0 }, fontScaleFor(spec, w, h), scale);
5757
7366
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
5758
7367
  } else if (s.kind === "textBlock") {
5759
7368
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
@@ -5773,7 +7382,7 @@ function rawDominance(spec, opts = {}) {
5773
7382
  if (layer.links && entities.length > 1) {
5774
7383
  const positions = entities.map((e) => posOf(scene, e, t));
5775
7384
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5776
- 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);
5777
7386
  const la = (layer.links.alpha ?? 0.6) * lifeA;
5778
7387
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5779
7388
  const lw = (layer.links.width ?? defaultWidth) * scale;
@@ -6227,8 +7836,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6227
7836
  }
6228
7837
  if (layer.links) {
6229
7838
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6230
- const maxDistPx = layer.links.maxDist * scale;
6231
- 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);
6232
7840
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6233
7841
  const lwPx = (layer.links.width ?? defaultWidth) * scale;
6234
7842
  const la = layer.links.alpha ?? 1;
@@ -6287,9 +7895,9 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6287
7895
  if (!layer.links || layer.links.mode === "chain") continue;
6288
7896
  const entities = allEntities[li];
6289
7897
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6290
- const maxDist = layer.links.maxDist * scale;
6291
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6292
- 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);
6293
7901
  if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
6294
7902
  warnings.push({
6295
7903
  path: `layers[${li}].links`,
@@ -6354,7 +7962,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6354
7962
  const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
6355
7963
  const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
6356
7964
  const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
6357
- textBoxes.push({ li, label, alpha, str, ...box });
7965
+ textBoxes.push({ li, label, alpha, str, ...box, rawW: raw.x1 - raw.x0, rawH: raw.y1 - raw.y0 });
6358
7966
  }
6359
7967
  }
6360
7968
  const asFraction = (b) => ({
@@ -6409,6 +8017,45 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6409
8017
  });
6410
8018
  }
6411
8019
  }
8020
+ const cellFlagged = /* @__PURE__ */ new Set();
8021
+ for (let i = 0; i < textBoxes.length; i++) {
8022
+ const a = textBoxes[i];
8023
+ if (cellFlagged.has(a.li)) continue;
8024
+ const layer = spec.layers[a.li];
8025
+ const layout = layer.layout;
8026
+ if (layout?.type !== "list" && layout?.type !== "table") continue;
8027
+ for (let j = i + 1; j < textBoxes.length; j++) {
8028
+ const b = textBoxes[j];
8029
+ if (b.li !== a.li) continue;
8030
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
8031
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
8032
+ if (ix <= 0 || iy <= 0) continue;
8033
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
8034
+ if (smaller <= 0 || ix * iy / smaller <= 0.1) continue;
8035
+ cellFlagged.add(a.li);
8036
+ const cells = textBoxes.filter((x) => x.li === a.li);
8037
+ const dflt = scale === 1 ? LIMITS.defaultListGapPx : LIMITS.defaultListGap;
8038
+ const g = layout.gap ?? dflt;
8039
+ const gx = typeof g === "number" ? g : g.x ?? dflt;
8040
+ const gy = typeof g === "number" ? g : g.y ?? dflt;
8041
+ const cols = layout.type === "list" ? 1 : Math.max(1, Math.round(layout.columns));
8042
+ const unitPx = (v) => scale === 1 ? v : v * scale;
8043
+ const fromPx = (px) => scale === 1 ? Math.ceil(px) : Math.ceil(px / scale * 100) / 100;
8044
+ const maxW = cells.reduce((m, c) => Math.max(m, c.rawW), 0);
8045
+ const maxH = cells.reduce((m, c) => Math.max(m, c.rawH), 0);
8046
+ const fixes = [];
8047
+ const across = cols > 1 && maxW > unitPx(gx);
8048
+ if (across) fixes.push(`its labels are up to ~${Math.round(maxW)}px wide against a ${Math.round(unitPx(gx))}px column pitch (gap${typeof g === "number" ? "" : ".x"} ${gx}) \u2014 raise gap${typeof g === "number" ? "" : ".x"} to \u2265 ${fromPx(maxW * 1.1)}`);
8049
+ if (!across || maxH > unitPx(gy)) fixes.push(`rows are ~${Math.round(maxH)}px tall against a ${Math.round(unitPx(gy))}px row pitch (gap${typeof g === "number" ? "" : ".y"} ${gy}) \u2014 raise gap${typeof g === "number" ? "" : ".y"} to \u2265 ${fromPx(maxH * 1.1)}`);
8050
+ warnings.push({
8051
+ path: `layers[${a.li}].layout.gap`,
8052
+ code: "layout-text-overlap",
8053
+ message: `${a.label} cells overlap at ${w}\xD7${h}: a ${layout.type}'s \`gap\` is the pitch between cell anchors, not the space between cells; ${fixes.join("; and ")}, or shrink the text`,
8054
+ boxes: [asFraction(a), asFraction(b)]
8055
+ });
8056
+ break;
8057
+ }
8058
+ }
6412
8059
  const readFlagged = /* @__PURE__ */ new Set();
6413
8060
  for (const b of textBoxes) {
6414
8061
  if (readFlagged.has(b.li)) continue;
@@ -6514,20 +8161,20 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6514
8161
  const c = positionAt(e, 0, w, h);
6515
8162
  const dx = pt.x - c.x;
6516
8163
  const dy = pt.y - c.y;
6517
- let inside = false;
6518
- 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;
6519
8166
  else if (sp.kind === "rect") {
6520
8167
  const rot = ((e.rotate ?? 0) % Math.PI + Math.PI) % Math.PI;
6521
8168
  const quarter = Math.abs(rot - Math.PI / 2) < 0.01;
6522
8169
  if (rot > 0.01 && rot < Math.PI - 0.01 && !quarter) continue;
6523
8170
  const hx = (quarter ? e.size2 ?? e.size : e.size) / 2;
6524
8171
  const hy = (quarter ? e.size : e.size2 ?? e.size) / 2;
6525
- inside = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
8172
+ inside2 = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
6526
8173
  } else {
6527
8174
  if (e.rotate) continue;
6528
- inside = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
8175
+ inside2 = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
6529
8176
  }
6530
- if (!inside) continue;
8177
+ if (!inside2) continue;
6531
8178
  plate = sourceOverPlate(plate, hexRgb(hex), Math.min(1, e.alpha * a));
6532
8179
  covered = true;
6533
8180
  }
@@ -6535,13 +8182,13 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6535
8182
  return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
6536
8183
  }
6537
8184
  function pointInPolygon(x, y, pts) {
6538
- let inside = false;
8185
+ let inside2 = false;
6539
8186
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
6540
8187
  const a = pts[i];
6541
8188
  const b = pts[j];
6542
- 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;
6543
8190
  }
6544
- return inside;
8191
+ return inside2;
6545
8192
  }
6546
8193
  function entityReachesBox(e, r, box, w, h) {
6547
8194
  if (e.motion === "static") {
@@ -6693,10 +8340,10 @@ function describeScene(spec, options = {}) {
6693
8340
  let connectedComponents = null;
6694
8341
  let isolatedNodes = null;
6695
8342
  if (layer.links) {
6696
- const maxDist = layer.links.maxDist * scale;
6697
- 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]);
6698
8345
  linksDrawn = pairs.length;
6699
- 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);
6700
8347
  const parent = Array.from({ length: entities.length }, (_, i) => i);
6701
8348
  const find = (x) => {
6702
8349
  while (parent[x] !== x) {
@@ -9197,6 +10844,7 @@ export {
9197
10844
  LIMITS,
9198
10845
  LOBBY_TALK_SPEC,
9199
10846
  MATRIX_RAIN_SPEC,
10847
+ MAX_PATH_POINTS,
9200
10848
  MURMUR_SPEC,
9201
10849
  NOSTALGHIA_CANDLE_SPEC,
9202
10850
  ORRERY_SPEC,
@@ -9252,6 +10900,7 @@ export {
9252
10900
  fieldRgbAt,
9253
10901
  fieldSampleTime,
9254
10902
  finishStrength,
10903
+ flattenPath,
9255
10904
  grainOffset,
9256
10905
  grainTilePixels,
9257
10906
  graphemeClusters,
@@ -9259,12 +10908,15 @@ export {
9259
10908
  headingAt,
9260
10909
  hexToRgb255,
9261
10910
  ignoredPropertyPaths,
10911
+ isPathPolygon,
9262
10912
  latticeHash,
9263
10913
  layerCohesion,
10914
+ lerpPathD,
9264
10915
  lerpSpec,
9265
10916
  lerpValue,
9266
10917
  lifeAlphaAt,
9267
10918
  linkEdges,
10919
+ linkMaxDistPx,
9268
10920
  linkPairs,
9269
10921
  luminanceGrid,
9270
10922
  manifestFor,
@@ -9273,6 +10925,8 @@ export {
9273
10925
  motionStats,
9274
10926
  nextKeyAfter,
9275
10927
  normalizeSeed,
10928
+ pathFrame,
10929
+ pathParts,
9276
10930
  perceiveScene,
9277
10931
  perceiveSequenceFrame,
9278
10932
  pickScreenOp,