@idle-screens/schema 3.12.1 → 3.14.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
@@ -255,6 +255,35 @@ function applyDeltasToSpec(spec, deltas) {
255
255
  }
256
256
  return copy;
257
257
  }
258
+ function applyDeltasShared(spec, deltas) {
259
+ const out = { ...spec };
260
+ const copied = new WeakSet([out]);
261
+ for (const d of deltas) {
262
+ const cp = canonicalSpecPath(out, d.path);
263
+ if (!cp) continue;
264
+ const parts = cp.split(".");
265
+ let node = out;
266
+ for (let i = 0; i < parts.length - 1; i++) {
267
+ const k = parts[i];
268
+ const child = node[k];
269
+ if (!child || typeof child !== "object") {
270
+ node = null;
271
+ break;
272
+ }
273
+ if (!copied.has(child)) {
274
+ const clone = Array.isArray(child) ? child.slice() : { ...child };
275
+ copied.add(clone);
276
+ node[k] = clone;
277
+ }
278
+ node = node[k];
279
+ }
280
+ if (node) {
281
+ const last = parts[parts.length - 1];
282
+ node[Array.isArray(node) ? Number(last) : last] = d.value;
283
+ }
284
+ }
285
+ return out;
286
+ }
258
287
  var HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
259
288
  function hexToRgb(hex) {
260
289
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
@@ -290,6 +319,10 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
290
319
  if (typeof a === "string" && typeof b === "string" && HEX.test(a) && HEX.test(b)) {
291
320
  return lerpHex(a, b, kk);
292
321
  }
322
+ if (key === "path" && typeof a === "string" && typeof b === "string" && a !== b) {
323
+ const m = lerpPathD(a, b, kk);
324
+ if (m !== null) return m;
325
+ }
293
326
  if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
294
327
  return b.map((bv, i) => walk(a[i], bv, i));
295
328
  }
@@ -306,6 +339,33 @@ function lerpValue(from, to, k, specRoot = false, rootKey) {
306
339
  };
307
340
  return walk(from, to, rootKey, specRoot);
308
341
  }
342
+ var PATH_TOKEN = /[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g;
343
+ function lerpPathD(a, b, k) {
344
+ const ta = a.match(PATH_TOKEN) ?? [];
345
+ const tb = b.match(PATH_TOKEN) ?? [];
346
+ if (ta.length !== tb.length) return null;
347
+ const out = [];
348
+ let cmd = "";
349
+ let argIndex = 0;
350
+ for (let i = 0; i < ta.length; i++) {
351
+ const x = ta[i], y = tb[i];
352
+ const isCmdA = /^[a-zA-Z]$/.test(x), isCmdB = /^[a-zA-Z]$/.test(y);
353
+ if (isCmdA !== isCmdB) return null;
354
+ if (isCmdA) {
355
+ if (x !== y) return null;
356
+ cmd = x.toUpperCase();
357
+ argIndex = 0;
358
+ out.push(x);
359
+ continue;
360
+ }
361
+ const va = Number(x), vb = Number(y);
362
+ const flag = cmd === "A" && (argIndex % 7 === 3 || argIndex % 7 === 4);
363
+ argIndex++;
364
+ const v = va + (vb - va) * k;
365
+ out.push(flag ? String(Math.round(v)) : String(+v.toFixed(5)));
366
+ }
367
+ return out.join(" ");
368
+ }
309
369
  function textStringsOf(sprite) {
310
370
  if (sprite.kind === "text") return sprite.strings;
311
371
  if (sprite.kind === "textBlock") return [sprite.text];
@@ -362,7 +422,7 @@ function steerablePaths(spec) {
362
422
  if (!spec || typeof spec !== "object") return [];
363
423
  const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer", "group", "origin"]);
364
424
  const SKIP_ROOT = /* @__PURE__ */ new Set(["timeline"]);
365
- const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
425
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands", "parts"]);
366
426
  const layers = spec.layers;
367
427
  const shadowedRootKeys = new Set(
368
428
  Array.isArray(layers) ? layers.map((l) => l && typeof l === "object" ? l.key : void 0).filter((k) => typeof k === "string" && STEERABLE_ROOT_KEYS.has(k)) : []
@@ -610,7 +670,7 @@ function evalTrack(keys, start, tt, leaf) {
610
670
  }
611
671
  return at(tt);
612
672
  }
613
- function resolveTimelineAt(spec, t) {
673
+ function resolveTimelineAt(spec, t, shared = false) {
614
674
  const tl = spec.timeline;
615
675
  if (!tl) return spec;
616
676
  const base = withoutTimeline(spec);
@@ -622,7 +682,7 @@ function resolveTimelineAt(spec, t) {
622
682
  const start = tl.loop ? keys[keys.length - 1].value : readSpecPath(base, path);
623
683
  deltas.push({ t: 0, path, value: evalTrack(keys, start, tt, path.slice(path.lastIndexOf(".") + 1)) });
624
684
  }
625
- return applyDeltasToSpec(base, deltas);
685
+ return shared ? applyDeltasShared(base, deltas) : applyDeltasToSpec(base, deltas);
626
686
  }
627
687
  function nextKeyAfter(spec, path, t) {
628
688
  const tl = spec.timeline;
@@ -674,6 +734,689 @@ function timelineSampleTimesAfter(spec, now, dense = false, only) {
674
734
  return local.filter((s) => s > now);
675
735
  }
676
736
 
737
+ // src/shapes.ts
738
+ function isShapedSprite(s) {
739
+ return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
740
+ }
741
+ function barFraction(s, index) {
742
+ const n = s.values.length;
743
+ if (n === 0) return 0;
744
+ const v = s.values[(index % n + n) % n] ?? 0;
745
+ const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
746
+ if (!(max > 0)) return 0;
747
+ return Math.max(0, Math.min(1, v / max));
748
+ }
749
+ function barBox(direction, len, thick) {
750
+ switch (direction) {
751
+ case "left":
752
+ return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
753
+ case "up":
754
+ return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
755
+ case "down":
756
+ return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
757
+ default:
758
+ return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
759
+ }
760
+ }
761
+ var DEFAULT_POLYGON_SIDES = 6;
762
+ var STROKE_SAMPLES = 24;
763
+ function polygonPoints(s, radius) {
764
+ if (isPathPolygon(s)) {
765
+ const unit = flattenSprite(s);
766
+ if (unit.points.length >= 3) return unit.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
767
+ }
768
+ if (s.points && s.points.length >= 3) {
769
+ return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
770
+ }
771
+ const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
772
+ const out = [];
773
+ for (let k = 0; k < n; k++) {
774
+ const a = -Math.PI / 2 + 2 * Math.PI * k / n;
775
+ out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
776
+ }
777
+ return out;
778
+ }
779
+ function polygonArea(pts) {
780
+ let a = 0;
781
+ for (let i = 0; i < pts.length; i++) {
782
+ const p = pts[i];
783
+ const q = pts[(i + 1) % pts.length];
784
+ a += p.x * q.y - q.x * p.y;
785
+ }
786
+ return Math.abs(a) / 2;
787
+ }
788
+ function polygonFill(s, radius) {
789
+ if (radius <= 0) return 0;
790
+ const disc = Math.PI * radius * radius;
791
+ return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
792
+ }
793
+ function catmullRom(p0, p1, p2, p3, u) {
794
+ const u2 = u * u;
795
+ const u3 = u2 * u;
796
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
797
+ }
798
+ function strokeSamples(s, halfSize, n) {
799
+ const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
800
+ const m = pts.length;
801
+ if (m < 2) return pts;
802
+ const smooth = s.curve !== "linear" && m >= 3;
803
+ const segs = m - 1;
804
+ n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
805
+ const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
806
+ const out = [];
807
+ for (let i = 0; i < n; i++) {
808
+ const u = i / (n - 1) * segs;
809
+ const seg = Math.min(segs - 1, Math.floor(u));
810
+ const local = u - seg;
811
+ const p1 = at(seg);
812
+ const p2 = at(seg + 1);
813
+ if (!smooth) {
814
+ out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
815
+ } else {
816
+ const p0 = at(seg - 1);
817
+ const p3 = at(seg + 2);
818
+ out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
819
+ }
820
+ }
821
+ return out;
822
+ }
823
+ function pathLength(pts) {
824
+ let l = 0;
825
+ for (let i = 1; i < pts.length; i++) {
826
+ const dx = pts[i].x - pts[i - 1].x;
827
+ const dy = pts[i].y - pts[i - 1].y;
828
+ l += Math.sqrt(dx * dx + dy * dy);
829
+ }
830
+ return l;
831
+ }
832
+ function strokeTaper(u) {
833
+ return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
834
+ }
835
+ function strokeWidthPx(s, scale) {
836
+ return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
837
+ }
838
+ function featherRamp(u, feather) {
839
+ const band = Math.max(0, Math.min(1, feather)) / 2;
840
+ const d = Math.min(u, 1 - u);
841
+ if (band <= 0) return d >= 0 ? 1 : 0;
842
+ return Math.max(0, Math.min(1, d / band));
843
+ }
844
+ var FEATHER_MASK_SIZE = 128;
845
+ function ribbonSides(pts, half) {
846
+ const n = pts.length;
847
+ const left = [];
848
+ const right = [];
849
+ if (n < 2) return { left, right };
850
+ const dir = (a, b) => {
851
+ const dx = b.x - a.x;
852
+ const dy = b.y - a.y;
853
+ const l = Math.hypot(dx, dy);
854
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
855
+ };
856
+ for (let i = 0; i < n; i++) {
857
+ const tIn = i > 0 ? dir(pts[i - 1], pts[i]) : dir(pts[0], pts[1]);
858
+ const tOut = i < n - 1 ? dir(pts[i], pts[i + 1]) : tIn;
859
+ let tx = tIn.x + tOut.x;
860
+ let ty = tIn.y + tOut.y;
861
+ const tl = Math.hypot(tx, ty);
862
+ if (tl < 1e-9) {
863
+ tx = tOut.x;
864
+ ty = tOut.y;
865
+ } else {
866
+ tx /= tl;
867
+ ty /= tl;
868
+ }
869
+ const hw = half[i] / Math.max(0.5, tx * tOut.x + ty * tOut.y);
870
+ const p = pts[i];
871
+ left.push({ x: p.x - ty * hw, y: p.y + tx * hw });
872
+ right.push({ x: p.x + ty * hw, y: p.y - tx * hw });
873
+ }
874
+ return { left, right };
875
+ }
876
+ function strokeOutline(pts, lw, taper = true) {
877
+ const n = pts.length;
878
+ if (n < 2) return [];
879
+ const half = (i) => Math.max(0.5, taper ? lw * strokeTaper(i / (n - 1)) : lw) / 2;
880
+ const dir = (a, b) => {
881
+ const dx = b.x - a.x;
882
+ const dy = b.y - a.y;
883
+ const l = Math.hypot(dx, dy);
884
+ return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
885
+ };
886
+ const { left, right } = ribbonSides(pts, Array.from({ length: n }, (_, i) => half(i)));
887
+ const cap = (c, r, from) => {
888
+ const out = [];
889
+ const steps = 6;
890
+ for (let k = 1; k < steps; k++) {
891
+ const a = from - Math.PI * k / steps;
892
+ out.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
893
+ }
894
+ return out;
895
+ };
896
+ const endT = dir(pts[n - 2], pts[n - 1]);
897
+ const startT = dir(pts[0], pts[1]);
898
+ const endA = Math.atan2(endT.y, endT.x) + Math.PI / 2;
899
+ const startA = Math.atan2(startT.y, startT.x) - Math.PI / 2;
900
+ return [
901
+ ...left,
902
+ ...cap(pts[n - 1], half(n - 1), endA),
903
+ ...right.reverse(),
904
+ ...cap(pts[0], half(0), startA)
905
+ ];
906
+ }
907
+ var MAX_PATH_POINTS = 4096;
908
+ var MAX_SCENE_PATH_POINTS = 16384;
909
+ var PATH_TOLERANCE = 5e-4;
910
+ var pathCache = { auto: /* @__PURE__ */ new Map(), nonzero: /* @__PURE__ */ new Map(), evenodd: /* @__PURE__ */ new Map() };
911
+ function flattenPath(d, rule = "auto") {
912
+ const cache = pathCache[rule] ?? pathCache.auto;
913
+ const hit = cache.get(d);
914
+ if (hit) return hit;
915
+ let out;
916
+ try {
917
+ const parsed = parseAndFlatten(d);
918
+ const rings = parsed.rings.map(simplifyRing).filter((r) => r.length >= 3);
919
+ const lines = parsed.lines.map((l) => ({ points: l.closed ? simplifyRing(l.points) : simplifyLine(l.points), closed: l.closed }));
920
+ out = { points: rule === "auto" ? joinRings(rings) : bridge(rings), subpaths: rings.length, lines };
921
+ } catch (e) {
922
+ out = { points: [], subpaths: 0, lines: [], error: e instanceof Error ? e.message : String(e) };
923
+ }
924
+ if (cache.size > 256) cache.clear();
925
+ cache.set(d, out);
926
+ return out;
927
+ }
928
+ var spriteFlat = /* @__PURE__ */ new WeakMap();
929
+ function flattenSprite(s) {
930
+ const d = s.parts ? s.parts.filter((p) => p.fill !== false).map((p) => p.path).join(" ") : s.path ?? "";
931
+ const rule = s.parts ? "auto" : s.fillRule ?? "auto";
932
+ const hit = spriteFlat.get(s);
933
+ if (hit && hit.d === d && hit.rule === rule) return hit.flat;
934
+ const flat = flattenPath(d, rule);
935
+ spriteFlat.set(s, { d, rule, flat });
936
+ return flat;
937
+ }
938
+ function polygonFillRule(s) {
939
+ return typeof s.path === "string" && s.fillRule === "evenodd" ? "evenodd" : "nonzero";
940
+ }
941
+ function isPathPolygon(s) {
942
+ return s.kind === "polygon" && (typeof s.path === "string" || Array.isArray(s.parts));
943
+ }
944
+ var spriteParts = /* @__PURE__ */ new WeakMap();
945
+ function pathParts(s) {
946
+ const key = s.parts ?? s.path;
947
+ const hit = spriteParts.get(s);
948
+ if (hit && hit.key === key) return hit.parts;
949
+ const src = s.parts ?? (typeof s.path === "string" ? [{ path: s.path, fillRule: s.fillRule, fill: s.fill, gradient: s.gradient, stroke: s.stroke }] : []);
950
+ const parts = src.map((p) => {
951
+ const flat = flattenPath(p.path, p.fillRule ?? "auto");
952
+ return {
953
+ flat,
954
+ color: s.parts ? p.color ?? null : null,
955
+ fill: p.fill !== false,
956
+ rule: p.fillRule === "evenodd" ? "evenodd" : "nonzero",
957
+ gradient: p.gradient,
958
+ stroke: p.stroke,
959
+ opacity: p.opacity ?? 1,
960
+ box: boxOfFlat(flat)
961
+ };
962
+ });
963
+ spriteParts.set(s, { key, parts });
964
+ return parts;
965
+ }
966
+ function partInkArea(part, sx, sy, r) {
967
+ if (part.opacity <= 0) return { fill: 0, stroke: 0 };
968
+ const fill = part.fill ? polygonArea(part.flat.points.map(([x, y]) => ({ x: x * sx, y: y * sy }))) * part.opacity : 0;
969
+ let stroke = 0;
970
+ if (part.stroke && part.stroke.width > 0) {
971
+ for (const l of part.flat.lines) {
972
+ const pts = l.points.map(([x, y]) => ({ x: x * sx, y: y * sy }));
973
+ if (l.closed && pts.length > 1) pts.push(pts[0]);
974
+ stroke += pathLength(pts) * part.stroke.width * r;
975
+ }
976
+ stroke *= (part.stroke.opacity ?? 1) * part.opacity;
977
+ }
978
+ return { fill, stroke };
979
+ }
980
+ function gradientStopWeights(stops) {
981
+ const at = stops.map((st) => Math.min(1, Math.max(0, st.at)));
982
+ const order = at.map((_, i) => i).sort((a, b) => at[a] - at[b]);
983
+ const out = new Array(stops.length).fill(0);
984
+ order.forEach((i, rank) => {
985
+ const prev = rank > 0 ? at[order[rank - 1]] : null;
986
+ const next = rank < order.length - 1 ? at[order[rank + 1]] : null;
987
+ const a = at[i];
988
+ out[i] = (prev === null ? a : (a - prev) / 2) + (next === null ? 1 - a : (next - a) / 2);
989
+ });
990
+ const sum = out.reduce((x, y) => x + y, 0);
991
+ return sum > 0 ? out.map((x) => x / sum) : out.map(() => 1 / Math.max(1, stops.length));
992
+ }
993
+ function pathInkArea(s, sx, sy, r) {
994
+ let area = 0;
995
+ for (const part of pathParts(s)) {
996
+ const a = partInkArea(part, sx, sy, r);
997
+ area += a.fill + a.stroke;
998
+ }
999
+ return area;
1000
+ }
1001
+ function pathFrame(s, r, w, h) {
1002
+ const span = s.span ?? "box";
1003
+ const sx = span === "width" || span === "screen" ? w / 2 : r;
1004
+ const sy = span === "height" || span === "screen" ? h / 2 : r;
1005
+ const [ox, oy] = s.origin ?? [0, 0];
1006
+ return { sx, sy, ox, oy, uniform: sx === sy };
1007
+ }
1008
+ var flatBoxes = /* @__PURE__ */ new WeakMap();
1009
+ function boxOfFlat(flat) {
1010
+ const hit = flatBoxes.get(flat);
1011
+ if (hit) return hit;
1012
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
1013
+ for (const [x, y] of flat.points.length ? flat.points : flat.lines.flatMap((l) => l.points)) {
1014
+ x0 = Math.min(x0, x);
1015
+ x1 = Math.max(x1, x);
1016
+ y0 = Math.min(y0, y);
1017
+ y1 = Math.max(y1, y);
1018
+ }
1019
+ if (!Number.isFinite(x0)) {
1020
+ x0 = -1;
1021
+ x1 = 1;
1022
+ y0 = -1;
1023
+ y1 = 1;
1024
+ }
1025
+ 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) };
1026
+ flatBoxes.set(flat, box);
1027
+ return box;
1028
+ }
1029
+ var pathIds = /* @__PURE__ */ new Map();
1030
+ var pathSeq = 0;
1031
+ var paintSigs = /* @__PURE__ */ new WeakMap();
1032
+ function pathPaintSig(s) {
1033
+ const hit = paintSigs.get(s);
1034
+ if (hit !== void 0) return hit;
1035
+ const sig = JSON.stringify(s, (k, v) => {
1036
+ if (k !== "path" || typeof v !== "string") return v;
1037
+ let id = pathIds.get(v);
1038
+ if (id === void 0) {
1039
+ if (pathIds.size > 4096) pathIds.clear();
1040
+ id = ++pathSeq;
1041
+ pathIds.set(v, id);
1042
+ }
1043
+ return `#${id}`;
1044
+ });
1045
+ paintSigs.set(s, sig);
1046
+ return sig;
1047
+ }
1048
+ function gradientSpace(part, u, v) {
1049
+ if (part.gradient?.units !== "part") return [u, v];
1050
+ return [(u - part.box.cx) / part.box.hw, (v - part.box.cy) / part.box.hh];
1051
+ }
1052
+ function segmentsFor(chord, curvature) {
1053
+ const n = Math.ceil(Math.sqrt(Math.max(curvature, chord * 0.05) / (4 * PATH_TOLERANCE)));
1054
+ return Math.max(2, Math.min(64, n));
1055
+ }
1056
+ function parseAndFlatten(d) {
1057
+ const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
1058
+ let i = 0;
1059
+ const num = () => {
1060
+ const t = tokens[i++];
1061
+ if (t === void 0 || /^[a-zA-Z]$/.test(t)) throw new Error("expected a number");
1062
+ return Number(t);
1063
+ };
1064
+ const flag = () => {
1065
+ const t = tokens[i];
1066
+ if (t !== void 0 && /^[01]{2,}/.test(t)) {
1067
+ tokens[i] = t.slice(1);
1068
+ return Number(t[0]);
1069
+ }
1070
+ return num();
1071
+ };
1072
+ const rings = [];
1073
+ let ring = [];
1074
+ let cx = 0, cy = 0, sx = 0, sy = 0;
1075
+ let lastCtrl = null;
1076
+ let lastCmd = "";
1077
+ const push = (x, y) => {
1078
+ ring.push([x, y]);
1079
+ cx = x;
1080
+ cy = y;
1081
+ };
1082
+ const lines = [];
1083
+ const close = (closed = false) => {
1084
+ if (ring.length >= 2) lines.push({ points: ring.slice(), closed });
1085
+ if (ring.length >= 3) rings.push(ring);
1086
+ ring = [];
1087
+ };
1088
+ const cubic = (x1, y1, x2, y2, x, y) => {
1089
+ 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);
1090
+ const n = segmentsFor(Math.hypot(x - cx, y - cy), dev * 4);
1091
+ const x0 = cx, y0 = cy;
1092
+ for (let k = 1; k <= n; k++) {
1093
+ const t = k / n, u = 1 - t;
1094
+ push(
1095
+ u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x,
1096
+ u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y
1097
+ );
1098
+ }
1099
+ lastCtrl = [x2, y2];
1100
+ };
1101
+ const quad = (x1, y1, x, y) => {
1102
+ const dev = Math.hypot(x1 - cx, y1 - cy) + Math.hypot(x - x1, y - y1) - Math.hypot(x - cx, y - cy);
1103
+ const n = segmentsFor(Math.hypot(x - cx, y - cy), dev * 4);
1104
+ const x0 = cx, y0 = cy;
1105
+ for (let k = 1; k <= n; k++) {
1106
+ const t = k / n, u = 1 - t;
1107
+ push(u * u * x0 + 2 * u * t * x1 + t * t * x, u * u * y0 + 2 * u * t * y1 + t * t * y);
1108
+ }
1109
+ lastCtrl = [x1, y1];
1110
+ };
1111
+ const arc = (rx, ry, phiDeg, large, sweep, x, y) => {
1112
+ const x0 = cx, y0 = cy;
1113
+ if (rx === 0 || ry === 0 || x0 === x && y0 === y) {
1114
+ push(x, y);
1115
+ return;
1116
+ }
1117
+ rx = Math.abs(rx);
1118
+ ry = Math.abs(ry);
1119
+ const phi = phiDeg * Math.PI / 180, cp = Math.cos(phi), sp = Math.sin(phi);
1120
+ const dx = (x0 - x) / 2, dy = (y0 - y) / 2;
1121
+ const x1p = cp * dx + sp * dy, y1p = -sp * dx + cp * dy;
1122
+ const lam = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
1123
+ if (lam > 1) {
1124
+ rx *= Math.sqrt(lam);
1125
+ ry *= Math.sqrt(lam);
1126
+ }
1127
+ const num2 = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
1128
+ const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
1129
+ let co = Math.sqrt(Math.max(0, num2 / den));
1130
+ if (large === sweep) co = -co;
1131
+ const cxp = co * rx * y1p / ry, cyp = -co * ry * x1p / rx;
1132
+ const ccx = cp * cxp - sp * cyp + (x0 + x) / 2, ccy = sp * cxp + cp * cyp + (y0 + y) / 2;
1133
+ const ang = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
1134
+ const t1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
1135
+ let dt = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
1136
+ if (!sweep && dt > 0) dt -= 2 * Math.PI;
1137
+ if (sweep && dt < 0) dt += 2 * Math.PI;
1138
+ const r = Math.max(rx, ry);
1139
+ const n = Math.max(2, Math.min(96, Math.ceil(Math.abs(dt) / (2 * Math.acos(Math.max(0, 1 - PATH_TOLERANCE / r))))));
1140
+ for (let k = 1; k <= n; k++) {
1141
+ const t = t1 + dt * k / n;
1142
+ const ex = rx * Math.cos(t), ey = ry * Math.sin(t);
1143
+ push(cp * ex - sp * ey + ccx, sp * ex + cp * ey + ccy);
1144
+ }
1145
+ };
1146
+ while (i < tokens.length) {
1147
+ let cmd = tokens[i];
1148
+ if (/^[a-zA-Z]$/.test(cmd)) i++;
1149
+ else if (lastCmd) cmd = lastCmd === "M" ? "L" : lastCmd === "m" ? "l" : lastCmd;
1150
+ else throw new Error("path must start with M");
1151
+ const rel = cmd === cmd.toLowerCase() && cmd !== "z" ? true : false;
1152
+ const ox = rel ? cx : 0, oy = rel ? cy : 0;
1153
+ const C = cmd.toUpperCase();
1154
+ const prevCmd = lastCmd.toUpperCase();
1155
+ if (C !== "S" && C !== "T") lastCtrl = null;
1156
+ switch (C) {
1157
+ case "M": {
1158
+ close();
1159
+ const x = num() + ox, y = num() + oy;
1160
+ push(x, y);
1161
+ sx = x;
1162
+ sy = y;
1163
+ break;
1164
+ }
1165
+ case "L":
1166
+ push(num() + ox, num() + oy);
1167
+ break;
1168
+ case "H":
1169
+ push(num() + ox, cy);
1170
+ break;
1171
+ case "V":
1172
+ push(cx, num() + oy);
1173
+ break;
1174
+ case "C": {
1175
+ const a = num() + ox, b = num() + oy, c = num() + ox, e = num() + oy;
1176
+ cubic(a, b, c, e, num() + ox, num() + oy);
1177
+ break;
1178
+ }
1179
+ case "S": {
1180
+ const r = lastCtrl && (prevCmd === "C" || prevCmd === "S") ? [2 * cx - lastCtrl[0], 2 * cy - lastCtrl[1]] : [cx, cy];
1181
+ const c = num() + ox, e = num() + oy;
1182
+ cubic(r[0], r[1], c, e, num() + ox, num() + oy);
1183
+ break;
1184
+ }
1185
+ case "Q": {
1186
+ const a = num() + ox, b = num() + oy;
1187
+ quad(a, b, num() + ox, num() + oy);
1188
+ break;
1189
+ }
1190
+ case "T": {
1191
+ const r = lastCtrl && (prevCmd === "Q" || prevCmd === "T") ? [2 * cx - lastCtrl[0], 2 * cy - lastCtrl[1]] : [cx, cy];
1192
+ quad(r[0], r[1], num() + ox, num() + oy);
1193
+ break;
1194
+ }
1195
+ case "A": {
1196
+ const rx = num(), ry = num(), ph = num(), la = flag(), sw = flag();
1197
+ arc(rx, ry, ph, la, sw, num() + ox, num() + oy);
1198
+ break;
1199
+ }
1200
+ case "Z":
1201
+ cx = sx;
1202
+ cy = sy;
1203
+ close(true);
1204
+ break;
1205
+ default:
1206
+ throw new Error(`unsupported path command '${cmd}'`);
1207
+ }
1208
+ lastCmd = cmd;
1209
+ }
1210
+ close();
1211
+ for (const r of rings) {
1212
+ const a = r[0], b = r[r.length - 1];
1213
+ if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9) r.pop();
1214
+ }
1215
+ for (const l of lines) {
1216
+ const a = l.points[0], b = l.points[l.points.length - 1];
1217
+ if (l.closed && l.points.length > 2 && Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9) l.points.pop();
1218
+ }
1219
+ return { rings, lines };
1220
+ }
1221
+ function simplifyLine(r) {
1222
+ if (r.length <= 2) return r;
1223
+ const keep = new Uint8Array(r.length);
1224
+ keep[0] = 1;
1225
+ keep[r.length - 1] = 1;
1226
+ const stack = [[0, r.length - 1]];
1227
+ while (stack.length) {
1228
+ const [a, b] = stack.pop();
1229
+ const A = r[a], B = r[b];
1230
+ const dx = B[0] - A[0], dy = B[1] - A[1], len = Math.hypot(dx, dy);
1231
+ let best = -1, bd = 0;
1232
+ for (let k = a + 1; k < b; k++) {
1233
+ const P = r[k];
1234
+ 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]);
1235
+ if (d > bd) {
1236
+ bd = d;
1237
+ best = k;
1238
+ }
1239
+ }
1240
+ if (best >= 0 && bd > PATH_TOLERANCE) {
1241
+ keep[best] = 1;
1242
+ stack.push([a, best], [best, b]);
1243
+ }
1244
+ }
1245
+ return r.filter((_, k) => keep[k] === 1);
1246
+ }
1247
+ function simplifyRing(r) {
1248
+ if (r.length <= 4) return r;
1249
+ let far = 0, fd = -1;
1250
+ for (let k = 1; k < r.length; k++) {
1251
+ const d = (r[k][0] - r[0][0]) ** 2 + (r[k][1] - r[0][1]) ** 2;
1252
+ if (d > fd) {
1253
+ fd = d;
1254
+ far = k;
1255
+ }
1256
+ }
1257
+ const keep = new Uint8Array(r.length);
1258
+ keep[0] = 1;
1259
+ keep[far] = 1;
1260
+ const at = (k) => r[k % r.length];
1261
+ const stack = [[0, far], [far, r.length]];
1262
+ while (stack.length) {
1263
+ const [a, b] = stack.pop();
1264
+ const A = at(a), B = at(b);
1265
+ const dx = B[0] - A[0], dy = B[1] - A[1], len = Math.hypot(dx, dy);
1266
+ let best = -1, bd = 0;
1267
+ for (let k = a + 1; k < b; k++) {
1268
+ const P = at(k);
1269
+ 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]);
1270
+ if (d > bd) {
1271
+ bd = d;
1272
+ best = k;
1273
+ }
1274
+ }
1275
+ if (best >= 0 && bd > PATH_TOLERANCE) {
1276
+ keep[best % r.length] = 1;
1277
+ stack.push([a, best], [best, b]);
1278
+ }
1279
+ }
1280
+ return r.filter((_, k) => keep[k] === 1);
1281
+ }
1282
+ function signedArea(r) {
1283
+ let a = 0;
1284
+ for (let k = 0; k < r.length; k++) {
1285
+ const p = r[k], q = r[(k + 1) % r.length];
1286
+ a += p[0] * q[1] - q[0] * p[1];
1287
+ }
1288
+ return a / 2;
1289
+ }
1290
+ function inside(pt, r) {
1291
+ let c = false;
1292
+ for (let k = 0, j = r.length - 1; k < r.length; j = k++) {
1293
+ const a = r[k], b = r[j];
1294
+ 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;
1295
+ }
1296
+ return c;
1297
+ }
1298
+ function containsRing(r, rb, sub, sb) {
1299
+ if (sb.x0 < rb.x0 || sb.x1 > rb.x1 || sb.y0 < rb.y0 || sb.y1 > rb.y1) return false;
1300
+ for (const p of sub) if (!inside(p, r)) return false;
1301
+ return true;
1302
+ }
1303
+ function boxOf(r) {
1304
+ const b = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity };
1305
+ for (const [x, y] of r) {
1306
+ b.x0 = Math.min(b.x0, x);
1307
+ b.x1 = Math.max(b.x1, x);
1308
+ b.y0 = Math.min(b.y0, y);
1309
+ b.y1 = Math.max(b.y1, y);
1310
+ }
1311
+ return b;
1312
+ }
1313
+ function joinRings(rings) {
1314
+ if (rings.length === 0) return [];
1315
+ const boxes = rings.map(boxOf);
1316
+ const areas = rings.map((r) => Math.abs(signedArea(r)));
1317
+ const order = rings.map((_, i) => i).sort((p, q) => areas[q] - areas[p]);
1318
+ const mult = new Array(rings.length).fill(0);
1319
+ const net = new Array(rings.length).fill(0);
1320
+ const done = [];
1321
+ for (const n of order) {
1322
+ const containers = done.filter((m) => areas[m] > areas[n] && containsRing(rings[m], boxes[m], rings[n], boxes[n]));
1323
+ const around = containers.reduce((sum, m) => sum + mult[m], 0);
1324
+ let want = 1;
1325
+ if (containers.length) {
1326
+ const parent = containers.reduce((p, m) => areas[m] < areas[p] ? m : p);
1327
+ want = net[parent] !== 0 ? 0 : 1;
1328
+ }
1329
+ net[n] = want;
1330
+ mult[n] = want - around;
1331
+ done.push(n);
1332
+ }
1333
+ const traced = [];
1334
+ const trace = (r, m) => {
1335
+ const o = Math.sign(signedArea(r)) === Math.sign(m) ? r : r.slice().reverse();
1336
+ for (let k = 0; k < Math.abs(m); k++) traced.push(o);
1337
+ };
1338
+ for (let n = 0; n < rings.length; n++) {
1339
+ if (mult[n] !== 0) trace(rings[n], mult[n]);
1340
+ }
1341
+ const holes = rings.map((_, n) => n).filter((n) => net[n] === 0 && mult[n] !== 0 && isConvex(rings[n]));
1342
+ for (let m = 0; m < rings.length; m++) {
1343
+ if (mult[m] <= 0) continue;
1344
+ const bm = boxes[m];
1345
+ const crossing = holes.filter((n) => {
1346
+ const bn = boxes[n];
1347
+ if (n === m || bm.x1 <= bn.x0 || bm.x0 >= bn.x1 || bm.y1 <= bn.y0 || bm.y0 >= bn.y1) return false;
1348
+ return !containsRing(rings[m], bm, rings[n], bn) && !containsRing(rings[n], bn, rings[m], bm);
1349
+ });
1350
+ for (const n of crossing) {
1351
+ if (crossing.some((o) => o !== n && areas[o] > areas[n] && containsRing(rings[o], boxes[o], rings[n], boxes[n]))) continue;
1352
+ const piece = clipToConvex(rings[m], rings[n]);
1353
+ if (piece.length >= 3 && Math.abs(signedArea(piece)) > 1e-9) trace(piece, -mult[m]);
1354
+ }
1355
+ }
1356
+ return bridge(traced);
1357
+ }
1358
+ function isConvex(r) {
1359
+ let sign = 0;
1360
+ for (let k = 0; k < r.length; k++) {
1361
+ const a = r[k], b = r[(k + 1) % r.length], c = r[(k + 2) % r.length];
1362
+ const z = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
1363
+ if (Math.abs(z) < 1e-12) continue;
1364
+ if (sign === 0) sign = Math.sign(z);
1365
+ else if (Math.sign(z) !== sign) return false;
1366
+ }
1367
+ return true;
1368
+ }
1369
+ function clipToConvex(subject, clip) {
1370
+ const o = Math.sign(signedArea(clip)) || 1;
1371
+ let out = subject;
1372
+ for (let k = 0; k < clip.length && out.length; k++) {
1373
+ const a = clip[k], b = clip[(k + 1) % clip.length];
1374
+ const side = (p) => o * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
1375
+ const cut = (p, q) => {
1376
+ const sp = side(p), sq = side(q), t = sp / (sp - sq);
1377
+ return [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
1378
+ };
1379
+ const next = [];
1380
+ for (let i = 0; i < out.length; i++) {
1381
+ const p = out[i], q = out[(i + 1) % out.length];
1382
+ const pin = side(p) >= 0, qin = side(q) >= 0;
1383
+ if (pin) next.push(p);
1384
+ if (pin !== qin) next.push(cut(p, q));
1385
+ }
1386
+ out = next;
1387
+ }
1388
+ return out;
1389
+ }
1390
+ function bridge(rings) {
1391
+ if (rings.length === 0) return [];
1392
+ let ring = rings[0].slice();
1393
+ for (let n = 1; n < rings.length; n++) {
1394
+ const sub = rings[n];
1395
+ let bi = 0, bj = 0, best = Infinity;
1396
+ for (let a = 0; a < ring.length; a++) {
1397
+ for (let b = 0; b < sub.length; b++) {
1398
+ const dd = (ring[a][0] - sub[b][0]) ** 2 + (ring[a][1] - sub[b][1]) ** 2;
1399
+ if (dd < best) {
1400
+ best = dd;
1401
+ bi = a;
1402
+ bj = b;
1403
+ }
1404
+ }
1405
+ }
1406
+ const rot = sub.slice(bj).concat(sub.slice(0, bj));
1407
+ ring = ring.slice(0, bi + 1).concat(rot, [rot[0], ring[bi]], ring.slice(bi + 1));
1408
+ }
1409
+ return ring;
1410
+ }
1411
+ function pathDrawCost(s) {
1412
+ let c = 0;
1413
+ for (const p of pathParts(s)) {
1414
+ if (p.fill) c += p.flat.points.length * (p.gradient ? 2 : 1);
1415
+ if (p.stroke) for (const l of p.flat.lines) c += l.points.length * 3;
1416
+ }
1417
+ return c;
1418
+ }
1419
+
677
1420
  // src/validate.ts
678
1421
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
679
1422
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -719,7 +1462,8 @@ var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "c
719
1462
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
720
1463
  var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
721
1464
  var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
722
- var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
1465
+ var KNOWN_PATH_PART = /* @__PURE__ */ new Set(["path", "color", "fillRule", "fill", "gradient", "stroke", "opacity"]);
1466
+ var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "path", "fillRule", "fill", "gradient", "stroke", "parts", "span", "origin", "soft", "colors", "colorWeights"]);
723
1467
  var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
724
1468
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
725
1469
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle", "role"]);
@@ -776,7 +1520,7 @@ function validateSpec(spec) {
776
1520
  }
777
1521
  function ignoredPropertyPaths(spec) {
778
1522
  if (!isObj(spec)) return [];
779
- return (validateSpecCore(spec).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
1523
+ return (validateSpecCore(spec, false).warnings ?? []).filter((w) => w.code === "unknown-property" || w.code === "misplaced-property").map((w) => w.path.replace(/\[(\d+)\]/g, ".$1"));
780
1524
  }
781
1525
  function validateSpecPaths(spec, paths) {
782
1526
  const layers = /* @__PURE__ */ new Set();
@@ -814,7 +1558,7 @@ function validateSpecPaths(spec, paths) {
814
1558
  }
815
1559
  return ok;
816
1560
  }
817
- function validateSpecCore(spec) {
1561
+ function validateSpecCore(spec, checkApplied = true) {
818
1562
  const errors = [];
819
1563
  const warnings = [];
820
1564
  const err = (path, message) => void errors.push({ path, message });
@@ -859,6 +1603,23 @@ function validateSpecCore(spec) {
859
1603
  err("layers", "must be a non-empty array");
860
1604
  } else {
861
1605
  if (spec.layers.length > LIMITS.maxLayers) err("layers", `at most ${LIMITS.maxLayers} layers`);
1606
+ {
1607
+ let pathPoints = 0;
1608
+ for (const l of spec.layers) {
1609
+ const sp = isObj(l) ? l.sprite : void 0;
1610
+ if (!sp || sp.kind !== "polygon") continue;
1611
+ const pieces = Array.isArray(sp.parts) ? sp.parts : typeof sp.path === "string" ? [sp] : [];
1612
+ const n = typeof l.count === "number" && l.count > 0 ? l.count : 1;
1613
+ for (const pc of pieces) {
1614
+ if (!isObj(pc) || typeof pc.path !== "string") continue;
1615
+ const fl = flattenPath(pc.path, pc.fillRule ?? "auto");
1616
+ pathPoints += ((pc.fill === false ? 0 : fl.points.length) + (isObj(pc.stroke) ? fl.lines.reduce((m, x) => m + x.points.length, 0) : 0)) * n;
1617
+ }
1618
+ }
1619
+ if (pathPoints > MAX_SCENE_PATH_POINTS) {
1620
+ err("layers", `path outlines total ${pathPoints} points (flattened points \xD7 count); the scene budget is ${MAX_SCENE_PATH_POINTS}`);
1621
+ }
1622
+ }
862
1623
  let total = 0;
863
1624
  spec.layers.forEach((layer, i) => {
864
1625
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
@@ -866,7 +1627,7 @@ function validateSpecCore(spec) {
866
1627
  });
867
1628
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
868
1629
  }
869
- if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn);
1630
+ if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn, checkApplied);
870
1631
  return { valid: errors.length === 0, errors, warnings };
871
1632
  }
872
1633
  function validateTransform(tf, path, err, warn, spec, anchorOk) {
@@ -904,7 +1665,7 @@ function validateGroups(groups, err, warn, spec) {
904
1665
  if (g.transform !== void 0) validateTransform(g.transform, `${p}.transform`, err, warn, spec, false);
905
1666
  }
906
1667
  }
907
- function validateTimeline(spec, errOuter, warn) {
1668
+ function validateTimeline(spec, errOuter, warn, checkApplied = true) {
908
1669
  let failed = false;
909
1670
  const err = (p, m) => {
910
1671
  failed = true;
@@ -958,20 +1719,22 @@ function validateTimeline(spec, errOuter, warn) {
958
1719
  if (!ok || !cp) return;
959
1720
  const t = key.t;
960
1721
  latest = Math.max(latest, t);
961
- const applied = applyDeltasToSpec(base, [{ t: 0, path: cp, value: key.value }]);
962
- const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(applied);
963
- if (!r.valid) {
964
- for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
965
- return;
966
- }
967
- if (isObj(key.value) && cp.endsWith("transform")) {
968
- const before = readSpecPath(base, cp);
969
- const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
970
- const o1 = key.value.origin ?? "viewport";
971
- if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
972
- }
973
- if (structuralSignature(applied) !== baseSig) {
974
- warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
1722
+ if (checkApplied) {
1723
+ const applied = applyDeltasShared(base, [{ t: 0, path: cp, value: key.value }]);
1724
+ const r = validateSpecPaths(applied, [cp]) ? { valid: true, errors: [] } : validateSpec(structuredClone(applied));
1725
+ if (!r.valid) {
1726
+ for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
1727
+ return;
1728
+ }
1729
+ if (isObj(key.value) && cp.endsWith("transform")) {
1730
+ const before = readSpecPath(base, cp);
1731
+ const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
1732
+ const o1 = key.value.origin ?? "viewport";
1733
+ if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
1734
+ }
1735
+ if (structuralSignature(applied) !== baseSig) {
1736
+ warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
1737
+ }
975
1738
  }
976
1739
  const list = times.get(cp) ?? [];
977
1740
  list.push(t);
@@ -997,16 +1760,16 @@ function validateTimeline(spec, errOuter, warn) {
997
1760
  if (wrapGap < minGap) err("timeline.keys", `keys on '${path}' at ${distinct[distinct.length - 1]} ms and ${distinct[0]} ms (next lap) are closer than ${minGap} ms across the loop wrap (flash safety)`);
998
1761
  }
999
1762
  }
1000
- if (failed) return;
1763
+ if (failed || !checkApplied) return;
1001
1764
  const keyed = [...timelineTracks(spec).keys()];
1002
1765
  const seen = /* @__PURE__ */ new Set();
1003
1766
  for (const t of timelineSampleTimes(spec, true, 120)) {
1004
- const r = resolveTimelineAt(spec, t);
1767
+ const r = resolveTimelineAt(spec, t, true);
1005
1768
  const state = JSON.stringify(keyed.map((p) => readSpecPath(r, p)));
1006
1769
  if (seen.has(state)) continue;
1007
1770
  seen.add(state);
1008
1771
  if (validateSpecPaths(r, keyed)) continue;
1009
- const e = validateSpec(r).errors[0];
1772
+ const e = validateSpec(structuredClone(r)).errors[0];
1010
1773
  err("timeline", `at t = ${Math.round(t)} ms the keys combine into an invalid scene \u2014 ${e ? `${e.path || "<root>"}: ${e.message}` : "invalid"}`);
1011
1774
  return;
1012
1775
  }
@@ -1232,10 +1995,20 @@ function validateLayer(layer, path, err, warn, spec) {
1232
1995
  if (layer.links !== void 0) {
1233
1996
  if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
1234
1997
  else {
1235
- if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
1236
- err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
1998
+ const chain = layer.links.mode === "chain";
1999
+ if (!chain || layer.links.k !== void 0) {
2000
+ if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
2001
+ err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
2002
+ }
2003
+ }
2004
+ if (!chain || layer.links.maxDist !== void 0 || layer.links.falloff === true) {
2005
+ positiveScalar(
2006
+ layer.links.maxDist,
2007
+ `${path}.links.maxDist`,
2008
+ err,
2009
+ chain && layer.links.maxDist === void 0 ? "is required (> 0) when a chain sets falloff: true \u2014 it is the fade distance" : void 0
2010
+ );
1237
2011
  }
1238
- positiveScalar(layer.links.maxDist, `${path}.links.maxDist`, err);
1239
2012
  if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
1240
2013
  if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
1241
2014
  err(`${path}.links.alpha`, "must be 0..1");
@@ -1476,6 +2249,34 @@ function validateSprite(sprite, path, err, warn, spec) {
1476
2249
  err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
1477
2250
  }
1478
2251
  if (sprite.points !== void 0) validateShapePoints(sprite.points, `${path}.points`, 3, err);
2252
+ if (sprite.parts !== void 0) {
2253
+ for (const k of ["path", "points", "sides", "gradient", "stroke", "fill", "fillRule"]) {
2254
+ if (sprite[k] !== void 0) err(`${path}.${k}`, `with parts, put ${k} on each part (or drop parts)`);
2255
+ }
2256
+ if (!Array.isArray(sprite.parts) || sprite.parts.length === 0 || sprite.parts.length > MAX_PATH_PARTS) {
2257
+ err(`${path}.parts`, `must be 1..${MAX_PATH_PARTS} parts, each { path, color?, gradient?, stroke?, fill?, fillRule?, opacity? }`);
2258
+ } else {
2259
+ sprite.parts.forEach((part, i) => {
2260
+ const pp = `${path}.parts[${i}]`;
2261
+ if (!isObj(part)) return err(pp, "must be { path, color?, gradient?, stroke?, fill?, fillRule?, opacity? }");
2262
+ validatePathPiece(part, pp, err, warn);
2263
+ if (part.color !== void 0) color(part.color, `${pp}.color`, err);
2264
+ if (part.opacity !== void 0 && (!isNum(part.opacity) || part.opacity < 0 || part.opacity > 1)) err(`${pp}.opacity`, "must be 0..1");
2265
+ for (const k of unknownKeys(part, KNOWN_PATH_PART)) warn(`${pp}.${k}`, "unknown-property", `unknown part property '${k}' \u2014 will be ignored`);
2266
+ });
2267
+ }
2268
+ } else if (sprite.path !== void 0) {
2269
+ if (sprite.points !== void 0 || sprite.sides !== void 0) err(`${path}.path`, "use path, points or sides, not more than one");
2270
+ validatePathPiece(sprite, path, err, warn);
2271
+ } else {
2272
+ 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)`);
2273
+ if (sprite.fillRule !== void 0) warn(`${path}.fillRule`, "unknown-property", "fillRule applies only to a path \u2014 ignored");
2274
+ }
2275
+ if (sprite.span !== void 0 || sprite.origin !== void 0) {
2276
+ 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)");
2277
+ if (sprite.span !== void 0 && !["box", "width", "height", "screen"].includes(sprite.span)) err(`${path}.span`, "must be 'box' | 'width' | 'height' | 'screen'");
2278
+ 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");
2279
+ }
1479
2280
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
1480
2281
  color(sprite.color, `${path}.color`, err);
1481
2282
  validatePalette(sprite, path, err);
@@ -1732,6 +2533,70 @@ function validateMotion(motion, path, err, warn, spec) {
1732
2533
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
1733
2534
  }
1734
2535
  }
2536
+ 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;
2537
+ var MAX_PATH_PARTS = 256;
2538
+ var pathVerdicts = /* @__PURE__ */ new Map();
2539
+ var pathVerdictBytes = 0;
2540
+ function pathGeometryProblem(d, rule, filled) {
2541
+ const key = `${rule}|${filled ? 1 : 0}|${d}`;
2542
+ const hit = pathVerdicts.get(key);
2543
+ if (hit !== void 0) return hit;
2544
+ const flat = flattenPath(d, rule);
2545
+ const inBox = ([x, y]) => Math.abs(x) <= 1.0001 && Math.abs(y) <= 1.0001;
2546
+ const problem = flat.error ? `not a usable path: ${flat.error}` : filled && flat.points.length < 3 ? "must enclose an area (at least 3 points), or set fill: false and a stroke for line art" : !filled && flat.lines.length === 0 ? "must draw at least one line (2+ points)" : flat.points.length > MAX_PATH_POINTS ? `flattens to ${flat.points.length} points; at most ${MAX_PATH_POINTS}` : !flat.points.every(inBox) || !flat.lines.every((l) => l.points.every(inBox)) ? "must stay inside the -1..1 unit box (curves included)" : null;
2547
+ if (pathVerdicts.size >= 1024 || pathVerdictBytes + key.length > 2e6) {
2548
+ pathVerdicts.clear();
2549
+ pathVerdictBytes = 0;
2550
+ }
2551
+ pathVerdicts.set(key, problem);
2552
+ pathVerdictBytes += key.length;
2553
+ return problem;
2554
+ }
2555
+ function validatePathPiece(piece, path, err, warn) {
2556
+ if (typeof piece.path !== "string" || piece.path.length === 0 || piece.path.length > 65536) {
2557
+ err(`${path}.path`, "must be an SVG path d string (1..65536 chars) in the -1..1 unit box");
2558
+ } else {
2559
+ const problem = pathGeometryProblem(piece.path, piece.fillRule ?? "auto", piece.fill !== false);
2560
+ if (problem) err(`${path}.path`, problem);
2561
+ }
2562
+ if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2563
+ if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
2564
+ if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err);
2565
+ if (piece.stroke !== void 0) validatePathStroke(piece.stroke, `${path}.stroke`, err);
2566
+ if (piece.fillRule !== void 0 && !["auto", "nonzero", "evenodd"].includes(piece.fillRule)) err(`${path}.fillRule`, "must be 'auto' | 'nonzero' | 'evenodd'");
2567
+ void warn;
2568
+ }
2569
+ function validatePathGradient(g, path, err) {
2570
+ if (!isObj(g)) return err(path, "must be { type: 'linear', from, to, stops } or { type: 'radial', center, radius, stops }");
2571
+ if (g.type === "linear") {
2572
+ if (!isPair(g.from)) err(`${path}.from`, "must be [x, y] in unit-box coordinates (-2..2)");
2573
+ if (!isPair(g.to)) err(`${path}.to`, "must be [x, y] in unit-box coordinates (-2..2)");
2574
+ } else if (g.type === "radial") {
2575
+ if (!isPair(g.center)) err(`${path}.center`, "must be [x, y] in unit-box coordinates (-2..2)");
2576
+ if (!isNum(g.radius) || g.radius <= 0 || g.radius > 4) err(`${path}.radius`, "must be > 0 and <= 4 (unit-box units)");
2577
+ if (g.focus !== void 0 && !isPair(g.focus)) err(`${path}.focus`, "must be [x, y] in unit-box coordinates (-2..2)");
2578
+ } else {
2579
+ return err(`${path}.type`, "must be 'linear' | 'radial'");
2580
+ }
2581
+ 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)");
2582
+ if (!Array.isArray(g.stops) || g.stops.length < 2 || g.stops.length > 8) return err(`${path}.stops`, "must be 2..8 stops");
2583
+ let last = -Infinity;
2584
+ g.stops.forEach((st, i) => {
2585
+ if (!isObj(st)) return err(`${path}.stops[${i}]`, "must be { at, color, opacity? }");
2586
+ if (!isNum(st.at) || st.at < 0 || st.at > 1 || st.at < last) err(`${path}.stops[${i}].at`, "must be 0..1, not decreasing");
2587
+ else last = st.at;
2588
+ color(st.color, `${path}.stops[${i}].color`, err);
2589
+ if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.stops[${i}].opacity`, "must be 0..1");
2590
+ });
2591
+ }
2592
+ function validatePathStroke(st, path, err) {
2593
+ if (!isObj(st)) return err(path, "must be { color, width, opacity?, join?, cap? }");
2594
+ color(st.color, `${path}.color`, err);
2595
+ 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)");
2596
+ if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.opacity`, "must be 0..1");
2597
+ if (st.join !== void 0 && !["round", "miter", "bevel"].includes(st.join)) err(`${path}.join`, "must be 'round' | 'miter' | 'bevel'");
2598
+ if (st.cap !== void 0 && !["round", "butt", "square"].includes(st.cap)) err(`${path}.cap`, "must be 'round' | 'butt' | 'square'");
2599
+ }
1735
2600
  function validateShapePoints(points, path, min, err) {
1736
2601
  if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
1737
2602
  return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
@@ -1900,199 +2765,32 @@ function validateSequence(seq) {
1900
2765
  code: "morph-nothing-morphable",
1901
2766
  message: `segments ${i}\u2192${i + 1} differ only in values morph cannot interpolate (strings such as textBlock.text step on the first frame): the morph will look like a cut \u2014 fade text via colour or reveal.progress`
1902
2767
  });
1903
- }
1904
- }
1905
- }
1906
- if (errors.length === 0 && seq.wrapMorph === true) {
1907
- const sq = seq;
1908
- const last = sq.segments.length - 1;
1909
- const lastTr = sq.segments[last]?.transition;
1910
- let why = null;
1911
- if (sq.loop !== true) why = "the sequence does not loop";
1912
- else if (last < 1) why = "there is only one segment";
1913
- else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
1914
- else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
1915
- else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
1916
- if (why || !canWrapMorph(sq)) {
1917
- warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
1918
- }
1919
- }
1920
- return { valid: errors.length === 0, errors, warnings };
1921
- }
1922
- function assertValidSequence(seq) {
1923
- const r = validateSequence(seq);
1924
- if (!r.valid) {
1925
- throw new Error(`invalid sequence:
1926
- ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
1927
- }
1928
- return seq;
1929
- }
1930
-
1931
- // src/shapes.ts
1932
- function isShapedSprite(s) {
1933
- return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
1934
- }
1935
- function barFraction(s, index) {
1936
- const n = s.values.length;
1937
- if (n === 0) return 0;
1938
- const v = s.values[(index % n + n) % n] ?? 0;
1939
- const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
1940
- if (!(max > 0)) return 0;
1941
- return Math.max(0, Math.min(1, v / max));
1942
- }
1943
- function barBox(direction, len, thick) {
1944
- switch (direction) {
1945
- case "left":
1946
- return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1947
- case "up":
1948
- return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
1949
- case "down":
1950
- return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
1951
- default:
1952
- return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1953
- }
1954
- }
1955
- var DEFAULT_POLYGON_SIDES = 6;
1956
- var STROKE_SAMPLES = 24;
1957
- function polygonPoints(s, radius) {
1958
- if (s.points && s.points.length >= 3) {
1959
- return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
1960
- }
1961
- const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
1962
- const out = [];
1963
- for (let k = 0; k < n; k++) {
1964
- const a = -Math.PI / 2 + 2 * Math.PI * k / n;
1965
- out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
1966
- }
1967
- return out;
1968
- }
1969
- function polygonArea(pts) {
1970
- let a = 0;
1971
- for (let i = 0; i < pts.length; i++) {
1972
- const p = pts[i];
1973
- const q = pts[(i + 1) % pts.length];
1974
- a += p.x * q.y - q.x * p.y;
1975
- }
1976
- return Math.abs(a) / 2;
1977
- }
1978
- function polygonFill(s, radius) {
1979
- if (radius <= 0) return 0;
1980
- const disc = Math.PI * radius * radius;
1981
- return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
1982
- }
1983
- function catmullRom(p0, p1, p2, p3, u) {
1984
- const u2 = u * u;
1985
- const u3 = u2 * u;
1986
- return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
1987
- }
1988
- function strokeSamples(s, halfSize, n) {
1989
- const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
1990
- const m = pts.length;
1991
- if (m < 2) return pts;
1992
- const smooth = s.curve !== "linear" && m >= 3;
1993
- const segs = m - 1;
1994
- n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
1995
- const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
1996
- const out = [];
1997
- for (let i = 0; i < n; i++) {
1998
- const u = i / (n - 1) * segs;
1999
- const seg = Math.min(segs - 1, Math.floor(u));
2000
- const local = u - seg;
2001
- const p1 = at(seg);
2002
- const p2 = at(seg + 1);
2003
- if (!smooth) {
2004
- out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
2005
- } else {
2006
- const p0 = at(seg - 1);
2007
- const p3 = at(seg + 2);
2008
- out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
2009
- }
2010
- }
2011
- return out;
2012
- }
2013
- function pathLength(pts) {
2014
- let l = 0;
2015
- for (let i = 1; i < pts.length; i++) {
2016
- const dx = pts[i].x - pts[i - 1].x;
2017
- const dy = pts[i].y - pts[i - 1].y;
2018
- l += Math.sqrt(dx * dx + dy * dy);
2019
- }
2020
- return l;
2021
- }
2022
- function strokeTaper(u) {
2023
- return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
2024
- }
2025
- function strokeWidthPx(s, scale) {
2026
- return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2027
- }
2028
- function featherRamp(u, feather) {
2029
- const band = Math.max(0, Math.min(1, feather)) / 2;
2030
- const d = Math.min(u, 1 - u);
2031
- if (band <= 0) return d >= 0 ? 1 : 0;
2032
- return Math.max(0, Math.min(1, d / band));
2033
- }
2034
- var FEATHER_MASK_SIZE = 128;
2035
- function ribbonSides(pts, half) {
2036
- const n = pts.length;
2037
- const left = [];
2038
- const right = [];
2039
- if (n < 2) return { left, right };
2040
- const dir = (a, b) => {
2041
- const dx = b.x - a.x;
2042
- const dy = b.y - a.y;
2043
- const l = Math.hypot(dx, dy);
2044
- return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2045
- };
2046
- for (let i = 0; i < n; i++) {
2047
- const tIn = i > 0 ? dir(pts[i - 1], pts[i]) : dir(pts[0], pts[1]);
2048
- const tOut = i < n - 1 ? dir(pts[i], pts[i + 1]) : tIn;
2049
- let tx = tIn.x + tOut.x;
2050
- let ty = tIn.y + tOut.y;
2051
- const tl = Math.hypot(tx, ty);
2052
- if (tl < 1e-9) {
2053
- tx = tOut.x;
2054
- ty = tOut.y;
2055
- } else {
2056
- tx /= tl;
2057
- ty /= tl;
2058
- }
2059
- const hw = half[i] / Math.max(0.5, tx * tOut.x + ty * tOut.y);
2060
- const p = pts[i];
2061
- left.push({ x: p.x - ty * hw, y: p.y + tx * hw });
2062
- right.push({ x: p.x + ty * hw, y: p.y - tx * hw });
2063
- }
2064
- return { left, right };
2065
- }
2066
- function strokeOutline(pts, lw, taper = true) {
2067
- const n = pts.length;
2068
- if (n < 2) return [];
2069
- const half = (i) => Math.max(0.5, taper ? lw * strokeTaper(i / (n - 1)) : lw) / 2;
2070
- const dir = (a, b) => {
2071
- const dx = b.x - a.x;
2072
- const dy = b.y - a.y;
2073
- const l = Math.hypot(dx, dy);
2074
- return l > 1e-9 ? { x: dx / l, y: dy / l } : { x: 1, y: 0 };
2075
- };
2076
- const { left, right } = ribbonSides(pts, Array.from({ length: n }, (_, i) => half(i)));
2077
- const cap = (c, r, from) => {
2078
- const out = [];
2079
- const steps = 6;
2080
- for (let k = 1; k < steps; k++) {
2081
- const a = from - Math.PI * k / steps;
2082
- out.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
2768
+ }
2083
2769
  }
2084
- return out;
2085
- };
2086
- const endT = dir(pts[n - 2], pts[n - 1]);
2087
- const startT = dir(pts[0], pts[1]);
2088
- const endA = Math.atan2(endT.y, endT.x) + Math.PI / 2;
2089
- const startA = Math.atan2(startT.y, startT.x) - Math.PI / 2;
2090
- return [
2091
- ...left,
2092
- ...cap(pts[n - 1], half(n - 1), endA),
2093
- ...right.reverse(),
2094
- ...cap(pts[0], half(0), startA)
2095
- ];
2770
+ }
2771
+ if (errors.length === 0 && seq.wrapMorph === true) {
2772
+ const sq = seq;
2773
+ const last = sq.segments.length - 1;
2774
+ const lastTr = sq.segments[last]?.transition;
2775
+ let why = null;
2776
+ if (sq.loop !== true) why = "the sequence does not loop";
2777
+ else if (last < 1) why = "there is only one segment";
2778
+ else if (lastTr?.type !== "morph") why = "the last segment declares no morph transition";
2779
+ else if (structuralSignature(sq.segments[last].scene) !== structuralSignature(sq.segments[0].scene)) why = `segment ${last} and segment 0 differ structurally`;
2780
+ else if (morphChainRoot(sq, last) !== 0) why = `the lap is not one morph chain (segment ${morphChainRoot(sq, last)} starts a new chain), so the wrap would jump seeds`;
2781
+ if (why || !canWrapMorph(sq)) {
2782
+ warnings.push({ path: "wrapMorph", code: "wrap-morph-inactive", message: `wrapMorph has no effect: ${why ?? "the wrap cannot morph"} \u2014 the wrap stays a cut` });
2783
+ }
2784
+ }
2785
+ return { valid: errors.length === 0, errors, warnings };
2786
+ }
2787
+ function assertValidSequence(seq) {
2788
+ const r = validateSequence(seq);
2789
+ if (!r.valid) {
2790
+ throw new Error(`invalid sequence:
2791
+ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
2792
+ }
2793
+ return seq;
2096
2794
  }
2097
2795
 
2098
2796
  // src/simulate.ts
@@ -2616,9 +3314,13 @@ function wrapDist(pi, pj, doWrap, w, h) {
2616
3314
  }
2617
3315
  return Math.sqrt(dx * dx + dy * dy);
2618
3316
  }
3317
+ function linkMaxDistPx(links, scale) {
3318
+ return links.maxDist === void 0 ? Infinity : links.maxDist * scale;
3319
+ }
2619
3320
  function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2620
3321
  const n = positions.length;
2621
3322
  const mode = links.mode ?? "nearest";
3323
+ const k = links.k ?? 1;
2622
3324
  if (mode === "chain") {
2623
3325
  const out = [];
2624
3326
  for (let i = 0; i < n - 1; i++) {
@@ -2632,7 +3334,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2632
3334
  const edges = /* @__PURE__ */ new Set();
2633
3335
  const out = [];
2634
3336
  for (let i = 0; i < n; i++) {
2635
- for (let mIdx = 1; mIdx <= links.k; mIdx++) {
3337
+ for (let mIdx = 1; mIdx <= k; mIdx++) {
2636
3338
  const j = (i + mIdx * stride) % n;
2637
3339
  if (j === i) continue;
2638
3340
  const dist = wrapDist(positions[i], positions[j], doWrap, w, h);
@@ -2646,7 +3348,7 @@ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
2646
3348
  }
2647
3349
  return out;
2648
3350
  }
2649
- return linkPairs(positions, links.k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
3351
+ return linkPairs(positions, k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
2650
3352
  i,
2651
3353
  j,
2652
3354
  dist: wrapDist(positions[i], positions[j], doWrap, w, h)
@@ -2886,6 +3588,10 @@ function paintOpacity(spec, layer) {
2886
3588
  const g = groupOf(spec, layer);
2887
3589
  return g?.opacity === void 0 ? own : own * clamp01(g.opacity);
2888
3590
  }
3591
+ function paintRotation(spec, layer) {
3592
+ const deg = (layer.transform?.rotate ?? 0) + (groupOf(spec, layer)?.transform?.rotate ?? 0);
3593
+ return deg * Math.PI / 180;
3594
+ }
2889
3595
  function paintSize(spec, layer) {
2890
3596
  return transformSize(layer.transform) * transformSize(groupOf(spec, layer)?.transform);
2891
3597
  }
@@ -3325,6 +4031,45 @@ function overridesStayValid(authored, overrides, now) {
3325
4031
  }
3326
4032
  return true;
3327
4033
  }
4034
+ var PATH_BLIT_MIN_POINTS = 128;
4035
+ var PLATE_MIN_POINTS = 2500;
4036
+ var PATH_BLIT_PER_SPRITE = 8;
4037
+ var PATH_SLOT_MAX = 96;
4038
+ var PATH_SPRITE_PER_SPRITE = 96;
4039
+ var PATH_SPRITE_MAX_PIXELS = 512 * 512;
4040
+ var PATH_BLIT_MIN_DENSITY = 1500;
4041
+ var PATH_BLIT_MAX_PIXELS = 16e6;
4042
+ function ringPath2D(lines) {
4043
+ const p2 = new Path2D();
4044
+ for (const l of lines) {
4045
+ if (l.points.length < 2) continue;
4046
+ p2.moveTo(l.points[0][0], l.points[0][1]);
4047
+ for (let i = 1; i < l.points.length; i++) p2.lineTo(l.points[i][0], l.points[i][1]);
4048
+ if (l.closed) p2.closePath();
4049
+ }
4050
+ return p2;
4051
+ }
4052
+ function traceLines(g, lines) {
4053
+ g.beginPath();
4054
+ for (const l of lines) {
4055
+ if (l.points.length < 2) continue;
4056
+ g.moveTo(l.points[0][0], l.points[0][1]);
4057
+ for (let i = 1; i < l.points.length; i++) g.lineTo(l.points[i][0], l.points[i][1]);
4058
+ if (l.closed) g.closePath();
4059
+ }
4060
+ }
4061
+ function pathGradientFill(g, gr) {
4062
+ 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);
4063
+ for (const st of gr.stops) grad.addColorStop(st.at, st.opacity === void 0 ? st.color : hexToRgba(st.color, st.opacity));
4064
+ return grad;
4065
+ }
4066
+ function softFill(g, color2, r) {
4067
+ const grad = g.createRadialGradient(0, 0, 0, 0, 0, r);
4068
+ grad.addColorStop(0, color2);
4069
+ grad.addColorStop(0.35, hexToRgba(color2, 0.75));
4070
+ grad.addColorStop(1, hexToRgba(color2, 0));
4071
+ return grad;
4072
+ }
3328
4073
  var SpecInstance = class {
3329
4074
  constructor(spec, ctx, opts = {}) {
3330
4075
  this.spec = spec;
@@ -3435,6 +4180,20 @@ var SpecInstance = class {
3435
4180
  paintSeq = 0;
3436
4181
  /** Per layer: ribbon area painted this frame (px²), and whether its trails ribbon (see ribbonAllowed). */
3437
4182
  ribbonBudget = /* @__PURE__ */ new WeakMap();
4183
+ /**
4184
+ * Render-once cache for path polygons (polygon.path). A detailed drawing is
4185
+ * rasterised once per (colour, device size, rotation, sub-pixel offset)
4186
+ * into its own bitmap and copied 1:1 at a whole-pixel offset every frame
4187
+ * after — so a static 7,000-point drawing costs a blit, not a re-fill.
4188
+ * Keyed by the sprite object, so a steer or republish (a new spec object)
4189
+ * starts clean. A drawing whose key keeps changing (spinning, trailing,
4190
+ * growing) stops being cached and draws from a reused Path2D instead.
4191
+ */
4192
+ pathBlits = /* @__PURE__ */ new Map();
4193
+ /** Test/bench switch: `globalThis.__idleNoPathCache = true` before compiling draws every path directly. */
4194
+ noPathCache = globalThis.__idleNoPathCache === true;
4195
+ /** Device pixels held by every pathBlits bitmap — capped so a TV never pays more than PATH_BLIT_MAX_PIXELS of memory. */
4196
+ pathBlitPixels = 0;
3438
4197
  featherTints = /* @__PURE__ */ new Map();
3439
4198
  /**
3440
4199
  * The presentation pass when this instance applies a `finish`: the
@@ -3713,6 +4472,277 @@ var SpecInstance = class {
3713
4472
  return rb.ribbon;
3714
4473
  }
3715
4474
  /** A fresh 2D canvas (DOM or Offscreen), or null where none can be made. */
4475
+ /**
4476
+ * A drawing's cache slot, keyed by what it draws (pathPaintSig), not by the
4477
+ * sprite object — glides and timelines replace that object every frame.
4478
+ * Least-recently-used slots are evicted past PATH_SLOT_MAX, and their
4479
+ * bitmaps' pixels returned to the budget.
4480
+ */
4481
+ pathSlot(sprite) {
4482
+ const sig = pathPaintSig(sprite);
4483
+ let slot = this.pathBlits.get(sig);
4484
+ if (slot) {
4485
+ this.pathBlits.delete(sig);
4486
+ this.pathBlits.set(sig, slot);
4487
+ return slot;
4488
+ }
4489
+ if (this.pathBlits.size >= PATH_SLOT_MAX) {
4490
+ const [oldSig, old] = this.pathBlits.entries().next().value;
4491
+ for (const ent of old.entries.values()) this.pathBlitPixels -= ent.px;
4492
+ this.pathBlits.delete(oldSig);
4493
+ }
4494
+ slot = { entries: /* @__PURE__ */ new Map(), flushes: 0, off: false };
4495
+ this.pathBlits.set(sig, slot);
4496
+ return slot;
4497
+ }
4498
+ /**
4499
+ * Whether a layer's path drawing looks the same as last frame — the caches
4500
+ * serve only steady drawings; one that is gliding (colour, gradient, shape)
4501
+ * draws directly until it settles, instead of rasterising a bitmap a frame.
4502
+ */
4503
+ // Keyed by the layer's place in the scene, never the per-layer record: a
4504
+ // scene that animates a baked field (alpha, size) rebuilds every record
4505
+ // every frame, and a drawing beside it is still steady.
4506
+ pathSteady = /* @__PURE__ */ new Map();
4507
+ drawingSteady(built, sprite) {
4508
+ const sig = pathPaintSig(sprite);
4509
+ const li = this.layers.indexOf(built);
4510
+ const prev = this.pathSteady.get(li);
4511
+ if (prev && prev.seq === this.paintSeq && prev.sig === sig) return prev.steady;
4512
+ const steady = prev !== void 0 && prev.sig === sig;
4513
+ this.pathSteady.set(li, { sig, seq: this.paintSeq, steady });
4514
+ return steady;
4515
+ }
4516
+ /**
4517
+ * Draw a path polygon in its own unit box: the caller has moved to the
4518
+ * entity and rotated; this scales by r. Parts paint back to front (a single
4519
+ * `path` is one part). Each fill is its gradient (drawing-box coordinates,
4520
+ * or with units 'part' the part's own box, filled under that box's
4521
+ * transform so a radial reads as the part's ellipse), the soft radial
4522
+ * falloff, or flat colour; each stroke traces the part's subpaths as drawn,
4523
+ * width in unit-box units. Outlines are Path2Ds built once per sprite.
4524
+ */
4525
+ drawPathUnit(g, sprite, color2, fr, r) {
4526
+ const slot = this.pathSlot(sprite);
4527
+ const parts = pathParts(sprite);
4528
+ if (!slot.parts2d) slot.parts2d = parts.map(() => ({}));
4529
+ if (fr.ox || fr.oy) g.translate(-fr.ox * fr.sx, -fr.oy * fr.sy);
4530
+ g.scale(fr.sx, fr.sy);
4531
+ const a0 = g.globalAlpha;
4532
+ for (let i = 0; i < parts.length; i++) {
4533
+ const part = parts[i];
4534
+ const cache = slot.parts2d[i];
4535
+ const pc = part.color ?? color2;
4536
+ if (part.fill && part.flat.points.length >= 3) {
4537
+ g.globalAlpha = a0 * part.opacity;
4538
+ if (part.gradient?.units === "part") {
4539
+ const { cx, cy, hw, hh } = part.box;
4540
+ if (cache.fillPart === void 0) {
4541
+ cache.fillPart = typeof Path2D === "undefined" ? null : ringPath2D([{ points: part.flat.points.map(([x, y]) => [(x - cx) / hw, (y - cy) / hh]), closed: true }]);
4542
+ }
4543
+ g.save();
4544
+ g.transform(hw, 0, 0, hh, cx, cy);
4545
+ g.fillStyle = pathGradientFill(g, part.gradient);
4546
+ if (cache.fillPart) g.fill(cache.fillPart, part.rule);
4547
+ else {
4548
+ traceLines(g, [{ points: part.flat.points.map(([x, y]) => [(x - cx) / hw, (y - cy) / hh]), closed: true }]);
4549
+ g.fill(part.rule);
4550
+ }
4551
+ g.restore();
4552
+ } else {
4553
+ g.fillStyle = part.gradient ? pathGradientFill(g, part.gradient) : sprite.soft && !sprite.parts ? softFill(g, pc, 1) : pc;
4554
+ if (cache.fill === void 0) cache.fill = typeof Path2D === "undefined" ? null : ringPath2D([{ points: part.flat.points, closed: true }]);
4555
+ if (cache.fill) g.fill(cache.fill, part.rule);
4556
+ else {
4557
+ traceLines(g, [{ points: part.flat.points, closed: true }]);
4558
+ g.fill(part.rule);
4559
+ }
4560
+ }
4561
+ }
4562
+ const st = part.stroke;
4563
+ if (st && part.flat.lines.length) {
4564
+ g.globalAlpha = a0 * part.opacity * (st.opacity ?? 1);
4565
+ g.strokeStyle = st.color;
4566
+ g.lineJoin = st.join ?? "round";
4567
+ g.lineCap = st.cap ?? "round";
4568
+ if (fr.uniform) {
4569
+ g.lineWidth = st.width * r / fr.sx;
4570
+ if (cache.stroke === void 0) cache.stroke = typeof Path2D === "undefined" ? null : ringPath2D(part.flat.lines);
4571
+ if (cache.stroke) g.stroke(cache.stroke);
4572
+ else {
4573
+ traceLines(g, part.flat.lines);
4574
+ g.stroke();
4575
+ }
4576
+ } else {
4577
+ const scaled = part.flat.lines.map((l) => ({ closed: l.closed, points: l.points.map(([x, y]) => [x * fr.sx, y * fr.sy]) }));
4578
+ g.save();
4579
+ g.scale(1 / fr.sx, 1 / fr.sy);
4580
+ g.lineWidth = st.width * r;
4581
+ traceLines(g, scaled);
4582
+ g.stroke();
4583
+ g.restore();
4584
+ }
4585
+ }
4586
+ }
4587
+ g.globalAlpha = a0;
4588
+ }
4589
+ /**
4590
+ * Draw a path polygon from its render-once bitmap. False when it cannot be
4591
+ * blitted exactly — a rotated or skewed layer transform, a bitmap past the
4592
+ * memory cap, a drawing too sparse for its area, or one that keeps
4593
+ * changing — and the caller draws it directly.
4594
+ */
4595
+ blitPath(sprite, p, r, rot, color2, fr) {
4596
+ const ctx = this.ctx;
4597
+ const slot = this.pathSlot(sprite);
4598
+ if (slot.off) return false;
4599
+ if (slot.sprite) return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4600
+ const m = ctx.getTransform();
4601
+ if (Math.abs(m.b) > 1e-9 || Math.abs(m.c) > 1e-9 || Math.abs(m.a - m.d) > 1e-9 || !(m.a > 0)) {
4602
+ slot.sprite = true;
4603
+ return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4604
+ }
4605
+ const k = m.a;
4606
+ const dx = k * p.x + m.e;
4607
+ const dy = k * p.y + m.f;
4608
+ const ix = Math.floor(dx);
4609
+ const iy = Math.floor(dy);
4610
+ const fx = Math.round((dx - ix) * 8) / 8;
4611
+ const fy = Math.round((dy - iy) * 8) / 8;
4612
+ 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}`;
4613
+ let hit = slot.entries.get(key);
4614
+ if (!hit) {
4615
+ if (slot.entries.size >= PATH_BLIT_PER_SPRITE) {
4616
+ for (const old of slot.entries.values()) this.pathBlitPixels -= old.px;
4617
+ slot.entries.clear();
4618
+ if (++slot.flushes > 1) {
4619
+ slot.sprite = true;
4620
+ slot.flushes = 0;
4621
+ return this.spriteBlit(sprite, slot, p, r, rot, color2, fr);
4622
+ }
4623
+ }
4624
+ const c = Math.cos(rot);
4625
+ const sn = Math.sin(rot);
4626
+ const s = r * k;
4627
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
4628
+ const grow = (u) => {
4629
+ const ux = (u[0] - fr.ox) * fr.sx * k, uy = (u[1] - fr.oy) * fr.sy * k;
4630
+ const x = ux * c - uy * sn;
4631
+ const y = ux * sn + uy * c;
4632
+ if (x < x0) x0 = x;
4633
+ if (x > x1) x1 = x;
4634
+ if (y < y0) y0 = y;
4635
+ if (y > y1) y1 = y;
4636
+ };
4637
+ let sw = 0;
4638
+ for (const part of pathParts(sprite)) {
4639
+ if (part.fill) for (const u of part.flat.points) grow(u);
4640
+ if (part.stroke) {
4641
+ sw = Math.max(sw, part.stroke.width);
4642
+ for (const l of part.flat.lines) for (const u of l.points) grow(u);
4643
+ }
4644
+ }
4645
+ const pad = 2 + Math.ceil(sw * s);
4646
+ const bx = Math.floor(fx + x0) - pad;
4647
+ const by = Math.floor(fy + y0) - pad;
4648
+ const w = Math.ceil(fx + x1) - bx + pad;
4649
+ const h = Math.ceil(fy + y1) - by + pad;
4650
+ if (!(w > 0 && h > 0) || !Number.isFinite(w + h)) return false;
4651
+ if (pathDrawCost(sprite) / (w * h / 1e6) < PATH_BLIT_MIN_DENSITY) {
4652
+ slot.off = true;
4653
+ return false;
4654
+ }
4655
+ if (this.pathBlitPixels + w * h > PATH_BLIT_MAX_PIXELS) return false;
4656
+ const made = this.makeCanvas(1);
4657
+ if (!made) return false;
4658
+ made.canvas.width = w;
4659
+ made.canvas.height = h;
4660
+ const g = made.ctx;
4661
+ g.setTransform(k, 0, 0, k, fx - bx, fy - by);
4662
+ if (rot) g.rotate(rot);
4663
+ this.drawPathUnit(g, sprite, color2, fr, r);
4664
+ hit = { canvas: made.canvas, x0: bx, y0: by, px: w * h };
4665
+ this.pathBlitPixels += hit.px;
4666
+ slot.entries.set(key, hit);
4667
+ }
4668
+ ctx.save();
4669
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4670
+ ctx.drawImage(hit.canvas, ix + hit.x0, iy + hit.y0);
4671
+ ctx.restore();
4672
+ return true;
4673
+ }
4674
+ /**
4675
+ * The moving-sprite cache: for a drawing whose exact key keeps changing. It
4676
+ * is rasterised once per colour, UNROTATED, at a device radius rounded UP to
4677
+ * the next 1/8-octave step (so it is only ever shrunk, never enlarged), then
4678
+ * drawn every frame at its true sub-pixel position, rotation and size under
4679
+ * the context's own transform, with smoothing. Not pixel-exact like the
4680
+ * still cache (edges resample by well under a pixel), but a 7-part drifting
4681
+ * balloon costs one image draw instead of seven fills and strokes.
4682
+ */
4683
+ spriteBlit(sprite, slot, p, r, rot, color2, fr) {
4684
+ const ctx = this.ctx;
4685
+ if (!fr.uniform || fr.sx !== r) return false;
4686
+ const m = ctx.getTransform();
4687
+ const k = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c));
4688
+ const s = r * k;
4689
+ if (!(s >= 1)) return false;
4690
+ const bucket = 2 ** (Math.ceil(Math.log2(s) * 8) / 8);
4691
+ const key = `S|${color2}|${bucket.toFixed(3)}`;
4692
+ let hit = slot.entries.get(key);
4693
+ if (!hit) {
4694
+ if (slot.entries.size >= PATH_SPRITE_PER_SPRITE) {
4695
+ for (const old of slot.entries.values()) this.pathBlitPixels -= old.px;
4696
+ slot.entries.clear();
4697
+ if (++slot.flushes > 2) {
4698
+ slot.off = true;
4699
+ return false;
4700
+ }
4701
+ }
4702
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, sw = 0;
4703
+ const grow = (u2) => {
4704
+ if (u2[0] < x0) x0 = u2[0];
4705
+ if (u2[0] > x1) x1 = u2[0];
4706
+ if (u2[1] < y0) y0 = u2[1];
4707
+ if (u2[1] > y1) y1 = u2[1];
4708
+ };
4709
+ for (const part of pathParts(sprite)) {
4710
+ if (part.fill) for (const u2 of part.flat.points) grow([u2[0] - fr.ox, u2[1] - fr.oy]);
4711
+ if (part.stroke) {
4712
+ sw = Math.max(sw, part.stroke.width);
4713
+ for (const l of part.flat.lines) for (const u2 of l.points) grow([u2[0] - fr.ox, u2[1] - fr.oy]);
4714
+ }
4715
+ }
4716
+ const pad = 2 + Math.ceil(sw * bucket);
4717
+ const ox = Math.ceil(-x0 * bucket) + pad;
4718
+ const oy = Math.ceil(-y0 * bucket) + pad;
4719
+ const w = ox + Math.ceil(x1 * bucket) + pad;
4720
+ const h = oy + Math.ceil(y1 * bucket) + pad;
4721
+ if (!(w > 0 && h > 0) || !Number.isFinite(w + h)) return false;
4722
+ if (w * h > PATH_SPRITE_MAX_PIXELS) {
4723
+ slot.off = true;
4724
+ return false;
4725
+ }
4726
+ if (this.pathBlitPixels + w * h > PATH_BLIT_MAX_PIXELS) return false;
4727
+ const made = this.makeCanvas(1);
4728
+ if (!made) return false;
4729
+ made.canvas.width = w;
4730
+ made.canvas.height = h;
4731
+ made.ctx.setTransform(1, 0, 0, 1, ox, oy);
4732
+ this.drawPathUnit(made.ctx, sprite, color2, { sx: bucket, sy: bucket, ox: fr.ox, oy: fr.oy, uniform: true }, bucket);
4733
+ hit = { canvas: made.canvas, x0: ox, y0: oy, px: w * h };
4734
+ this.pathBlitPixels += hit.px;
4735
+ slot.entries.set(key, hit);
4736
+ }
4737
+ const u = r / bucket;
4738
+ ctx.save();
4739
+ ctx.translate(p.x, p.y);
4740
+ if (rot) ctx.rotate(rot);
4741
+ ctx.imageSmoothingEnabled = true;
4742
+ ctx.drawImage(hit.canvas, -hit.x0 * u, -hit.y0 * u, hit.canvas.width * u, hit.canvas.height * u);
4743
+ ctx.restore();
4744
+ return true;
4745
+ }
3716
4746
  makeCanvas(n) {
3717
4747
  try {
3718
4748
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : new OffscreenCanvas(n, n);
@@ -3944,24 +4974,26 @@ var SpecInstance = class {
3944
4974
  if (sprite.kind === "polygon") {
3945
4975
  const r = sz / 2;
3946
4976
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
3947
- const pts = polygonPoints(sprite, r);
4977
+ if (isPathPolygon(sprite)) {
4978
+ const fr = pathFrame(sprite, r, this.w, this.h);
4979
+ if (pathDrawCost(sprite) >= PATH_BLIT_MIN_POINTS && !this.noPathCache && this.drawingSteady(built, sprite) && this.blitPath(sprite, p, r, rot, resolvedColor, fr)) return;
4980
+ ctx.save();
4981
+ ctx.translate(p.x, p.y);
4982
+ if (rot) ctx.rotate(rot);
4983
+ if (r > 0 && fr.sx > 0 && fr.sy > 0) this.drawPathUnit(ctx, sprite, resolvedColor, fr, r);
4984
+ ctx.restore();
4985
+ return;
4986
+ }
3948
4987
  ctx.save();
3949
4988
  ctx.translate(p.x, p.y);
3950
4989
  if (rot) ctx.rotate(rot);
3951
- if (sprite.soft) {
3952
- const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
3953
- g.addColorStop(0, resolvedColor);
3954
- g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
3955
- g.addColorStop(1, hexToRgba(resolvedColor, 0));
3956
- ctx.fillStyle = g;
3957
- } else {
3958
- ctx.fillStyle = resolvedColor;
3959
- }
4990
+ ctx.fillStyle = sprite.soft ? softFill(ctx, resolvedColor, r) : resolvedColor;
4991
+ const pts = polygonPoints(sprite, r);
3960
4992
  ctx.beginPath();
3961
4993
  ctx.moveTo(pts[0].x, pts[0].y);
3962
4994
  for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
3963
4995
  ctx.closePath();
3964
- ctx.fill();
4996
+ ctx.fill(polygonFillRule(sprite));
3965
4997
  ctx.restore();
3966
4998
  return;
3967
4999
  }
@@ -4200,9 +5232,9 @@ var SpecInstance = class {
4200
5232
  const motionWraps = ["drift", "rise", "wander"].includes(built.layer.motion.type);
4201
5233
  const wrap2 = built.layer.wrap !== false && motionWraps;
4202
5234
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
4203
- const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
4204
- const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
4205
5235
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
5236
+ const maxDistPx = linkMaxDistPx(links, unitScale);
5237
+ const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
4206
5238
  const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4207
5239
  const lw = (links.width ?? defaultWidth) * unitScale;
4208
5240
  ctx.lineWidth = lw;
@@ -4219,7 +5251,7 @@ var SpecInstance = class {
4219
5251
  } else resolvedColor = "#e6e8ef";
4220
5252
  }
4221
5253
  let a = links.alpha ?? alphaAt(ei, t);
4222
- if (links.falloff) a *= Math.max(0, 1 - dist / maxDistPx);
5254
+ if (links.falloff && Number.isFinite(maxDistPx)) a *= Math.max(0, 1 - dist / maxDistPx);
4223
5255
  ctx.globalAlpha = a * lifeA;
4224
5256
  ctx.strokeStyle = resolvedColor;
4225
5257
  let dx = pj.x - pi.x;
@@ -4246,38 +5278,212 @@ var SpecInstance = class {
4246
5278
  this.drawBackground(t);
4247
5279
  }
4248
5280
  ctx.globalAlpha = 1;
4249
- for (let li = 0; li < this.layers.length; li++) {
4250
- const built = this.layers[li];
4251
- const opacity = built.layer.opacity;
4252
- const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
4253
- let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
4254
- if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
4255
- if (lifeA <= 0) continue;
4256
- const parentE = this.parentEntityFor(built);
4257
- const tf = built.layer.transform;
4258
- const gtf = group?.transform;
4259
- if (tf || gtf) {
4260
- ctx.save();
4261
- if (gtf) this.applyLayerTransform(gtf, null);
4262
- if (tf) this.applyLayerTransform(tf, built.layer);
4263
- }
4264
- ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
4265
- this.drawLinks(built, t, lifeA, parentE);
4266
- const override = this.paintOverrides?.get(li) ?? null;
4267
- for (const e of built.entities) {
4268
- this.drawTrail(built, e, t, lifeA, parentE);
4269
- if (override === null) {
4270
- this.drawEntity(built, e, t, lifeA, parentE);
5281
+ const n = this.layers.length;
5282
+ for (let li = 0; li < n; li++) {
5283
+ if (!this.noPathCache && this.plateable(li, t)) {
5284
+ let j = li;
5285
+ while (j + 1 < n && this.plateable(j + 1, t)) j++;
5286
+ if (j > li && this.runPoints(li, j) >= PLATE_MIN_POINTS && this.paintPlate(li, j, t)) {
5287
+ li = j;
4271
5288
  continue;
4272
5289
  }
4273
- const [aOut, aIn] = override.mode === "dip" ? [Math.max(0, 1 - 2 * override.k), Math.max(0, 2 * override.k - 1)] : [1 - override.k, override.k];
4274
- if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
4275
- if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
4276
5290
  }
4277
- if (tf || gtf) ctx.restore();
5291
+ this.paintLayer(li, t);
5292
+ }
5293
+ ctx.globalAlpha = 1;
5294
+ ctx.globalCompositeOperation = "source-over";
5295
+ }
5296
+ /** Paint layer `li` at time t onto this.ctx (the scene canvas, or a plate being rebuilt). */
5297
+ paintLayer(li, t) {
5298
+ const { ctx } = this;
5299
+ const built = this.layers[li];
5300
+ const opacity = built.layer.opacity;
5301
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5302
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
5303
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
5304
+ if (lifeA <= 0) return;
5305
+ const parentE = this.parentEntityFor(built);
5306
+ const tf = built.layer.transform;
5307
+ const gtf = group?.transform;
5308
+ if (tf || gtf) {
5309
+ ctx.save();
5310
+ if (gtf) this.applyLayerTransform(gtf, null);
5311
+ if (tf) this.applyLayerTransform(tf, built.layer);
5312
+ }
5313
+ ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
5314
+ this.drawLinks(built, t, lifeA, parentE);
5315
+ const override = this.paintOverrides?.get(li) ?? null;
5316
+ for (const e of built.entities) {
5317
+ this.drawTrail(built, e, t, lifeA, parentE);
5318
+ if (override === null) {
5319
+ this.drawEntity(built, e, t, lifeA, parentE);
5320
+ continue;
5321
+ }
5322
+ 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];
5323
+ if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
5324
+ if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
5325
+ }
5326
+ if (tf || gtf) ctx.restore();
5327
+ }
5328
+ /**
5329
+ * Static plates. Consecutive path-polygon layers painted source-over, with
5330
+ * no trails, links or text overrides, are composited into ONE full-frame
5331
+ * bitmap and copied once per frame while nothing they draw has changed —
5332
+ * the change test is the drawn values themselves (each entity's position,
5333
+ * size, rotation, alpha and colour, the layer's life alpha and transforms),
5334
+ * so anything animated simply repaints its plate that frame. Source-over is
5335
+ * associative, so the plate over the frame equals the layers over it.
5336
+ */
5337
+ plates = /* @__PURE__ */ new Map();
5338
+ /** Outline points a run of path layers fills per frame (points × entities). */
5339
+ runPoints(a, b) {
5340
+ let n = 0;
5341
+ for (let li = a; li <= b; li++) {
5342
+ const built = this.layers[li];
5343
+ const sp = built.layer.sprite;
5344
+ if (sp.kind === "polygon" && isPathPolygon(sp)) n += pathDrawCost(sp) * built.entities.length;
4278
5345
  }
5346
+ return n;
5347
+ }
5348
+ /** Last drawn-value signature per layer, for plate stability. */
5349
+ layerSigs = /* @__PURE__ */ new Map();
5350
+ /**
5351
+ * A layer joins a plate only if it can (path polygon, source-over, no
5352
+ * trails/links/text override) AND nothing it draws changed since the last
5353
+ * frame — so a pulsing or moving layer splits the run and paints live, while
5354
+ * the static layers around it keep their plates.
5355
+ */
5356
+ plateable(li, t) {
5357
+ const built = this.layers[li];
5358
+ const l = built.layer;
5359
+ const sp = l.sprite;
5360
+ if (!isPathPolygon(sp)) return false;
5361
+ if ((l.blend ?? "source-over") !== "source-over") return false;
5362
+ if (l.trail || l.links || this.paintOverrides?.has(li)) return false;
5363
+ if (built.entities.length > 64) return false;
5364
+ const sig = this.layerSig(li, t);
5365
+ const prev = this.layerSigs.get(li);
5366
+ this.layerSigs.set(li, sig);
5367
+ return prev === sig;
5368
+ }
5369
+ layerSig(li, t) {
5370
+ const built = this.layers[li];
5371
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5372
+ const opacity = built.layer.opacity;
5373
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
5374
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
5375
+ let sig = `${pathPaintSig(built.layer.sprite)}:${lifeA.toFixed(4)}:${JSON.stringify(built.layer.transform ?? null)}:${JSON.stringify(group?.transform ?? null)}`;
5376
+ const parentE = this.parentEntityFor(built);
5377
+ for (const e of built.entities) {
5378
+ const p = this.entityPos(e, t, parentE);
5379
+ 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}`;
5380
+ }
5381
+ return sig;
5382
+ }
5383
+ plateSig(a, b) {
5384
+ let sig = `${a}-${b}`;
5385
+ for (let li = a; li <= b; li++) sig += `#${this.layerSigs.get(li)}`;
5386
+ return sig;
5387
+ }
5388
+ /** Max |point| of a path polygon's unit outline (its reach as a fraction of the entity radius). */
5389
+ pathReach = /* @__PURE__ */ new WeakMap();
5390
+ reachOf(sp) {
5391
+ let r = this.pathReach.get(sp);
5392
+ if (r === void 0) {
5393
+ r = 0;
5394
+ let sw = 0;
5395
+ for (const part of pathParts(sp)) {
5396
+ for (const [x, y] of part.flat.points) r = Math.max(r, Math.hypot(x, y));
5397
+ for (const l of part.flat.lines) for (const [x, y] of l.points) r = Math.max(r, Math.hypot(x, y));
5398
+ if (part.stroke) sw = Math.max(sw, part.stroke.width);
5399
+ }
5400
+ r += sw;
5401
+ this.pathReach.set(sp, r || 1);
5402
+ }
5403
+ return r || 1;
5404
+ }
5405
+ /**
5406
+ * Device-pixel box a run of layers can paint into, or null for "the whole
5407
+ * frame" (a layer/group transform moves things in ways this does not model).
5408
+ */
5409
+ runBox(a, b, t) {
5410
+ const m = this.ctx.getTransform();
5411
+ if (Math.abs(m.b) > 1e-9 || Math.abs(m.c) > 1e-9) return null;
5412
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
5413
+ for (let li = a; li <= b; li++) {
5414
+ const built = this.layers[li];
5415
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
5416
+ if (built.layer.transform || group?.transform) return null;
5417
+ const reach = this.reachOf(built.layer.sprite);
5418
+ const parentE = this.parentEntityFor(built);
5419
+ for (const e of built.entities) {
5420
+ const p = this.entityPos(e, t, parentE);
5421
+ const sp = built.layer.sprite;
5422
+ const fr = pathFrame(sp, sizeAt(e, t) / 2, this.w, this.h);
5423
+ const ext = Math.max(fr.sx, fr.sy) * (reach + Math.hypot(fr.ox, fr.oy));
5424
+ x0 = Math.min(x0, p.x - ext);
5425
+ x1 = Math.max(x1, p.x + ext);
5426
+ y0 = Math.min(y0, p.y - ext);
5427
+ y1 = Math.max(y1, p.y + ext);
5428
+ }
5429
+ }
5430
+ const W = this.canvas.width, H = this.canvas.height;
5431
+ const bx = Math.max(0, Math.floor(m.a * x0 + m.e) - 2);
5432
+ const by = Math.max(0, Math.floor(m.d * y0 + m.f) - 2);
5433
+ const bw = Math.min(W, Math.ceil(m.a * x1 + m.e) + 2) - bx;
5434
+ const bh = Math.min(H, Math.ceil(m.d * y1 + m.f) + 2) - by;
5435
+ return bw > 0 && bh > 0 ? { x: bx, y: by, w: bw, h: bh } : { x: 0, y: 0, w: 0, h: 0 };
5436
+ }
5437
+ /** Paint layers a..b from their plate, rebuilding it if anything drawn changed. False = paint them normally. */
5438
+ paintPlate(a, b, t) {
5439
+ const sig = this.plateSig(a, b);
5440
+ let plate = this.plates.get(a);
5441
+ if (!plate || plate.sig !== sig || plate.end !== b || plate.frameW !== this.canvas.width || plate.frameH !== this.canvas.height) {
5442
+ const box = this.runBox(a, b, t) ?? { x: 0, y: 0, w: this.canvas.width, h: this.canvas.height };
5443
+ if (!plate) {
5444
+ if (this.plates.size >= 4) this.plates.delete(this.plates.keys().next().value);
5445
+ const made = this.makeCanvas(1);
5446
+ if (!made) return false;
5447
+ plate = { ...made, end: b, sig: "", box, frameW: 0, frameH: 0 };
5448
+ this.plates.set(a, plate);
5449
+ }
5450
+ plate.box = box;
5451
+ plate.frameW = this.canvas.width;
5452
+ plate.frameH = this.canvas.height;
5453
+ if (box.w === 0 || box.h === 0) {
5454
+ plate.sig = sig;
5455
+ plate.end = b;
5456
+ return true;
5457
+ }
5458
+ if (plate.canvas.width !== box.w) plate.canvas.width = box.w;
5459
+ if (plate.canvas.height !== box.h) plate.canvas.height = box.h;
5460
+ const pc = plate.ctx;
5461
+ pc.setTransform(1, 0, 0, 1, 0, 0);
5462
+ pc.globalAlpha = 1;
5463
+ pc.globalCompositeOperation = "source-over";
5464
+ pc.clearRect(0, 0, box.w, box.h);
5465
+ const m = this.ctx.getTransform();
5466
+ pc.setTransform(m.a, m.b, m.c, m.d, m.e - box.x, m.f - box.y);
5467
+ const self = this;
5468
+ const main = self.ctx;
5469
+ self.ctx = pc;
5470
+ try {
5471
+ for (let li = a; li <= b; li++) this.paintLayer(li, t);
5472
+ } finally {
5473
+ self.ctx = main;
5474
+ }
5475
+ plate.sig = sig;
5476
+ plate.end = b;
5477
+ }
5478
+ if (plate.box.w === 0 || plate.box.h === 0) return true;
5479
+ const ctx = this.ctx;
5480
+ ctx.save();
5481
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4279
5482
  ctx.globalAlpha = 1;
4280
5483
  ctx.globalCompositeOperation = "source-over";
5484
+ ctx.drawImage(plate.canvas, plate.box.x, plate.box.y);
5485
+ ctx.restore();
5486
+ return true;
4281
5487
  }
4282
5488
  /**
4283
5489
  * A `transform`, in min(w,h) units (px under `units: 'px'`), about the
@@ -5129,6 +6335,7 @@ function compileSequence(spec) {
5129
6335
 
5130
6336
  // src/advise.ts
5131
6337
  import { createRng as createRng3 } from "@idle-screens/core";
6338
+ import { analyzeFlashes as analyzeFlashes2, srgbToLinear as srgbToLinear3 } from "@idle-screens/validator";
5132
6339
 
5133
6340
  // src/luma.ts
5134
6341
  function fieldRenderedRgbs(bg) {
@@ -5150,12 +6357,53 @@ function hexLuma(hex) {
5150
6357
  }
5151
6358
  function spriteLuma(layer, e) {
5152
6359
  const s = layer.sprite;
6360
+ if (s.kind === "polygon" && isPathPolygon(s)) {
6361
+ const base = s.colors?.[e.colorIndex] ?? s.color;
6362
+ let sum = 0, wsum = 0;
6363
+ for (const part of pathParts(s)) {
6364
+ const pc = hexLuma(part.color ?? base);
6365
+ let l;
6366
+ if (!part.fill) l = part.stroke ? hexLuma(part.stroke.color) : pc;
6367
+ else if (part.gradient) {
6368
+ let g = 0;
6369
+ for (const st of part.gradient.stops) {
6370
+ const o = st.opacity ?? 1;
6371
+ g += hexLuma(st.color) * o + pc * (1 - o);
6372
+ }
6373
+ l = g / part.gradient.stops.length;
6374
+ } else l = pc;
6375
+ 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;
6376
+ sum += l * w;
6377
+ wsum += w;
6378
+ }
6379
+ if (wsum > 0) return sum / wsum;
6380
+ }
5153
6381
  if (isShapedSprite(s)) {
5154
6382
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
5155
6383
  }
5156
6384
  if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
5157
6385
  return 0.75;
5158
6386
  }
6387
+ function spriteInks(layer, e) {
6388
+ const s = layer.sprite;
6389
+ if (s.kind === "polygon" && isPathPolygon(s)) {
6390
+ const base = s.colors?.[e.colorIndex] ?? s.color;
6391
+ const inks = [];
6392
+ for (const part of pathParts(s)) {
6393
+ if (part.opacity <= 0) continue;
6394
+ if (part.fill) {
6395
+ if (part.gradient) for (const st of part.gradient.stops) {
6396
+ if ((st.opacity ?? 1) > 0) inks.push(st.color);
6397
+ }
6398
+ else inks.push(part.color ?? base);
6399
+ }
6400
+ if (part.stroke && part.stroke.width > 0 && (part.stroke.opacity ?? 1) > 0) inks.push(part.stroke.color);
6401
+ }
6402
+ return inks;
6403
+ }
6404
+ const hex = spriteHex(layer, e);
6405
+ return hex === null ? null : [hex];
6406
+ }
5159
6407
  function spriteHex(layer, e) {
5160
6408
  const s = layer.sprite;
5161
6409
  if (isShapedSprite(s)) {
@@ -5271,6 +6519,23 @@ var WASH_ALPHA_MAX = 0.45;
5271
6519
  var COHESION_T = 5e3;
5272
6520
  var OUTLINE_SAMPLES = 24;
5273
6521
  var MAX_TRACED = 240;
6522
+ function discSamples(cx, cy, r, n) {
6523
+ const golden = Math.PI * (3 - Math.sqrt(5));
6524
+ return Array.from({ length: n }, (_, i) => {
6525
+ const rr = r * Math.sqrt((i + 0.5) / n);
6526
+ return { x: cx + rr * Math.cos(i * golden), y: cy + rr * Math.sin(i * golden) };
6527
+ });
6528
+ }
6529
+ function gridSamples(uw, vh, n, map) {
6530
+ if (!(uw > 0) || !(vh > 0)) return [];
6531
+ const nv = Math.min(n, Math.max(8, Math.round(Math.sqrt(n * vh / uw))));
6532
+ const nu = Math.max(8, Math.round(n / nv));
6533
+ const out = [];
6534
+ for (let i = 0; i < nu; i++) {
6535
+ for (let j = 0; j < nv; j++) out.push(map(((i + 0.5) / nu - 0.5) * uw, ((j + 0.5) / nv - 0.5) * vh));
6536
+ }
6537
+ return out;
6538
+ }
5274
6539
  var EDGE_INSET = 1e-4;
5275
6540
  function circleShape(cx, cy, r) {
5276
6541
  return {
@@ -5279,6 +6544,7 @@ function circleShape(cx, cy, r) {
5279
6544
  br: r,
5280
6545
  area: Math.PI * r * r,
5281
6546
  contains: (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r,
6547
+ interior: (n) => discSamples(cx, cy, r, n),
5282
6548
  outline: (n) => Array.from({ length: n }, (_, i) => {
5283
6549
  const a = i / n * Math.PI * 2;
5284
6550
  const inner = r * (1 - EDGE_INSET);
@@ -5294,7 +6560,7 @@ function polyShape(cx, cy, raw) {
5294
6560
  if (abs.length < 3) return null;
5295
6561
  let br = 0;
5296
6562
  for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
5297
- const inside = (x, y) => {
6563
+ const inside2 = (x, y) => {
5298
6564
  let hit = false;
5299
6565
  for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
5300
6566
  const a = abs[i];
@@ -5312,12 +6578,21 @@ function polyShape(cx, cy, raw) {
5312
6578
  const step = Math.min(br * EDGE_INSET, shortest * 0.25);
5313
6579
  const area = polygonArea(abs);
5314
6580
  if (!(area > 0)) return null;
6581
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
6582
+ for (const p of abs) {
6583
+ x0 = Math.min(x0, p.x);
6584
+ y0 = Math.min(y0, p.y);
6585
+ x1 = Math.max(x1, p.x);
6586
+ y1 = Math.max(y1, p.y);
6587
+ }
5315
6588
  return {
5316
6589
  cx,
5317
6590
  cy,
5318
6591
  br,
5319
6592
  area,
5320
- contains: inside,
6593
+ contains: inside2,
6594
+ // Its bounding box's grid, kept to the cells inside the outline.
6595
+ interior: (n) => gridSamples(x1 - x0, y1 - y0, n, (u, v) => ({ x: (x0 + x1) / 2 + u, y: (y0 + y1) / 2 + v })).filter((p) => inside2(p.x, p.y)),
5321
6596
  /**
5322
6597
  * `n` points spread along the perimeter by ARC LENGTH, not one share per
5323
6598
  * edge — a long skinny rectangle has two edges carrying almost all of its
@@ -5346,13 +6621,13 @@ function polyShape(cx, cy, raw) {
5346
6621
  for (let t = step; t > 0; t /= 8) {
5347
6622
  const qx1 = px - uy * t;
5348
6623
  const qy1 = py + ux * t;
5349
- if (inside(qx1, qy1)) {
6624
+ if (inside2(qx1, qy1)) {
5350
6625
  out.push({ x: qx1, y: qy1 });
5351
6626
  break;
5352
6627
  }
5353
6628
  const qx2 = px + uy * t;
5354
6629
  const qy2 = py - ux * t;
5355
- if (inside(qx2, qy2)) {
6630
+ if (inside2(qx2, qy2)) {
5356
6631
  out.push({ x: qx2, y: qy2 });
5357
6632
  break;
5358
6633
  }
@@ -5367,12 +6642,13 @@ function rectShape(cx, cy, halfW, halfH, rot) {
5367
6642
  const cos = Math.cos(rot);
5368
6643
  const sin = Math.sin(rot);
5369
6644
  const corner = (lx, ly) => ({ x: cx + lx * cos - ly * sin, y: cy + lx * sin + ly * cos });
5370
- return polyShape(cx, cy, [
6645
+ const shape = polyShape(cx, cy, [
5371
6646
  corner(-halfW, -halfH),
5372
6647
  corner(halfW, -halfH),
5373
6648
  corner(halfW, halfH),
5374
6649
  corner(-halfW, halfH)
5375
6650
  ]);
6651
+ return shape && { ...shape, interior: (n) => gridSamples(halfW * 2, halfH * 2, n, corner) };
5376
6652
  }
5377
6653
  function polygonShape(cx, cy, pts, rot) {
5378
6654
  const cos = Math.cos(rot);
@@ -5432,8 +6708,7 @@ function cohesionOf(layers, t, w, h) {
5432
6708
  seamCause
5433
6709
  };
5434
6710
  if (lifeAlphaAt(layer.life, t) <= 0) return { ...base, overlap: null, reads: null };
5435
- const shapes = entities.map((e) => shapeOf(layer, e, t, w, h));
5436
- const solid = shapes.filter((x) => x !== null && x.area > 0);
6711
+ const solid = solidShapes(layer, entities, t, w, h);
5437
6712
  if (solid.length < 2) return { ...base, overlap: null, reads: null };
5438
6713
  const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
5439
6714
  let covered = 0;
@@ -5465,16 +6740,58 @@ function cohesionOf(layers, t, w, h) {
5465
6740
  return { ...base, overlap, reads };
5466
6741
  });
5467
6742
  }
5468
- function seamsWorthWarning(layer, c, entityCount) {
6743
+ function solidShapes(layer, entities, t, w, h) {
6744
+ return entities.map((e) => shapeOf(layer, e, t, w, h)).filter((x) => x !== null && x.area > 0);
6745
+ }
6746
+ var AREA_SAMPLES = 128;
6747
+ var TILED_AREA_MAX = 0.15;
6748
+ function buriedAreaOf(layer, entities, t, w, h) {
6749
+ if (lifeAlphaAt(layer.life, t) <= 0) return null;
6750
+ const solid = solidShapes(layer, entities, t, w, h);
6751
+ if (solid.length < 2) return null;
6752
+ const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
6753
+ let covered = 0;
6754
+ let traced = 0;
6755
+ for (let i = 0; i < solid.length; i += stride) {
6756
+ const pts = solid[i].interior(AREA_SAMPLES);
6757
+ if (pts.length === 0) continue;
6758
+ let hit = 0;
6759
+ for (const p of pts) {
6760
+ for (let j = 0; j < solid.length; j++) {
6761
+ if (j === i) continue;
6762
+ const other = solid[j];
6763
+ const dx = p.x - other.cx;
6764
+ const dy = p.y - other.cy;
6765
+ if (dx * dx + dy * dy > other.br * other.br) continue;
6766
+ if (other.contains(p.x, p.y)) {
6767
+ hit++;
6768
+ break;
6769
+ }
6770
+ }
6771
+ }
6772
+ covered += hit / pts.length;
6773
+ traced++;
6774
+ }
6775
+ return traced === 0 ? null : Number((covered / traced).toFixed(4));
6776
+ }
6777
+ function seamsWorthWarning(layer, c, entityCount, buriedArea) {
5469
6778
  if (c.reads !== "seamed" || entityCount < 6) return false;
5470
6779
  const [lo, hi] = layer.alpha ?? [1, 1];
5471
6780
  if ((lo + hi) / 2 < WASH_ALPHA_MAX) return false;
5472
- return layer.blend !== "lighter" && layer.blend !== "screen";
6781
+ if (layer.blend === "lighter" || layer.blend === "screen") return false;
6782
+ if (buriedArea && paletteIsTheOnlySeam(layer)) {
6783
+ const a = buriedArea();
6784
+ if (a !== null && a < TILED_AREA_MAX) return false;
6785
+ }
6786
+ return true;
6787
+ }
6788
+ function paletteIsTheOnlySeam(layer) {
6789
+ if (seamCauseOf(layer) === null) return false;
6790
+ return seamCauseOf({ ...layer, sprite: { ...layer.sprite, colors: void 0 } }) === null;
5473
6791
  }
5474
6792
 
5475
6793
  // src/perceive.ts
5476
6794
  import { createRng as createRng2 } from "@idle-screens/core";
5477
- var GLOW_SPREAD = 2.4;
5478
6795
  var LINE_SALIENCE = 3.5;
5479
6796
  var GHOST_TAPS = 12;
5480
6797
  var POLYGON_OUTLINE_CELLS = 3;
@@ -5509,11 +6826,18 @@ function posOf(scene, e, t) {
5509
6826
  const map = scene.transforms?.get(e);
5510
6827
  return map ? map(p) : p;
5511
6828
  }
5512
- function textBox(s, e, p) {
6829
+ function fontScaleFor(spec, w, h) {
6830
+ return spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
6831
+ }
6832
+ function textFontPx(s, size, fontScale) {
5513
6833
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
5514
- const fh = m ? Number(m[1]) : e.size;
6834
+ return m ? Number(m[1]) * fontScale : size;
6835
+ }
6836
+ function textBox(s, e, p, fontScale, unitScale) {
6837
+ const fh = textFontPx(s, e.size, fontScale);
5515
6838
  const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
5516
- const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
6839
+ const est = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
6840
+ const fw = s.maxWidth ? Math.min(est, s.maxWidth * unitScale) : est;
5517
6841
  const align = s.align ?? "center";
5518
6842
  const baseline = s.baseline ?? "middle";
5519
6843
  return {
@@ -5559,7 +6883,24 @@ function textBlockRevealFraction(s, w, h, t) {
5559
6883
  }
5560
6884
  return rs.progress;
5561
6885
  }
5562
- function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
6886
+ function gradientAt(gr, u, v) {
6887
+ let t;
6888
+ if (gr.type === "linear") {
6889
+ const dx = gr.to[0] - gr.from[0], dy = gr.to[1] - gr.from[1];
6890
+ const len2 = dx * dx + dy * dy || 1;
6891
+ t = ((u - gr.from[0]) * dx + (v - gr.from[1]) * dy) / len2;
6892
+ } else {
6893
+ t = Math.hypot(u - gr.center[0], v - gr.center[1]) / gr.radius;
6894
+ }
6895
+ const st = gr.stops;
6896
+ t = Math.max(st[0].at, Math.min(st[st.length - 1].at, t));
6897
+ let k = 0;
6898
+ while (k < st.length - 2 && t > st[k + 1].at) k++;
6899
+ const A = st[k], B = st[Math.min(k + 1, st.length - 1)];
6900
+ const w = B.at > A.at ? (t - A.at) / (B.at - A.at) : 0;
6901
+ return { luma: hexLuma(A.color) * (1 - w) + hexLuma(B.color) * w, opacity: (A.opacity ?? 1) * (1 - w) + (B.opacity ?? 1) * w };
6902
+ }
6903
+ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1, evenodd = false) {
5563
6904
  const nc = c1 - c0 + 1;
5564
6905
  const cov = new Float64Array(nc * (r1 - r0 + 1));
5565
6906
  const xMin = c0 * cellW;
@@ -5592,30 +6933,52 @@ function polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1) {
5592
6933
  hits.sort((m, n) => m.x - n.x);
5593
6934
  let wind = 0;
5594
6935
  let start = 0;
6936
+ const filled = (w) => evenodd ? (w & 1) !== 0 : w !== 0;
5595
6937
  for (const hit of hits) {
5596
- const before = wind;
6938
+ const before = filled(wind);
5597
6939
  wind += hit.dir;
5598
- if (before === 0 && wind !== 0) start = hit.x;
5599
- else if (before !== 0 && wind === 0) addSpan(r - r0, start, hit.x);
6940
+ const after = filled(wind);
6941
+ if (!before && after) start = hit.x;
6942
+ else if (before && !after) addSpan(r - r0, start, hit.x);
5600
6943
  }
5601
6944
  }
5602
6945
  }
5603
6946
  return cov;
5604
6947
  }
5605
- function distanceToOutline(pts, x, y) {
5606
- let best = Infinity;
5607
- for (let i = 0; i < pts.length; i++) {
5608
- const p = pts[i];
5609
- const q = pts[(i + 1) % pts.length];
5610
- const ex = q.x - p.x;
5611
- const ey = q.y - p.y;
5612
- const len2 = ex * ex + ey * ey;
5613
- const u = len2 > 0 ? Math.max(0, Math.min(1, ((x - p.x) * ex + (y - p.y) * ey) / len2)) : 0;
5614
- const dx = x - (p.x + ex * u);
5615
- const dy = y - (p.y + ey * u);
5616
- best = Math.min(best, dx * dx + dy * dy);
5617
- }
5618
- return Math.sqrt(best);
6948
+ function softProfile(u) {
6949
+ if (u <= 0) return 1;
6950
+ if (u >= 1) return 0;
6951
+ return u <= 0.35 ? 1 - 0.25 * u / 0.35 : 0.75 * (1 - (u - 0.35) / 0.65);
6952
+ }
6953
+ var SOFT_DISC_MEAN = 0.3783;
6954
+ function discCellInk(cx, cy, rOuter, rInner, soft, x0, y0, cw, ch) {
6955
+ const nx = Math.max(x0, Math.min(cx, x0 + cw)) - cx;
6956
+ const ny = Math.max(y0, Math.min(cy, y0 + ch)) - cy;
6957
+ const near = Math.hypot(nx, ny);
6958
+ const fx = Math.max(Math.abs(x0 - cx), Math.abs(x0 + cw - cx));
6959
+ const fy = Math.max(Math.abs(y0 - cy), Math.abs(y0 + ch - cy));
6960
+ const far = Math.hypot(fx, fy);
6961
+ if (near >= rOuter || far <= rInner) return 0;
6962
+ if (far <= rOuter && near >= rInner) return soft ? softProfile(Math.hypot(x0 + cw / 2 - cx, y0 + ch / 2 - cy) / rOuter) : 1;
6963
+ if (cx - rOuter >= x0 && cx + rOuter <= x0 + cw && cy - rOuter >= y0 && cy + rOuter <= y0 + ch) {
6964
+ return Math.PI * (soft ? rOuter * rOuter * SOFT_DISC_MEAN : rOuter * rOuter - rInner * rInner) / (cw * ch);
6965
+ }
6966
+ const bx0 = Math.max(x0, cx - rOuter);
6967
+ const by0 = Math.max(y0, cy - rOuter);
6968
+ const sx = Math.min(x0 + cw, cx + rOuter) - bx0;
6969
+ const sy = Math.min(y0 + ch, cy + rOuter) - by0;
6970
+ if (sx <= 0 || sy <= 0) return 0;
6971
+ const step = Math.max(rOuter / 4, 1e-6);
6972
+ const ni = Math.max(4, Math.min(16, Math.ceil(sx / step)));
6973
+ const nj = Math.max(4, Math.min(16, Math.ceil(sy / step)));
6974
+ let ink = 0;
6975
+ for (let j = 0; j < nj; j++) {
6976
+ for (let i = 0; i < ni; i++) {
6977
+ const d = Math.hypot(bx0 + (i + 0.5) * sx / ni - cx, by0 + (j + 0.5) * sy / nj - cy);
6978
+ if (d <= rOuter && d >= rInner) ink += soft ? softProfile(d / rOuter) : 1;
6979
+ }
6980
+ }
6981
+ return ink / (ni * nj) * sx * sy / (cw * ch);
5619
6982
  }
5620
6983
  function luminanceGrid(spec, opts = {}) {
5621
6984
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
@@ -5712,11 +7075,14 @@ function luminanceGrid(spec, opts = {}) {
5712
7075
  const r1 = Math.min(rows - 1, Math.floor((pos.y + rad) / cellH));
5713
7076
  for (let r = r0; r <= r1; r++) {
5714
7077
  for (let c = c0; c <= c1; c++) {
5715
- const dx = (c + 0.5) * cellW - pos.x;
5716
- const dy = (r + 0.5) * cellH - pos.y;
5717
- const d = Math.sqrt(dx * dx + dy * dy);
5718
- if (d > rad + Math.min(cellW, cellH) / 2) continue;
5719
- const wgt = soft ? Math.max(0.1, 1 - d / Math.max(rad, 1e-6)) : 1;
7078
+ let wgt = 1;
7079
+ if (soft) {
7080
+ wgt = discCellInk(pos.x, pos.y, rad, 0, true, c * cellW, r * cellH, cellW, cellH);
7081
+ if (wgt <= 0) continue;
7082
+ } else {
7083
+ const d = Math.hypot((c + 0.5) * cellW - pos.x, (r + 0.5) * cellH - pos.y);
7084
+ if (d > rad + Math.min(cellW, cellH) / 2) continue;
7085
+ }
5720
7086
  compose(r * cols + c, lum, Math.min(1, a * wgt), layer.blend);
5721
7087
  }
5722
7088
  }
@@ -5724,20 +7090,15 @@ function luminanceGrid(spec, opts = {}) {
5724
7090
  };
5725
7091
  const splatPolygonOutline = (layer, s, e, p, sz, a, lum, tPass) => {
5726
7092
  const radius = sz / 2;
5727
- const rot = rotationAt(e, tPass);
7093
+ const rot = rotationAt(e, tPass) + paintRotation(spec, layer);
5728
7094
  const cr = Math.cos(rot);
5729
7095
  const sr = Math.sin(rot);
5730
- const pts = polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5731
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5732
- const glow = !!s.soft && additive;
5733
- let band = 0;
5734
- if (glow) {
5735
- const perimeter = pts.reduce((acc, q, i) => {
5736
- const n = pts[(i + 1) % pts.length];
5737
- return acc + Math.hypot(n.x - q.x, n.y - q.y);
5738
- }, 0);
5739
- band = perimeter > 0 ? (GLOW_SPREAD - 1) * (2 * polygonArea(pts) / perimeter) : 0;
5740
- }
7096
+ const fr = isPathPolygon(s) ? pathFrame(s, radius, w, h) : { sx: radius, sy: radius, ox: 0, oy: 0, uniform: true };
7097
+ const mapUnit = (u, v) => {
7098
+ const qx = (u - fr.ox) * fr.sx, qy = (v - fr.oy) * fr.sy;
7099
+ return { x: p.x + qx * cr - qy * sr, y: p.y + qx * sr + qy * cr };
7100
+ };
7101
+ const pts = isPathPolygon(s) ? flattenSprite(s).points.map(([u, v]) => mapUnit(u, v)) : polygonPoints(s, radius).map((q) => ({ x: p.x + q.x * cr - q.y * sr, y: p.y + q.x * sr + q.y * cr }));
5741
7102
  let minX = Infinity;
5742
7103
  let maxX = -Infinity;
5743
7104
  let minY = Infinity;
@@ -5748,34 +7109,97 @@ function luminanceGrid(spec, opts = {}) {
5748
7109
  minY = Math.min(minY, q.y);
5749
7110
  maxY = Math.max(maxY, q.y);
5750
7111
  }
5751
- const c0 = Math.max(0, Math.floor((minX - band) / cellW));
5752
- const c1 = Math.min(cols - 1, Math.floor((maxX + band) / cellW));
5753
- const r0 = Math.max(0, Math.floor((minY - band) / cellH));
5754
- const r1 = Math.min(rows - 1, Math.floor((maxY + band) / cellH));
7112
+ const strokeQuadsPx = (part) => {
7113
+ if (!part.stroke) return [];
7114
+ const hw = part.stroke.width * radius / 2;
7115
+ const out = [];
7116
+ for (const l of part.flat.lines) {
7117
+ const m = l.points.map(([u, v]) => mapUnit(u, v));
7118
+ const n = l.closed ? m.length : m.length - 1;
7119
+ for (let i = 0; i < n; i++) {
7120
+ const A = m[i], B = m[(i + 1) % m.length];
7121
+ const dx = B.x - A.x, dy = B.y - A.y, len = Math.hypot(dx, dy) || 1;
7122
+ const nx = -dy / len * hw, ny = dx / len * hw;
7123
+ 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 }]);
7124
+ }
7125
+ }
7126
+ return out;
7127
+ };
7128
+ if (isPathPolygon(s)) {
7129
+ for (const part of pathParts(s)) for (const quad of strokeQuadsPx(part)) for (const q of quad) {
7130
+ minX = Math.min(minX, q.x);
7131
+ maxX = Math.max(maxX, q.x);
7132
+ minY = Math.min(minY, q.y);
7133
+ maxY = Math.max(maxY, q.y);
7134
+ }
7135
+ }
7136
+ const c0 = Math.max(0, Math.floor(minX / cellW));
7137
+ const c1 = Math.min(cols - 1, Math.floor(maxX / cellW));
7138
+ const r0 = Math.max(0, Math.floor(minY / cellH));
7139
+ const r1 = Math.min(rows - 1, Math.floor(maxY / cellH));
5755
7140
  if (c0 > c1 || r0 > r1) return;
5756
- const cov = polygonCellCoverage(pts, cellW, cellH, c0, c1, r0, r1);
5757
7141
  const nc = c1 - c0 + 1;
5758
- for (let r = r0; r <= r1; r++) {
5759
- for (let c = c0; c <= c1; c++) {
5760
- const f = cov[(r - r0) * nc + (c - c0)];
5761
- const x = (c + 0.5) * cellW;
5762
- const y = (r + 0.5) * cellH;
5763
- let wgt;
5764
- if (glow) {
5765
- const outX = Math.max(0, minX - x, x - maxX);
5766
- const outY = Math.max(0, minY - y, y - maxY);
5767
- if (f >= 1) wgt = 1;
5768
- else if (outX * outX + outY * outY >= band * band) wgt = f;
5769
- else {
5770
- const k = band > 0 ? Math.max(0, 1 - distanceToOutline(pts, x, y) / band) : 0;
5771
- wgt = f + (1 - f) * k * k;
7142
+ const unitAt = (x, y) => {
7143
+ const dx = x - p.x, dy = y - p.y;
7144
+ 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];
7145
+ };
7146
+ const parts = isPathPolygon(s) ? pathParts(s) : null;
7147
+ const fillPass = (ring, rule, partLum, partA, part) => {
7148
+ const cov = polygonCellCoverage(ring, cellW, cellH, c0, c1, r0, r1, rule);
7149
+ for (let r = r0; r <= r1; r++) {
7150
+ for (let c = c0; c <= c1; c++) {
7151
+ const f = cov[(r - r0) * nc + (c - c0)];
7152
+ if (!(f > 0)) continue;
7153
+ const x = (c + 0.5) * cellW;
7154
+ const y = (r + 0.5) * cellH;
7155
+ let cl = partLum, ca = 1;
7156
+ if (part?.gradient) {
7157
+ const [u, v] = unitAt(x, y);
7158
+ const [gu, gv] = gradientSpace(part, u, v);
7159
+ const g = gradientAt(part.gradient, gu, gv);
7160
+ cl = g.luma;
7161
+ ca = g.opacity;
7162
+ }
7163
+ const wgt = s.soft && !parts ? f * softProfile(Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6)) : f;
7164
+ if (wgt > 0) compose(r * cols + c, cl, a * partA * ca * Math.min(1, wgt), layer.blend);
7165
+ }
7166
+ }
7167
+ };
7168
+ if (!parts) {
7169
+ fillPass(pts, polygonFillRule(s) === "evenodd", lum, 1, null);
7170
+ return;
7171
+ }
7172
+ for (const part of parts) {
7173
+ const partLum = part.color ? hexLuma(part.color) : lum;
7174
+ if (part.fill && part.flat.points.length >= 3) {
7175
+ fillPass(part.flat.points.map(([u, v]) => mapUnit(u, v)), part.rule === "evenodd", partLum, part.opacity, part);
7176
+ }
7177
+ if (part.stroke) {
7178
+ const sa = (part.stroke.opacity ?? 1) * part.opacity;
7179
+ const sl = hexLuma(part.stroke.color);
7180
+ const scov = new Float64Array(nc * (r1 - r0 + 1));
7181
+ for (const qp of strokeQuadsPx(part)) {
7182
+ let qx0 = Infinity, qx1 = -Infinity, qy0 = Infinity, qy1 = -Infinity;
7183
+ for (const q of qp) {
7184
+ qx0 = Math.min(qx0, q.x);
7185
+ qx1 = Math.max(qx1, q.x);
7186
+ qy0 = Math.min(qy0, q.y);
7187
+ qy1 = Math.max(qy1, q.y);
7188
+ }
7189
+ const a0 = Math.max(c0, Math.floor(qx0 / cellW)), a1 = Math.min(c1, Math.floor(qx1 / cellW));
7190
+ const b0 = Math.max(r0, Math.floor(qy0 / cellH)), b1 = Math.min(r1, Math.floor(qy1 / cellH));
7191
+ if (a0 > a1 || b0 > b1) continue;
7192
+ const qc = polygonCellCoverage(qp, cellW, cellH, a0, a1, b0, b1);
7193
+ const qn = a1 - a0 + 1;
7194
+ for (let r = b0; r <= b1; r++) for (let c = a0; c <= a1; c++) {
7195
+ const i = (r - r0) * nc + (c - c0);
7196
+ scov[i] = Math.min(1, scov[i] + qc[(r - b0) * qn + (c - a0)]);
5772
7197
  }
5773
- } else if (s.soft) {
5774
- wgt = f * Math.max(0.1, 1 - Math.hypot(x - p.x, y - p.y) / Math.max(radius, 1e-6));
5775
- } else {
5776
- wgt = f;
5777
7198
  }
5778
- if (wgt > 0) compose(r * cols + c, lum, a * Math.min(1, wgt), layer.blend);
7199
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
7200
+ const f = scov[(r - r0) * nc + (c - c0)];
7201
+ if (f > 0) compose(r * cols + c, sl, a * sa * f, layer.blend);
7202
+ }
5779
7203
  }
5780
7204
  }
5781
7205
  };
@@ -5791,13 +7215,13 @@ function luminanceGrid(spec, opts = {}) {
5791
7215
  const p = posOf(scene, e, tPass);
5792
7216
  const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5793
7217
  const s = layer.sprite;
5794
- if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
7218
+ if (s.kind === "polygon" && (sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH) || isPathPolygon(s) && ((s.span ?? "box") !== "box" || s.origin !== void 0))) {
5795
7219
  splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
5796
7220
  continue;
5797
7221
  }
5798
7222
  if (s.kind === "stroke") {
5799
7223
  const pts = strokeSamples(s, sz / 2);
5800
- let angle = rotationAt(e, tPass);
7224
+ let angle = rotationAt(e, tPass) + paintRotation(spec, layer);
5801
7225
  if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
5802
7226
  const ca = Math.cos(angle);
5803
7227
  const sa = Math.sin(angle);
@@ -5826,7 +7250,7 @@ function luminanceGrid(spec, opts = {}) {
5826
7250
  continue;
5827
7251
  }
5828
7252
  if (s.kind === "streak") {
5829
- const heading = (headingAt(e, tPass, w, h) ?? 0) + rotationAt(e, tPass);
7253
+ const heading = (headingAt(e, tPass, w, h) ?? 0) + (rotationAt(e, tPass) + paintRotation(spec, layer));
5830
7254
  const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
5831
7255
  const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
5832
7256
  for (let i = 0; i <= steps; i++) {
@@ -5845,7 +7269,7 @@ function luminanceGrid(spec, opts = {}) {
5845
7269
  let halfX = sz / 2;
5846
7270
  let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
5847
7271
  if (s.kind === "text") {
5848
- const box = textBox(s, e, p);
7272
+ const box = textBox(s, e, p, fontScaleFor(spec, w, h), scale);
5849
7273
  centerX = box.cx;
5850
7274
  centerY = box.cy;
5851
7275
  halfX = box.halfX;
@@ -5861,7 +7285,7 @@ function luminanceGrid(spec, opts = {}) {
5861
7285
  if (len <= 0) continue;
5862
7286
  const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
5863
7287
  const box = barBox(s.direction ?? "right", len, th);
5864
- const rot = rotationAt(e, tPass);
7288
+ const rot = rotationAt(e, tPass) + paintRotation(spec, layer);
5865
7289
  const cr = Math.cos(rot);
5866
7290
  const sr = Math.sin(rot);
5867
7291
  centerX = p.x + box.cx * cr - box.cy * sr;
@@ -5872,45 +7296,24 @@ function luminanceGrid(spec, opts = {}) {
5872
7296
  const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5873
7297
  const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
5874
7298
  const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
5875
- const additive = layer.blend === "lighter" || layer.blend === "screen";
5876
- const glow = soft && additive;
5877
- const reachX = glow ? halfX * GLOW_SPREAD : halfX;
5878
- const reachY = glow ? halfY * GLOW_SPREAD : halfY;
5879
- const c0 = Math.max(0, Math.floor((centerX - reachX) / cellW));
5880
- const c1 = Math.min(cols - 1, Math.floor((centerX + reachX) / cellW));
5881
- const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5882
- const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
7299
+ const c0 = Math.max(0, Math.floor((centerX - halfX) / cellW));
7300
+ const c1 = Math.min(cols - 1, Math.floor((centerX + halfX) / cellW));
7301
+ const r0 = Math.max(0, Math.floor((centerY - halfY) / cellH));
7302
+ const r1 = Math.min(rows - 1, Math.floor((centerY + halfY) / cellH));
5883
7303
  const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
5884
- const byArea = s.kind === "rect" || s.kind === "bar";
7304
+ const byArea = s.kind === "rect" || s.kind === "bar" || s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock";
5885
7305
  for (let r = r0; r <= r1; r++) {
5886
7306
  const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
5887
7307
  if (fy <= 0) continue;
5888
7308
  for (let c = c0; c <= c1; c++) {
5889
- const dx = (c + 0.5) * cellW - centerX;
5890
- const dy = (r + 0.5) * cellH - centerY;
5891
7309
  if (circular) {
5892
- const d = Math.sqrt(dx * dx + dy * dy);
5893
- const reach = glow ? reachX : halfX;
5894
- if (d > reach + Math.min(cellW, cellH) / 2) continue;
5895
- if (s.kind === "ring") {
5896
- const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
5897
- if (d < halfX - strokeW) continue;
5898
- }
5899
- let wgt;
5900
- if (glow) {
5901
- const core = halfX;
5902
- if (d <= core) wgt = 1;
5903
- else {
5904
- const f = Math.max(0, 1 - (d - core) / Math.max(reachX - core, 1e-6));
5905
- wgt = f * f;
5906
- }
5907
- } else {
5908
- wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5909
- }
7310
+ const inner = s.kind === "ring" ? Math.max(0, halfX - (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) : 0;
7311
+ const wgt = discCellInk(centerX, centerY, halfX, inner, soft, c * cellW, r * cellH, cellW, cellH);
7312
+ if (wgt <= 0) continue;
5910
7313
  compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
5911
7314
  } else if (byArea) {
5912
7315
  const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
5913
- if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
7316
+ if (fx > 0) compose(r * cols + c, lum, a * inkWeight * shapeWeight * fx * fy, layer.blend);
5914
7317
  } else {
5915
7318
  compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5916
7319
  }
@@ -5919,9 +7322,8 @@ function luminanceGrid(spec, opts = {}) {
5919
7322
  }
5920
7323
  if (layer.links) {
5921
7324
  const positions = entities.map((e) => posOf(scene, e, tPass));
5922
- const maxDistPx = layer.links.maxDist * scale;
5923
7325
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
5924
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
7326
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false && motionWraps, w, h);
5925
7327
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
5926
7328
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
5927
7329
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
@@ -6062,8 +7464,7 @@ function textSprites(spec, opts = {}) {
6062
7464
  }
6063
7465
  if (layer.sprite.kind !== "text") return;
6064
7466
  const s = layer.sprite;
6065
- const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
6066
- const sizePx = m ? Number(m[1]) : entities[0] ? sizeAt(entities[0], t) : 0;
7467
+ const sizePx = textFontPx(s, entities[0] ? sizeAt(entities[0], t) : 0, fontScaleFor(spec, scene.w, scene.h));
6067
7468
  out.push({ layerIndex, key: layer.key, strings: [...s.strings], count: entities.length, sizePx: Math.round(sizePx) });
6068
7469
  });
6069
7470
  return out;
@@ -6083,6 +7484,42 @@ function rankDominance(raw) {
6083
7484
  factors: r.factors
6084
7485
  }));
6085
7486
  }
7487
+ function onScreenFraction(scene, s, e, sz, k, t, paintRot = 0) {
7488
+ if (s.kind === "text" || s.kind === "textBlock") return 1;
7489
+ const { w, h } = scene;
7490
+ const p = posOf(scene, e, t);
7491
+ let cx = p.x, cy = p.y, hx = sz / 2 * k, hy = hx;
7492
+ if (s.kind === "bar") {
7493
+ const len = sz * barFraction(s, e.barIndex ?? 0);
7494
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
7495
+ const b = barBox(s.direction ?? "right", len * k, th * k);
7496
+ if (!(b.halfX > 0) || !(b.halfY > 0)) return 1;
7497
+ const rot = rotationAt(e, t);
7498
+ const cr = Math.cos(rot), sr = Math.sin(rot);
7499
+ cx = p.x + b.cx * cr - b.cy * sr;
7500
+ cy = p.y + b.cx * sr + b.cy * cr;
7501
+ const ex = Math.abs(b.halfX * cr) + Math.abs(b.halfY * sr);
7502
+ const ey = Math.abs(b.halfX * sr) + Math.abs(b.halfY * cr);
7503
+ const fx2 = Math.max(0, Math.min(cx + ex, w) - Math.max(cx - ex, 0)) / (2 * ex);
7504
+ const fy2 = Math.max(0, Math.min(cy + ey, h) - Math.max(cy - ey, 0)) / (2 * ey);
7505
+ return fx2 * fy2;
7506
+ }
7507
+ if (s.kind === "polygon" && isPathPolygon(s)) {
7508
+ const fr = pathFrame(s, sz / 2, w, h);
7509
+ const rot = rotationAt(e, t) + paintRot;
7510
+ const cr = Math.cos(rot), sr = Math.sin(rot);
7511
+ const ox = -fr.ox * fr.sx * k, oy = -fr.oy * fr.sy * k;
7512
+ cx += ox * cr - oy * sr;
7513
+ cy += ox * sr + oy * cr;
7514
+ const qx = fr.sx * k, qy = fr.sy * k;
7515
+ hx = Math.abs(qx * cr) + Math.abs(qy * sr);
7516
+ hy = Math.abs(qx * sr) + Math.abs(qy * cr);
7517
+ }
7518
+ if (!(hx > 0) || !(hy > 0)) return 1;
7519
+ const fx = Math.max(0, Math.min(cx + hx, w) - Math.max(cx - hx, 0)) / (2 * hx);
7520
+ const fy = Math.max(0, Math.min(cy + hy, h) - Math.max(cy - hy, 0)) / (2 * hy);
7521
+ return fx * fy;
7522
+ }
6086
7523
  function rawDominance(spec, opts = {}) {
6087
7524
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
6088
7525
  const scene = buildScene(spec, opts);
@@ -6126,20 +7563,25 @@ function rawDominance(spec, opts = {}) {
6126
7563
  const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
6127
7564
  const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
6128
7565
  entArea = sz * h2 * f;
6129
- } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
6130
- else if (s.kind === "bar") {
7566
+ } else if (s.kind === "polygon") {
7567
+ if (isPathPolygon(s)) {
7568
+ const fr = pathFrame(s, sz / 2, w, h);
7569
+ entArea = pathInkArea(s, fr.sx, fr.sy, sz / 2);
7570
+ } else entArea = polygonArea(polygonPoints(s, sz / 2));
7571
+ } else if (s.kind === "bar") {
6131
7572
  const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
6132
7573
  entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
6133
7574
  } else if (s.kind === "stroke") {
6134
7575
  const meanTaper = s.taper ? 0.64 : 1;
6135
7576
  entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
6136
7577
  } else if (s.kind === "text") {
6137
- const box = textBox(s, e, { x: 0, y: 0 });
7578
+ const box = textBox(s, e, { x: 0, y: 0 }, fontScaleFor(spec, w, h), scale);
6138
7579
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
6139
7580
  } else if (s.kind === "textBlock") {
6140
7581
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
6141
7582
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
6142
7583
  } else entArea = sz * sz * 0.55;
7584
+ entArea *= onScreenFraction(scene, s, e, sz, painted ? paintSize(spec, layer) : 1, t, painted ? paintRotation(spec, layer) : 0);
6143
7585
  area += painted ? entArea * areaScale * a : entArea * a;
6144
7586
  if (layer.trail) {
6145
7587
  const fade = layer.trail.fade ?? 1;
@@ -6154,7 +7596,7 @@ function rawDominance(spec, opts = {}) {
6154
7596
  if (layer.links && entities.length > 1) {
6155
7597
  const positions = entities.map((e) => posOf(scene, e, t));
6156
7598
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
6157
- const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
7599
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false && motionWraps, w, h);
6158
7600
  const la = (layer.links.alpha ?? 0.6) * lifeA;
6159
7601
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6160
7602
  const lw = (layer.links.width ?? defaultWidth) * scale;
@@ -6184,6 +7626,7 @@ function motionStats(spec, opts = {}) {
6184
7626
  const unit = Math.min(w, h);
6185
7627
  return scene0.layers.map(({ layer, entities }, layerIndex) => {
6186
7628
  const entities1 = scene1.layers[layerIndex]?.entities ?? entities;
7629
+ const spinVisible = layer.sprite.kind !== "circle" && layer.sprite.kind !== "ring";
6187
7630
  let acc = 0;
6188
7631
  let max = 0;
6189
7632
  let n = 0;
@@ -6197,7 +7640,8 @@ function motionStats(spec, opts = {}) {
6197
7640
  const dy = p1.y - p0.y;
6198
7641
  if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return;
6199
7642
  const dist = Math.sqrt(dx * dx + dy * dy);
6200
- const speed = dist / dt * 1e3;
7643
+ const turn = spinVisible ? Math.abs(rotationAt(entities1[i] ?? e, t + dt) - rotationAt(e, t)) * (sizeAt(e, t) / 3) * paintSize(spec, layer) : 0;
7644
+ const speed = Math.sqrt(dist * dist + turn * turn) / dt * 1e3;
6201
7645
  acc += speed;
6202
7646
  max = Math.max(max, speed);
6203
7647
  n++;
@@ -6254,17 +7698,36 @@ function sceneColour(spec, opts = {}) {
6254
7698
  let wsum = 0;
6255
7699
  for (const { layer, entities } of scene.layers) {
6256
7700
  const lifeA = lifeAlphaAt(layer.life, t) * (layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer));
6257
- for (const e of entities) {
6258
- const hex = spriteHex(layer, e);
6259
- if (hex === null) continue;
6260
- const sz = sizeAt(e, t);
6261
- const weight = alphaAt(e, t) * lifeA * (layer.sprite.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz);
6262
- if (weight <= 0) continue;
7701
+ const k = layer.transform !== void 0 || layer.group !== void 0 ? paintSize(spec, layer) : 1;
7702
+ const add = (hex, weight) => {
7703
+ if (weight <= 0) return;
6263
7704
  const c = hexRgb(hex);
6264
7705
  wr += c.r * weight;
6265
7706
  wg += c.g * weight;
6266
7707
  wb += c.b * weight;
6267
7708
  wsum += weight;
7709
+ };
7710
+ for (const e of entities) {
7711
+ const sz = sizeAt(e, t);
7712
+ const s = layer.sprite;
7713
+ const a = alphaAt(e, t) * lifeA * onScreenFraction(scene, s, e, sz, k, t, paintRotation(spec, layer));
7714
+ if (a <= 0) continue;
7715
+ if (s.kind === "polygon" && isPathPolygon(s)) {
7716
+ const fr = pathFrame(s, sz / 2, scene.w, scene.h);
7717
+ const base = s.colors?.[e.colorIndex] ?? s.color;
7718
+ for (const part of pathParts(s)) {
7719
+ const ink = partInkArea(part, fr.sx, fr.sy, sz / 2);
7720
+ if (part.gradient) {
7721
+ const gw = gradientStopWeights(part.gradient.stops);
7722
+ part.gradient.stops.forEach((st, i) => add(st.color, a * ink.fill * (st.opacity ?? 1) * gw[i]));
7723
+ } else add(part.color ?? base, a * ink.fill);
7724
+ if (part.stroke) add(part.stroke.color, a * ink.stroke);
7725
+ }
7726
+ continue;
7727
+ }
7728
+ const hex = spriteHex(layer, e);
7729
+ if (hex === null) continue;
7730
+ add(hex, a * (s.kind === "circle" ? Math.PI * (sz / 2) * (sz / 2) : sz * sz));
6268
7731
  }
6269
7732
  }
6270
7733
  const bg = backgroundRgb(spec);
@@ -6457,12 +7920,92 @@ function perceiveSequenceFrame(seq, T, opts = {}) {
6457
7920
  };
6458
7921
  }
6459
7922
 
7923
+ // src/flash-timeline.ts
7924
+ import { analyzeFlashes, srgbToLinear as srgbToLinear2 } from "@idle-screens/validator";
7925
+ var LUMA_PATH = /(^|\.)(opacity|alpha|color|colors|stops|bands|gradient|stroke|fill|background)(\.|$)|(^|\.)(sprite|parts?)$|^layers\.\d+$/;
7926
+ var MIN_BOUNDARIES = 7;
7927
+ var MAX_SAMPLES = 64;
7928
+ var MAX_TOTAL_SAMPLES = 512;
7929
+ var WINDOW = 1e3;
7930
+ function lumaKeyTimes(spec) {
7931
+ const tl = spec.timeline;
7932
+ if (!tl) return { times: [], lap: 0 };
7933
+ const lap = tl.loop && tl.duration ? tl.duration : 0;
7934
+ const bounds = [];
7935
+ for (const [path, keys] of timelineTracks(spec)) {
7936
+ if (!LUMA_PATH.test(path)) continue;
7937
+ for (const k of keys) {
7938
+ const dur = k.dur ?? DEFAULT_KEY_DUR;
7939
+ bounds.push(k.t);
7940
+ if (dur > 0) bounds.push(k.t + dur);
7941
+ }
7942
+ }
7943
+ return { times: [...new Set(lap ? [...bounds, ...bounds.map((b) => b + lap)] : bounds)].sort((a, b) => a - b), lap };
7944
+ }
7945
+ function flashSampleTimes(boundaries) {
7946
+ const ts = /* @__PURE__ */ new Set();
7947
+ boundaries.forEach((t, i) => {
7948
+ ts.add(t - 1);
7949
+ ts.add(t + 1);
7950
+ const next = boundaries[i + 1];
7951
+ if (next !== void 0 && next - t > 4) ts.add((t + next) / 2);
7952
+ });
7953
+ return [...ts].sort((a, b) => a - b);
7954
+ }
7955
+ function timelineFlashReport(spec) {
7956
+ const pass = { fails: false, flashesPerSecond: 0, flashingArea: 0, at: null };
7957
+ if (!spec.timeline) return pass;
7958
+ const { times, lap } = lumaKeyTimes(spec);
7959
+ const dense = [];
7960
+ for (let i = 0, e = 0; i < times.length; i++) {
7961
+ while (e < times.length && times[e] < times[i] + WINDOW) e++;
7962
+ if (e - i < MIN_BOUNDARIES) continue;
7963
+ const last = dense[dense.length - 1];
7964
+ if (last && times[i] <= last.to) {
7965
+ last.to = Math.max(last.to, times[e - 1]);
7966
+ last.n = Math.max(last.n, e - i);
7967
+ } else dense.push({ from: times[i], to: times[e - 1], n: e - i });
7968
+ }
7969
+ if (dense.length === 0) return pass;
7970
+ dense.sort((a, b) => b.n - a.n);
7971
+ let budget = MAX_TOTAL_SAMPLES;
7972
+ let worst = pass;
7973
+ for (const w of dense) {
7974
+ const sampleTimes = flashSampleTimes(times.filter((t) => t >= w.from && t <= w.to));
7975
+ for (let from = 0; from < sampleTimes.length - 1; ) {
7976
+ const chunk = sampleTimes.slice(from, from + MAX_SAMPLES);
7977
+ if (chunk.length < 2) break;
7978
+ if (chunk.length > budget) return { ...worst, fails: true, at: lap ? w.from % lap : w.from, incomplete: true };
7979
+ budget -= chunk.length;
7980
+ const samples = chunk.map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: 16, rows: 9 }).cells.flat().map((v) => srgbToLinear2(v * 255)) }));
7981
+ const r = analyzeFlashes(samples);
7982
+ if (r.general.worstTileFlashesPerSecond > worst.flashesPerSecond || !worst.fails && r.general.fails) {
7983
+ worst = {
7984
+ fails: r.general.fails,
7985
+ flashesPerSecond: r.general.worstTileFlashesPerSecond,
7986
+ flashingArea: r.general.flashingAreaFraction,
7987
+ at: lap ? w.from % lap : w.from
7988
+ };
7989
+ }
7990
+ if (worst.fails) return worst;
7991
+ if (from + MAX_SAMPLES >= sampleTimes.length) break;
7992
+ const resume = chunk[chunk.length - 1] - WINDOW;
7993
+ let next = from + 1;
7994
+ while (sampleTimes[next] < resume) next++;
7995
+ from = next;
7996
+ }
7997
+ }
7998
+ return worst;
7999
+ }
8000
+
6460
8001
  // src/advise.ts
6461
8002
  var LOW_CONTRAST_FLOOR = 0.05;
6462
8003
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
6463
8004
  var READ_LEGIBILITY_FLOOR = 4.5;
6464
8005
  var READ_SAFE_AREA = 0.05;
6465
8006
  var ADDITIVE_INK_CEILING = 1;
8007
+ var CALM_MAX_SPIN = 90;
8008
+ var NEAR_FLASH_MAX_BOUNDARIES = 10;
6466
8009
  function edgeAnchored(range) {
6467
8010
  if (!range) return false;
6468
8011
  const [a, b] = range;
@@ -6470,6 +8013,7 @@ function edgeAnchored(range) {
6470
8013
  }
6471
8014
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6472
8015
  const hidden = hiddenLayers(spec);
8016
+ const authored = spec;
6473
8017
  spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
6474
8018
  const warnings = [];
6475
8019
  const w = viewport.width;
@@ -6496,7 +8040,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6496
8040
  if (isStaticText) textLayerCount++;
6497
8041
  if (layer.motion.type !== "static") motionLayerCount++;
6498
8042
  const coh = cohesion[li];
6499
- if (seamsWorthWarning(layer, coh, entities.length)) {
8043
+ if (seamsWorthWarning(layer, coh, entities.length, () => buriedAreaOf(layer, entities, opts.t ?? COHESION_T, w, h))) {
6500
8044
  const s = layer.sprite;
6501
8045
  const softEdged = (s.kind === "circle" || s.kind === "polygon") && s.soft || s.kind === "rect" && !!s.feather;
6502
8046
  warnings.push({
@@ -6544,13 +8088,17 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6544
8088
  return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
6545
8089
  });
6546
8090
  const seps = entities.map((e) => {
6547
- const hex = spriteHex(layer, e);
6548
- if (hex === null) return Infinity;
6549
- const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
6550
- const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
6551
- if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
6552
- const under = groundUnder(spec, allEntities, li, hidden, positionAt(e, 0, w, h), w, h);
6553
- return under ? colourSeparation(layer, ink, under) : sep;
8091
+ const inks = spriteInks(layer, e);
8092
+ if (inks === null || inks.length === 0) return Infinity;
8093
+ return Math.max(...inks.map((hex) => {
8094
+ const ink = { rgb: hexRgb(hex), luma: hexLuma(hex) };
8095
+ const sep = Math.max(...plates.map((bg) => colourSeparation(layer, ink, bg)));
8096
+ if (sep >= LOW_CONTRAST_FLOOR || layer.blend === "lighter" || layer.blend === "screen") return sep;
8097
+ const at = positionAt(e, 0, w, h);
8098
+ const under = groundUnder(spec, allEntities, li, hidden, at, w, h);
8099
+ const framed = holeFrame(spec, allEntities, li, hidden, at, w, h);
8100
+ return Math.max(under ? colourSeparation(layer, ink, under) : sep, framed ? colourSeparation(layer, ink, framed) : sep);
8101
+ }));
6554
8102
  });
6555
8103
  const best = Math.max(...seps);
6556
8104
  if (best < LOW_CONTRAST_FLOOR) {
@@ -6579,7 +8127,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6579
8127
  if (layer.sprite.kind === "circle") {
6580
8128
  pixArea = Math.PI * r * r;
6581
8129
  } else if (layer.sprite.kind === "polygon") {
6582
- pixArea = polygonArea(polygonPoints(layer.sprite, r));
8130
+ if (isPathPolygon(layer.sprite)) {
8131
+ const fr = pathFrame(layer.sprite, r, w, h);
8132
+ pixArea = pathInkArea(layer.sprite, fr.sx, fr.sy, r);
8133
+ } else pixArea = polygonArea(polygonPoints(layer.sprite, r));
6583
8134
  } else if (layer.sprite.kind === "bar") {
6584
8135
  pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
6585
8136
  } else if (layer.sprite.kind === "textBlock") {
@@ -6608,8 +8159,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6608
8159
  }
6609
8160
  if (layer.links) {
6610
8161
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6611
- const maxDistPx = layer.links.maxDist * scale;
6612
- const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
8162
+ const edges = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h);
6613
8163
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
6614
8164
  const lwPx = (layer.links.width ?? defaultWidth) * scale;
6615
8165
  const la = layer.links.alpha ?? 1;
@@ -6662,15 +8212,39 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6662
8212
  });
6663
8213
  }
6664
8214
  }
8215
+ if (spec.motionIntensity === "calm") {
8216
+ for (let li = 0; li < spec.layers.length; li++) {
8217
+ if (hidden?.[li]) continue;
8218
+ const layer = spec.layers[li];
8219
+ if (!layer.spin || layer.sprite.kind === "circle" || layer.sprite.kind === "ring") continue;
8220
+ const entities = allEntities[li];
8221
+ if (entities.length === 0) continue;
8222
+ const rate = entities.reduce((s, e) => s + Math.abs(e.spinSpeed), 0) / entities.length * (180 / Math.PI);
8223
+ if (rate <= CALM_MAX_SPIN) continue;
8224
+ warnings.push({
8225
+ path: `layers[${li}].spin`,
8226
+ code: "motion-intensity-mismatch",
8227
+ message: `declared motionIntensity: 'calm' but ${layer.key ? `\`${layer.key}\`` : `layers[${li}]`} spins at ~${Math.round(rate)}\xB0/s on average \u2014 that reads busy, not calm (calm spins sit at \u2264 ${CALM_MAX_SPIN}\xB0/s); slow the spin or declare 'moderate' / 'energetic'`
8228
+ });
8229
+ }
8230
+ }
8231
+ const flicker = nearLimitFlash(authored);
8232
+ if (flicker) {
8233
+ warnings.push({
8234
+ path: "timeline",
8235
+ code: "flash-near-limit",
8236
+ message: `the timeline flickers ${flicker.flashesPerSecond} times a second over ${Math.round(flicker.area * 100)}% of the frame around ${Math.round(flicker.at)} ms \u2014 at or near WCAG 2.3.1's limit of 3; space opposing luminance changes \u2265 500 ms apart, or lower the contrast between the two states`
8237
+ });
8238
+ }
6665
8239
  for (let li = 0; li < spec.layers.length; li++) {
6666
8240
  if (hidden?.[li]) continue;
6667
8241
  const layer = spec.layers[li];
6668
8242
  if (!layer.links || layer.links.mode === "chain") continue;
6669
8243
  const entities = allEntities[li];
6670
8244
  const positions = entities.map((e) => positionAt(e, 0, w, h));
6671
- const maxDist = layer.links.maxDist * scale;
6672
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
6673
- const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
8245
+ const k = layer.links.k ?? 1;
8246
+ const pairs = linkPairs(positions, k, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h);
8247
+ const maxPossible = Math.min(entities.length * k, entities.length * (entities.length - 1) / 2);
6674
8248
  if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
6675
8249
  warnings.push({
6676
8250
  path: `layers[${li}].links`,
@@ -6729,9 +8303,13 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6729
8303
  const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
6730
8304
  for (const e of allEntities[li]) {
6731
8305
  const p = positionAt(e, 0, w, h);
6732
- const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
8306
+ const unturned = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
8307
+ const rot = rotationAt(e, 0);
8308
+ const turn = (q) => ({ x: p.x + (q.x - p.x) * Math.cos(rot) - (q.y - p.y) * Math.sin(rot), y: p.y + (q.x - p.x) * Math.sin(rot) + (q.y - p.y) * Math.cos(rot) });
6733
8309
  const map = paintMap(spec, layer, w, h, scale);
6734
- const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
8310
+ const both = rot && map ? (q) => map(turn(q)) : rot ? turn : map;
8311
+ const box = both ? { ...unturned, ...mapBox(both, unturned) } : unturned;
8312
+ const raw = rot ? { ...unturned, ...mapBox(turn, unturned) } : unturned;
6735
8313
  const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
6736
8314
  const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
6737
8315
  const str = s.kind === "textBlock" ? s.text : s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
@@ -6853,20 +8431,34 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6853
8431
  for (const box of boxes) {
6854
8432
  const cy = (box.y0 + box.y1) / 2;
6855
8433
  const cx = (box.x0 + box.x1) / 2;
6856
- const groundRgb = backgroundRgbAt(spec, cy, h, scale, cx, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
6857
- const ground = legibilityRatio(textPlate(groundRgb, box.alpha), groundRgb);
6858
- const lit = brightestAdditivePlate(spec, allEntities, groundRgb, box, w, h);
6859
- const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
6860
- if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
6861
- worst = { ground, plate, plateLabel: lit?.label ?? null };
8434
+ const bgAt = (x, y) => backgroundRgbAt(spec, y, h, scale, x, w, opts.backgroundSeed ?? opts.seed, opts.t ?? COHESION_T);
8435
+ const ix = (box.x1 - box.x0) * 0.02;
8436
+ const iy = (box.y1 - box.y0) * 0.02;
8437
+ const probes = [[cx, cy], [box.x0 + ix, box.y0 + iy], [box.x1 - ix, box.y0 + iy], [box.x0 + ix, box.y1 - iy], [box.x1 - ix, box.y1 - iy]];
8438
+ const grounds = [];
8439
+ let bare = false;
8440
+ for (const [x, y] of probes) {
8441
+ const under = groundUnder(spec, allEntities, box.li, hidden, { x, y }, w, h, bgAt(x, y));
8442
+ if (under) grounds.push({ rgb: under.rgb, onSolid: true });
8443
+ else bare = true;
8444
+ }
8445
+ if (bare) grounds.push({ rgb: bgAt(cx, cy), onSolid: false });
8446
+ for (const g of grounds) {
8447
+ const ground = legibilityRatio(textPlate(g.rgb, box.alpha), g.rgb);
8448
+ const lit = brightestAdditivePlate(spec, allEntities, g.rgb, box, w, h);
8449
+ const plate = lit ? legibilityRatio(textPlate(lit.rgb, box.alpha), lit.rgb) : Infinity;
8450
+ if (!worst || Math.min(ground, plate) < Math.min(worst.ground, worst.plate)) {
8451
+ worst = { ground, plate, plateLabel: lit?.label ?? null, onSolid: g.onSolid };
8452
+ }
6862
8453
  }
6863
8454
  }
6864
8455
  if (worst && Math.min(worst.ground, worst.plate) < READ_LEGIBILITY_FLOOR) {
6865
8456
  const under = worst.plateLabel ? ` and ${worst.plate.toFixed(1)}:1 under ${worst.plateLabel} at its brightest` : "";
8457
+ const against = worst.onSolid ? "against the solid layer painted under it" : "against the background at the box centre";
6866
8458
  warnings.push({
6867
8459
  path: `layers[${b.li}].sprite`,
6868
8460
  code: "text-legibility",
6869
- message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 against the background at the box centre${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
8461
+ message: `${b.label} is declared role: 'read' but its colour reads at ${worst.ground.toFixed(1)}:1 ${against}${under} \u2014 below ${READ_LEGIBILITY_FLOOR}:1; brighten the text, darken the ground under it, or declare role: 'atmosphere' if it is texture`
6870
8462
  });
6871
8463
  }
6872
8464
  const edges = [];
@@ -6889,6 +8481,21 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
6889
8481
  }
6890
8482
  return warnings;
6891
8483
  }
8484
+ function nearLimitFlash(spec) {
8485
+ if (!spec.timeline) return null;
8486
+ const { times, lap } = lumaKeyTimes(spec);
8487
+ let best = null;
8488
+ for (let i = 0, e = 0; i < times.length; i++) {
8489
+ while (e < times.length && times[e] < times[i] + 1e3) e++;
8490
+ if (e - i >= 4 && (!best || e - i > best.n)) best = { from: i, n: e - i };
8491
+ }
8492
+ if (!best) return null;
8493
+ const window = times.slice(best.from, best.from + Math.min(best.n, NEAR_FLASH_MAX_BOUNDARIES));
8494
+ const samples = flashSampleTimes(window).map((t) => ({ t, lum: luminanceGrid(spec, { t, cols: 16, rows: 9 }).cells.flat().map((v) => srgbToLinear3(v * 255)) }));
8495
+ const r = analyzeFlashes2(samples, { maxFlashesPerSecond: 1 }).general;
8496
+ if (!r.fails) return null;
8497
+ return { flashesPerSecond: r.worstTileFlashesPerSecond, area: r.flashingAreaFraction, at: lap ? window[0] % lap : window[0] };
8498
+ }
6892
8499
  function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
6893
8500
  let best = null;
6894
8501
  for (let lj = 0; lj < spec.layers.length; lj++) {
@@ -6916,38 +8523,68 @@ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
6916
8523
  }
6917
8524
  return best ? { rgb: best.rgb, label: best.label } : null;
6918
8525
  }
6919
- function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6920
- let plate = backgroundRgb(spec);
8526
+ function holeFrame(spec, allEntities, li, hidden, pt, w, h) {
8527
+ for (let lj = li + 1; lj < spec.layers.length; lj++) {
8528
+ if (hidden?.[lj]) continue;
8529
+ const layer = spec.layers[lj];
8530
+ const sp = layer.sprite;
8531
+ if (sp.kind !== "polygon" || !isPathPolygon(sp) || sp.soft) continue;
8532
+ if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
8533
+ if (flattenSprite(sp).subpaths < 2) continue;
8534
+ const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
8535
+ for (const e of allEntities[lj]) {
8536
+ if (e.spinSpeed !== 0 || e.rotate || e.alpha * a < 0.9) continue;
8537
+ const c = positionAt(e, 0, w, h);
8538
+ const fr = pathFrame(sp, e.size / 2, w, h);
8539
+ const pts = flattenSprite(sp).points.map(([u, v]) => ({ x: (u - fr.ox) * fr.sx, y: (v - fr.oy) * fr.sy }));
8540
+ if (pts.length < 3) continue;
8541
+ const dx = pt.x - c.x;
8542
+ const dy = pt.y - c.y;
8543
+ if (dx < Math.min(...pts.map((q) => q.x)) || dx > Math.max(...pts.map((q) => q.x))) continue;
8544
+ if (dy < Math.min(...pts.map((q) => q.y)) || dy > Math.max(...pts.map((q) => q.y))) continue;
8545
+ if (pointInPolygon(dx, dy, pts)) continue;
8546
+ const ink = spriteInks(layer, e)?.[0];
8547
+ if (ink === void 0) continue;
8548
+ const rgb = hexRgb(ink);
8549
+ return { rgb, luma: 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b };
8550
+ }
8551
+ }
8552
+ return null;
8553
+ }
8554
+ function groundUnder(spec, allEntities, li, hidden, pt, w, h, base = backgroundRgb(spec)) {
8555
+ let plate = base;
6921
8556
  let covered = false;
6922
8557
  for (let lj = 0; lj < li; lj++) {
6923
8558
  if (hidden?.[lj]) continue;
6924
8559
  const layer = spec.layers[lj];
6925
8560
  const sp = layer.sprite;
6926
8561
  if (layer.motion.type !== "static" || layer.blend !== void 0 || layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0) continue;
8562
+ if (layer.life !== void 0 || layer.emit !== void 0) continue;
6927
8563
  if ((sp.kind === "circle" || sp.kind === "polygon") && sp.soft) continue;
6928
8564
  if (sp.kind !== "circle" && sp.kind !== "rect" && sp.kind !== "polygon") continue;
8565
+ if (sp.kind === "polygon" && isPathPolygon(sp)) continue;
6929
8566
  const a = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
6930
8567
  for (const e of allEntities[lj]) {
6931
- if (e.spinSpeed !== 0 || e.alpha * a <= 0) continue;
8568
+ if (e.spinSpeed !== 0 || e.pulseAmp > 0 || e.growAmp > 0 || e.alpha * a <= 0) continue;
6932
8569
  const hex = spriteHex(layer, e);
6933
8570
  if (hex === null) continue;
6934
8571
  const c = positionAt(e, 0, w, h);
6935
8572
  const dx = pt.x - c.x;
6936
8573
  const dy = pt.y - c.y;
6937
- let inside = false;
6938
- if (sp.kind === "circle") inside = Math.hypot(dx, dy) <= e.size / 2;
8574
+ let inside2 = false;
8575
+ if (sp.kind === "circle") inside2 = Math.hypot(dx, dy) <= e.size / 2;
6939
8576
  else if (sp.kind === "rect") {
6940
8577
  const rot = ((e.rotate ?? 0) % Math.PI + Math.PI) % Math.PI;
6941
8578
  const quarter = Math.abs(rot - Math.PI / 2) < 0.01;
6942
8579
  if (rot > 0.01 && rot < Math.PI - 0.01 && !quarter) continue;
6943
8580
  const hx = (quarter ? e.size2 ?? e.size : e.size) / 2;
6944
8581
  const hy = (quarter ? e.size : e.size2 ?? e.size) / 2;
6945
- inside = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
8582
+ inside2 = Math.abs(dx) <= hx && Math.abs(dy) <= hy;
6946
8583
  } else {
6947
8584
  if (e.rotate) continue;
6948
- inside = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
8585
+ inside2 = pointInPolygon(dx, dy, polygonPoints(sp, e.size / 2));
6949
8586
  }
6950
- if (!inside) continue;
8587
+ if (!inside2) continue;
6951
8588
  plate = sourceOverPlate(plate, hexRgb(hex), Math.min(1, e.alpha * a));
6952
8589
  covered = true;
6953
8590
  }
@@ -6955,13 +8592,13 @@ function groundUnder(spec, allEntities, li, hidden, pt, w, h) {
6955
8592
  return covered ? { rgb: plate, luma: 0.2126 * plate.r + 0.7152 * plate.g + 0.0722 * plate.b } : null;
6956
8593
  }
6957
8594
  function pointInPolygon(x, y, pts) {
6958
- let inside = false;
8595
+ let inside2 = false;
6959
8596
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
6960
8597
  const a = pts[i];
6961
8598
  const b = pts[j];
6962
- if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
8599
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside2 = !inside2;
6963
8600
  }
6964
- return inside;
8601
+ return inside2;
6965
8602
  }
6966
8603
  function entityReachesBox(e, r, box, w, h) {
6967
8604
  if (e.motion === "static") {
@@ -6981,7 +8618,7 @@ function isOutlineStack(a, b) {
6981
8618
  if (fs <= 0 || Math.abs(a.fs - b.fs) > 0.1 * Math.max(a.fs, b.fs)) return false;
6982
8619
  const dx = (a.x0 + a.x1) / 2 - (b.x0 + b.x1) / 2;
6983
8620
  const dy = (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2;
6984
- return Math.hypot(dx, dy) <= OUTLINE_STACK_OFFSET * fs;
8621
+ return Math.max(Math.abs(dx), Math.abs(dy)) <= OUTLINE_STACK_OFFSET * fs;
6985
8622
  }
6986
8623
  function textBoxAt(s, e, p, spec, w, h) {
6987
8624
  const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
@@ -7113,10 +8750,10 @@ function describeScene(spec, options = {}) {
7113
8750
  let connectedComponents = null;
7114
8751
  let isolatedNodes = null;
7115
8752
  if (layer.links) {
7116
- const maxDist = layer.links.maxDist * scale;
7117
- const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
8753
+ const n = entities.length;
8754
+ const pairs = linkEdges(layer.links, positions, linkMaxDistPx(layer.links, scale), layer.wrap !== false, w, h).map(({ i, j }) => [i, j]);
7118
8755
  linksDrawn = pairs.length;
7119
- linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
8756
+ linksExpected = layer.links.mode === "chain" ? Math.max(0, n - 1) + (layer.links.closed && n > 2 ? 1 : 0) : Math.min(n * (layer.links.k ?? 1), n * (n - 1) / 2);
7120
8757
  const parent = Array.from({ length: entities.length }, (_, i) => i);
7121
8758
  const find = (x) => {
7122
8759
  while (parent[x] !== x) {
@@ -9617,6 +11254,7 @@ export {
9617
11254
  LIMITS,
9618
11255
  LOBBY_TALK_SPEC,
9619
11256
  MATRIX_RAIN_SPEC,
11257
+ MAX_PATH_POINTS,
9620
11258
  MURMUR_SPEC,
9621
11259
  NOSTALGHIA_CANDLE_SPEC,
9622
11260
  ORRERY_SPEC,
@@ -9643,6 +11281,7 @@ export {
9643
11281
  adviseSequence,
9644
11282
  adviseSpec,
9645
11283
  alphaAt,
11284
+ applyDeltasShared,
9646
11285
  applyDeltasToSpec,
9647
11286
  assertValidSequence,
9648
11287
  assertValidSpec,
@@ -9651,6 +11290,7 @@ export {
9651
11290
  bedRenderSeed,
9652
11291
  breakTextBlock,
9653
11292
  buildEntities,
11293
+ buriedAreaOf,
9654
11294
  canMorph,
9655
11295
  canWrapMorph,
9656
11296
  canonicalSpecPath,
@@ -9672,6 +11312,7 @@ export {
9672
11312
  fieldRgbAt,
9673
11313
  fieldSampleTime,
9674
11314
  finishStrength,
11315
+ flattenPath,
9675
11316
  grainOffset,
9676
11317
  grainTilePixels,
9677
11318
  graphemeClusters,
@@ -9679,12 +11320,15 @@ export {
9679
11320
  headingAt,
9680
11321
  hexToRgb255,
9681
11322
  ignoredPropertyPaths,
11323
+ isPathPolygon,
9682
11324
  latticeHash,
9683
11325
  layerCohesion,
11326
+ lerpPathD,
9684
11327
  lerpSpec,
9685
11328
  lerpValue,
9686
11329
  lifeAlphaAt,
9687
11330
  linkEdges,
11331
+ linkMaxDistPx,
9688
11332
  linkPairs,
9689
11333
  luminanceGrid,
9690
11334
  manifestFor,
@@ -9693,6 +11337,8 @@ export {
9693
11337
  motionStats,
9694
11338
  nextKeyAfter,
9695
11339
  normalizeSeed,
11340
+ pathFrame,
11341
+ pathParts,
9696
11342
  perceiveScene,
9697
11343
  perceiveSequenceFrame,
9698
11344
  pickScreenOp,
@@ -9726,6 +11372,7 @@ export {
9726
11372
  textStringsDiffer,
9727
11373
  textStringsOf,
9728
11374
  textWidthEm,
11375
+ timelineFlashReport,
9729
11376
  timelineLocalTime,
9730
11377
  timelineSampleTimes,
9731
11378
  timelineSampleTimesAfter,