@energy8platform/golem 0.2.0 → 0.3.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.
@@ -1,9 +1,9 @@
1
1
  // src/editor/server.ts
2
2
  import { createServer } from "node:http";
3
- import { readFile as readFile9, stat as stat3, readdir as readdir3, mkdir as mkdir5, open } from "node:fs/promises";
3
+ import { readFile as readFile10, stat as stat3, readdir as readdir4, mkdir as mkdir6, open } from "node:fs/promises";
4
4
  import { watch as fsWatch } from "node:fs";
5
- import { resolve as resolve2, dirname as dirname7, basename as basename5, extname as extname2, sep, join as join8, relative as relative3 } from "node:path";
6
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5
+ import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
7
7
 
8
8
  // src/rig-format.ts
9
9
  import { z } from "zod";
@@ -62,7 +62,7 @@ function minJacobian(offsets, w, h, samples = 17) {
62
62
  var ID_RE = /^[a-z][a-z0-9_]*$/;
63
63
  var Id = z.string().regex(ID_RE, "snake_case id");
64
64
  var Ease = z.union([
65
- z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "back_out", "elastic_out"]),
65
+ z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "minimum_jerk", "back_out", "elastic_out"]),
66
66
  // cubic-bezier control points, same semantics as CSS
67
67
  z.object({ bezier: z.tuple([z.number(), z.number(), z.number(), z.number()]) })
68
68
  ]);
@@ -94,6 +94,7 @@ var Asset = z.object({
94
94
  /** where this layer came from — provenance for agents */
95
95
  origin: z.object({
96
96
  sourceImage: z.string().optional(),
97
+ bboxSpace: z.enum(["source", "document"]).optional(),
97
98
  /** bbox in source image: [x, y, w, h] */
98
99
  bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
99
100
  inpainted: z.boolean().default(false),
@@ -116,6 +117,8 @@ var Bone = z.object({
116
117
  var Slot = z.object({
117
118
  id: Id,
118
119
  bone: Id,
120
+ /** Closed path attachment used as a moving clipping mask. */
121
+ clip: Id.optional(),
119
122
  /** active attachment id; undefined = hidden */
120
123
  attachment: Id.optional(),
121
124
  alpha: z.number().min(0).max(1).default(1),
@@ -178,6 +181,8 @@ var IkConstraint = z.object({
178
181
  order: z.number().int().default(0),
179
182
  bones: z.array(Id).min(1),
180
183
  target: Id,
184
+ iterations: z.number().int().min(1).max(1e3).optional(),
185
+ tolerance: z.number().positive().optional(),
181
186
  mix: z.number().default(1),
182
187
  softness: z.number().default(0),
183
188
  bendPositive: z.boolean().default(true),
@@ -230,7 +235,7 @@ var BoneProp = z.enum(["x", "y", "rotation", "scaleX", "scaleY", "shearX", "shea
230
235
  var SlotProp = z.enum(["alpha", "attachment", "z", "color"]);
231
236
  var DeformProp = z.string().regex(/^(bulge|bend|sway|squash|p[0-2][0-2][xy]|vertices)$/, "deform prop must be bulge|bend|sway|squash|p<row><col>x|y|vertices");
232
237
  var IkProp = z.enum(["mix", "softness", "bendPositive", "compress", "stretch"]);
233
- var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY"]);
238
+ var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY", "x", "y", "rotation", "scaleX", "scaleY", "shearY"]);
234
239
  var PathProp = z.enum(["position", "spacing", "mixRotate", "mixX", "mixY"]);
235
240
  var KeyValue = z.union([z.number(), z.string(), z.null(), z.array(z.number()), z.array(z.string())]);
236
241
  var Key = z.object({
@@ -239,14 +244,15 @@ var Key = z.object({
239
244
  /** easing INTO the next key */
240
245
  ease: Ease.default("ease_in_out")
241
246
  });
247
+ var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
242
248
  var Track = z.discriminatedUnion("target", [
243
- z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, keys: z.array(Key).min(1) }),
244
- z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, keys: z.array(Key).min(1) }),
245
- z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, keys: z.array(Key).min(1) }),
246
- z.object({ target: z.literal("ik"), id: Id, prop: IkProp, keys: z.array(Key).min(1) }),
247
- z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, keys: z.array(Key).min(1) }),
248
- z.object({ target: z.literal("path"), id: Id, prop: PathProp, keys: z.array(Key).min(1) }),
249
- z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), keys: z.array(Key).min(1) })
249
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
250
+ z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
251
+ z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
252
+ z.object({ target: z.literal("ik"), id: Id, prop: IkProp, ...Interpolation, keys: z.array(Key).min(1) }),
253
+ z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, ...Interpolation, keys: z.array(Key).min(1) }),
254
+ z.object({ target: z.literal("path"), id: Id, prop: PathProp, ...Interpolation, keys: z.array(Key).min(1) }),
255
+ z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), ...Interpolation, keys: z.array(Key).min(1) })
250
256
  ]);
251
257
  var Event = z.object({
252
258
  t: z.number().nonnegative(),
@@ -388,6 +394,10 @@ function validate(doc) {
388
394
  if (a.frame && !a.trim && !a.rotate && (a.frame[2] !== a.width || a.frame[3] !== a.height)) errs.push(`asset "${a.id}": frame size must equal width\xD7height (or declare trim/rotate)`);
389
395
  }
390
396
  for (const s of doc.slots) {
397
+ if (s.clip) {
398
+ const mask = attachments.get(s.clip);
399
+ if (mask?.type !== "path" || !mask.closed) errs.push(`slot "${s.id}": clip must name a closed path attachment`);
400
+ }
391
401
  if (!bones.has(s.bone)) errs.push(`slot "${s.id}": unknown bone "${s.bone}"`);
392
402
  if (s.attachment) {
393
403
  const a = attachments.get(s.attachment);
@@ -419,7 +429,8 @@ function validate(doc) {
419
429
  }
420
430
  }
421
431
  for (const c of doc.ik) {
422
- if (c.bones.length > 2) errs.push(`ik "${c.id}": must have 1 or 2 bones`);
432
+ for (let i = 1; i < c.bones.length; i++) if (doc.bones.find((b) => b.id === c.bones[i])?.parent !== c.bones[i - 1])
433
+ errs.push(`ik "${c.id}": bones must form a contiguous parent-to-child chain`);
423
434
  for (const b of c.bones) if (!bones.has(b)) errs.push(`ik "${c.id}": unknown bone "${b}"`);
424
435
  if (!bones.has(c.target)) errs.push(`ik "${c.id}": unknown target bone "${c.target}"`);
425
436
  }
@@ -484,9 +495,9 @@ function parse(input) {
484
495
  }
485
496
 
486
497
  // src/rig-api.ts
487
- import { z as z2 } from "zod";
488
- import { readFile as readFile8, writeFile as writeFile6, stat as stat2 } from "node:fs/promises";
489
- import { dirname as dirname6, join as join7, relative as relative2, isAbsolute } from "node:path";
498
+ import { z as z3 } from "zod";
499
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
500
+ import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
490
501
 
491
502
  // src/rig-io.ts
492
503
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
@@ -541,6 +552,7 @@ var named = {
541
552
  ease_in: (t) => t * t,
542
553
  ease_out: (t) => 1 - (1 - t) * (1 - t),
543
554
  ease_in_out: (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
555
+ minimum_jerk: (t) => t * t * t * (10 + t * (-15 + 6 * t)),
544
556
  back_out: (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2,
545
557
  elastic_out: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1
546
558
  };
@@ -572,7 +584,25 @@ function ease(spec) {
572
584
  return f;
573
585
  }
574
586
  var pk = (target, id, prop) => `${target}:${id}:${prop}`;
575
- function sample(track, t) {
587
+ function smoothValue(track, i, t, component, loop) {
588
+ const k = track.keys, n = k.length;
589
+ const v = (j2) => component === void 0 ? k[j2].v : k[j2].v[component];
590
+ const h = (j2) => k[j2 + 1].t - k[j2].t;
591
+ const d = (j2) => (v(j2 + 1) - v(j2)) / h(j2);
592
+ const harmonic = (a, b, h0, h1) => a * b <= 0 ? 0 : 3 * (h0 + h1) / ((2 * h1 + h0) / a + (h1 + 2 * h0) / b);
593
+ const tangent = (j2) => {
594
+ if (j2 === 0 || j2 === n - 1) {
595
+ if (loop && n > 2 && v(0) === v(n - 1) && k[0].ease !== "step" && k[n - 2].ease !== "step")
596
+ return harmonic(d(n - 2), d(0), h(n - 2), h(0));
597
+ return 0;
598
+ }
599
+ if (k[j2 - 1].ease === "step" || k[j2].ease === "step") return 0;
600
+ return harmonic(d(j2 - 1), d(j2), h(j2 - 1), h(j2));
601
+ };
602
+ const u = (t - k[i].t) / h(i), u2 = u * u, u3 = u2 * u;
603
+ return (2 * u3 - 3 * u2 + 1) * v(i) + (u3 - 2 * u2 + u) * h(i) * tangent(i) + (-2 * u3 + 3 * u2) * v(i + 1) + (u3 - u2) * h(i) * tangent(i + 1);
604
+ }
605
+ function sample(track, t, loop = false) {
576
606
  const keys = track.keys;
577
607
  if (t <= keys[0].t) return keys[0].v;
578
608
  const last = keys[keys.length - 1];
@@ -580,18 +610,39 @@ function sample(track, t) {
580
610
  let i = 0;
581
611
  while (keys[i + 1].t <= t) i++;
582
612
  const a = keys[i], b = keys[i + 1];
613
+ if (a.ease === "step") return a.v;
583
614
  if (typeof a.v === "number" && typeof b.v === "number") {
615
+ if (track.interpolation === "pchip") return smoothValue(track, i, t, void 0, loop);
584
616
  const u = ease(a.ease)((t - a.t) / (b.t - a.t));
585
617
  return a.v + (b.v - a.v) * u;
586
618
  }
587
619
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
620
+ if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
588
621
  const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
589
622
  return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
590
623
  }
591
624
  return a.v;
592
625
  }
593
626
  function poseOf(anim, t, out = /* @__PURE__ */ new Map()) {
594
- for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t));
627
+ for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t, anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === anim.duration));
628
+ return out;
629
+ }
630
+ function blendPoses(from, to, w, restOf) {
631
+ if (w >= 1) return new Map(to);
632
+ const out = /* @__PURE__ */ new Map();
633
+ for (const k of /* @__PURE__ */ new Set([...from.keys(), ...to.keys()])) {
634
+ const f = from.has(k) ? from.get(k) : restOf(k);
635
+ const cur = to.has(k) ? to.get(k) : restOf(k);
636
+ if (typeof f === "number" && !Array.isArray(cur)) {
637
+ const target = typeof cur === "number" ? cur : restOf(k);
638
+ out.set(k, f + (target - f) * w);
639
+ } else if (Array.isArray(f) && typeof f[0] === "number") {
640
+ const fv = f, tv = Array.isArray(cur) && typeof cur[0] === "number" ? cur : fv.map(() => 0);
641
+ out.set(k, fv.map((v, i) => v + ((tv[i] ?? 0) - v) * w));
642
+ } else if (Array.isArray(cur) && typeof cur[0] === "number" && typeof f !== "string") {
643
+ out.set(k, cur.map((v) => v * w));
644
+ } else out.set(k, f ?? null);
645
+ }
595
646
  return out;
596
647
  }
597
648
  function restValue(doc, key) {
@@ -763,10 +814,43 @@ function aimAt(skel, boneId, tx, ty, mix, compress = false, stretch = false, uni
763
814
  updateSubtree(skel, boneId);
764
815
  }
765
816
  var shortest = (deg) => ((deg + 180) % 360 + 360) % 360 - 180;
817
+ function applyLongIk(skel, c, tx, ty, mix) {
818
+ const initial = c.bones.map((id) => ({ ...skel.bones.get(id) }));
819
+ const last = c.bones.at(-1), len = boneOf(skel.doc, last).length;
820
+ const end = () => apply(skel.bones.get(last).world, len, 0);
821
+ const tolerance = c.tolerance ?? 0.05;
822
+ const root2 = skel.bones.get(c.bones[0]).world, tip = end();
823
+ if (Math.abs((tip[0] - root2.tx) * (ty - root2.ty) - (tip[1] - root2.ty) * (tx - root2.tx)) < 1e-7 && Math.hypot(tx - root2.tx, ty - root2.ty) < Math.hypot(tip[0] - root2.tx, tip[1] - root2.ty) - tolerance) {
824
+ skel.bones.get(last).rotation += cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 5 : -5;
825
+ updateSubtree(skel, last);
826
+ }
827
+ for (let iter = 0; iter < (c.iterations ?? 100); iter++) {
828
+ const e = end();
829
+ if (Math.hypot(e[0] - tx, e[1] - ty) <= tolerance) break;
830
+ for (let i = c.bones.length - 1; i >= 0; i--) {
831
+ const id = c.bones[i], b = boneOf(skel.doc, id), st = skel.bones.get(id), w = st.world, e2 = end();
832
+ const angle = wrapRad(Math.atan2(ty - w.ty, tx - w.tx) - Math.atan2(e2[1] - w.ty, e2[0] - w.tx));
833
+ const cos = Math.cos(angle), sin = Math.sin(angle);
834
+ setEffectiveWorld(skel, b, st, { ...w, a: cos * w.a - sin * w.b, b: sin * w.a + cos * w.b, c: cos * w.c - sin * w.d, d: sin * w.c + cos * w.d });
835
+ }
836
+ }
837
+ if (mix < 1) {
838
+ c.bones.forEach((id, i) => {
839
+ const st = skel.bones.get(id), from = initial[i];
840
+ for (const p of ["rotation", "shearX", "shearY"]) st[p] = from[p] + shortest(st[p] - from[p]) * mix;
841
+ for (const p of ["x", "y", "scaleX", "scaleY"]) st[p] = from[p] + (st[p] - from[p]) * mix;
842
+ });
843
+ updateSubtree(skel, c.bones[0]);
844
+ }
845
+ }
766
846
  function applyIk(skel, c) {
767
847
  const mix = cnum(skel, "ik", c.id, "mix", c.mix);
768
848
  if (mix <= 0) return;
769
849
  const target = skel.bones.get(c.target).world;
850
+ if (c.bones.length > 2) {
851
+ applyLongIk(skel, c, target.tx, target.ty, mix);
852
+ return;
853
+ }
770
854
  const bendDir = cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 1 : -1;
771
855
  const compress = cnum(skel, "ik", c.id, "compress", c.compress) > 0.5, stretch = cnum(skel, "ik", c.id, "stretch", c.stretch) > 0.5;
772
856
  let softness = cnum(skel, "ik", c.id, "softness", c.softness);
@@ -901,6 +985,7 @@ function applyTransformConstraint(skel, c) {
901
985
  const mixRotate = cnum(skel, "transform", c.id, "mixRotate", c.mixRotate), mixX = cnum(skel, "transform", c.id, "mixX", c.mixX), mixY = cnum(skel, "transform", c.id, "mixY", c.mixY);
902
986
  const mixScaleX = cnum(skel, "transform", c.id, "mixScaleX", c.mixScaleX), mixScaleY = cnum(skel, "transform", c.id, "mixScaleY", c.mixScaleY), mixShearY = cnum(skel, "transform", c.id, "mixShearY", c.mixShearY);
903
987
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
988
+ c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
904
989
  const target = skel.bones.get(c.target), tw = target.world;
905
990
  const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
906
991
  for (const id of c.bones) {
@@ -1291,10 +1376,10 @@ function traceLoops(mask, threshold) {
1291
1376
  return loops;
1292
1377
  }
1293
1378
  function traceHulls(mask, opts = {}) {
1294
- const { threshold = 8, tolerance = 2, margin = 1 } = opts;
1379
+ const { threshold = 8, tolerance = 2, margin = 1, minIslandRatio = MIN_ISLAND_AREA } = opts;
1295
1380
  const loops = traceLoops(mask, threshold).map((l) => ({ l, a: signedArea(l) }));
1296
1381
  const biggest = loops.reduce((m, x) => Math.abs(x.a) > Math.abs(m.a) ? x : m, loops[0]);
1297
- const islands = loops.filter((x) => Math.sign(x.a) === Math.sign(biggest.a) && Math.abs(x.a) >= MIN_ISLAND_AREA * Math.abs(biggest.a)).sort((p, q) => Math.abs(q.a) - Math.abs(p.a));
1382
+ const islands = loops.filter((x) => Math.sign(x.a) === Math.sign(biggest.a) && Math.abs(x.a) >= minIslandRatio * Math.abs(biggest.a)).sort((p, q) => Math.abs(q.a) - Math.abs(p.a));
1298
1383
  return islands.map(({ l }) => {
1299
1384
  const s = simplify(dropCollinear(l), tolerance);
1300
1385
  return margin ? offsetPolygon(s, margin) : s;
@@ -1697,6 +1782,54 @@ function pathFromPoints(skel, slotId, points, closed) {
1697
1782
  }
1698
1783
  return { vertices, closed, lengths: curveLengths(flatten(ctrl.flat()), closed) };
1699
1784
  }
1785
+ function constraintDependencies(doc) {
1786
+ const parent = new Map(doc.bones.map((b) => [b.id, b.parent]));
1787
+ const ancestors = (id) => {
1788
+ const out = /* @__PURE__ */ new Set();
1789
+ for (let b = id; b && !out.has(b); b = parent.get(b)) out.add(b);
1790
+ return out;
1791
+ };
1792
+ const all = [
1793
+ ...doc.ik.map((c) => ({ kind: "ik", ...c })),
1794
+ ...doc.transform.map((c) => ({ kind: "transform", ...c })),
1795
+ ...doc.path.map((c) => ({ kind: "path", ...c }))
1796
+ ].sort((a, b) => a.order - b.order);
1797
+ const active2 = (c) => MIX_PROPS[c.kind].some((p) => c[p] !== 0 || doc.animations.some((a) => a.tracks.some((t) => t.target === c.kind && t.id === c.id && t.prop === p && t.keys.some((k) => typeof k.v === "number" && k.v !== 0))));
1798
+ const edges = [];
1799
+ all.forEach((reader, ri) => {
1800
+ if (!active2(reader)) return;
1801
+ const reads = /* @__PURE__ */ new Set();
1802
+ const add = (b) => ancestors(b).forEach((x) => reads.add(x));
1803
+ if (reader.kind === "path") {
1804
+ const slot = doc.slots.find((s) => s.id === reader.target);
1805
+ if (slot) add(slot.bone);
1806
+ for (const a of doc.attachments) if (a.slot === reader.target && a.type === "path") {
1807
+ for (const list of a.weights ?? []) for (const inf of list) if (inf.w !== 0) add(inf.bone);
1808
+ }
1809
+ } else add(reader.target);
1810
+ for (const b of reader.bones) {
1811
+ const p = parent.get(b);
1812
+ if (p && !reader.bones.includes(p)) add(p);
1813
+ }
1814
+ all.forEach((writer, wi) => {
1815
+ if (ri === wi || !active2(writer)) return;
1816
+ const bone = writer.bones.find((b) => reads.has(b));
1817
+ if (bone) edges.push({ reader: reader.id, readerKind: reader.kind, writer: writer.id, writerKind: writer.kind, bone, late: wi > ri });
1818
+ });
1819
+ });
1820
+ return { all, edges };
1821
+ }
1822
+ function orderConstraints(doc) {
1823
+ const { all, edges } = constraintDependencies(doc);
1824
+ const pending3 = [...all], ids = [];
1825
+ while (pending3.length) {
1826
+ const i = pending3.findIndex((c2) => edges.filter((e) => e.reader === c2.id && e.readerKind === c2.kind).every((e) => ids.includes(`${e.writerKind}:${e.writer}`)));
1827
+ if (i < 0) throw new Error(`constraint dependency cycle: ${pending3.map((c2) => c2.id).join(", ")}`);
1828
+ const c = pending3.splice(i, 1)[0];
1829
+ ids.push(`${c.kind}:${c.id}`);
1830
+ }
1831
+ return { ...doc, ik: doc.ik.map((c) => ({ ...c, order: ids.indexOf(`ik:${c.id}`) })), transform: doc.transform.map((c) => ({ ...c, order: ids.indexOf(`transform:${c.id}`) })), path: doc.path.map((c) => ({ ...c, order: ids.indexOf(`path:${c.id}`) })) };
1832
+ }
1700
1833
 
1701
1834
  // src/rig-tools.ts
1702
1835
  function createDoc(name, width, height, notes) {
@@ -1798,13 +1931,13 @@ function placeAsset(doc, p) {
1798
1931
  const boneId = cur?.bone ?? p.bone;
1799
1932
  const world = evaluate(doc, /* @__PURE__ */ new Map()).bones.get(boneId)?.world;
1800
1933
  if (!world) throw new Error(`unknown bone "${boneId}"`);
1801
- const z3 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s) => s.z)) + 1 : 0);
1802
- let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z3 });
1934
+ const z4 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s) => s.z)) + 1 : 0);
1935
+ let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z4 });
1803
1936
  const [lx, ly] = apply(invert(world), p.x, p.y);
1804
1937
  next = setAttachment(next, { id: p.asset, slot: slotId, asset: p.asset, x: round4(lx), y: round4(ly) });
1805
1938
  return setSlot(next, { id: slotId, attachment: p.asset, ...p.z !== void 0 ? { z: p.z } : {} });
1806
1939
  }
1807
- function layerPrompt(doc, assetId) {
1940
+ function layerPrompt(doc, assetId, opts = {}) {
1808
1941
  const a = doc.assets.find((a2) => a2.id === assetId);
1809
1942
  if (!a) throw new Error(`unknown asset "${assetId}"`);
1810
1943
  const att = doc.attachments.find((a2) => a2.type !== "path" && a2.asset === assetId);
@@ -1817,10 +1950,15 @@ function layerPrompt(doc, assetId) {
1817
1950
  `Part: ${a.origin?.prompt ?? a.id}.`,
1818
1951
  `Size: ${a.width}\xD7${a.height} px at source scale${a.origin?.bbox ? ` (source bbox [${a.origin.bbox.join(", ")}])` : ""}; keep the same silhouette registration` + (att && att.type === "region" ? ` so the image centre stays at (${+att.x.toFixed(1)}, ${+att.y.toFixed(1)}) from the joint.` : "."),
1819
1952
  bone ? `Rig context: attached to bone "${bone.id}"${bone.parent ? `, parent "${bone.parent}"` : ""}${children.length ? `; child parts: ${children.join(", ")}` : ""}.` : "Rig context: not attached yet.",
1820
- "Add about 20 px (source scale) of overlap beyond every joint end so rotation cannot expose a cut.",
1953
+ "Paint overlap beyond each hidden joint end through the planned motion range; choose its depth from the pivot and extreme poses, not a fixed pixel margin.",
1821
1954
  "Composition: orthographic front view, one connected opaque part only, same orientation as the current layer, centred with generous padding, no foreshortening.",
1822
- "Backdrop: perfectly flat uniform #00FF00 chroma-key background with no gradient, floor, shadow, text or watermark; do not use #00FF00 inside the part."
1955
+ "Backdrop: transparent RGBA, no floor, shadow, text or watermark. If the selected generator cannot output alpha, use a flat #00FF00 guide background and remove only the reviewed background region."
1823
1956
  ];
1957
+ if (opts.method === "repair") lines.push(
1958
+ "Complete the host part inside the marked hidden-surface region. Retain surrounding art as a registration guide; small differences outside the repair are acceptable because assembly keeps the approved pixels there.",
1959
+ `Do not recreate removed occluders${opts.removed?.length ? ": " + opts.removed.join(", ") : " (neighbouring parts deliberately removed from the guide)"}. Continue only this part's own material and contour; no guide magenta in the output.`,
1960
+ "After generation: fit unchanged landmarks, remove explicitly masked background, match host-material tone, feather into the host colour and repair pale fringe only inside the ink mask."
1961
+ );
1824
1962
  return lines.join("\n");
1825
1963
  }
1826
1964
  function setMeta(doc, patch) {
@@ -1884,7 +2022,7 @@ function removeSlot(doc, id) {
1884
2022
  const gone = new Set(doc.attachments.filter((a) => a.slot === id).map((a) => a.id));
1885
2023
  return patchDrawOrder({
1886
2024
  ...doc,
1887
- slots: doc.slots.filter((s) => s.id !== id),
2025
+ slots: doc.slots.filter((s) => s.id !== id).map((s) => gone.has(s.clip ?? "") ? { ...s, clip: void 0 } : s),
1888
2026
  attachments: doc.attachments.filter((a) => a.slot !== id),
1889
2027
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
1890
2028
  }, (o) => o.filter((s) => s !== id));
@@ -1932,8 +2070,8 @@ function rebindSlot(doc, slotId, bone) {
1932
2070
  function setDrawOrder(doc, order) {
1933
2071
  const ids = new Set(doc.slots.map((s) => s.id));
1934
2072
  if (order.length !== ids.size || new Set(order).size !== order.length || !order.every((s) => ids.has(s))) throw new Error("order must list every slot exactly once");
1935
- const z3 = new Map(order.map((id, i) => [id, i]));
1936
- return { ...doc, slots: doc.slots.map((s) => ({ ...s, z: z3.get(s.id) })) };
2073
+ const z4 = new Map(order.map((id, i) => [id, i]));
2074
+ return { ...doc, slots: doc.slots.map((s) => ({ ...s, z: z4.get(s.id) })) };
1937
2075
  }
1938
2076
  function setAttachment(doc, att) {
1939
2077
  const cur = doc.attachments.find((a) => a.id === att.id);
@@ -1971,31 +2109,33 @@ function setMesh(doc, id, patch) {
1971
2109
  const len = deformLength(next);
1972
2110
  return dropVertexKeys(withAtt, id, (l) => l === len);
1973
2111
  }
1974
- var setWeights = (doc, id, weights) => setMesh(doc, id, { weights });
1975
- function convertToMesh(doc, mesh) {
1976
- const cur = doc.attachments.find((a) => a.id === mesh.id);
1977
- if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh.id}" is not a region`);
2112
+ var setWeights = (doc, id, weights) => {
2113
+ const old = doc.attachments.find((a) => a.id === id);
2114
+ const sameLayout = old?.type === "mesh" && old.weights?.length === weights.length && old.weights.every((list, i) => list.length === weights[i].length && list.every((inf, j2) => inf.bone === weights[i][j2].bone));
2115
+ const result = setMesh(doc, id, { weights });
2116
+ if (sameLayout) return result;
2117
+ const cleared = dropVertexKeys(result.doc, id, () => false);
2118
+ return { doc: cleared.doc, dropped: [...result.dropped, ...cleared.dropped] };
2119
+ };
2120
+ function convertToMesh(doc, mesh2) {
2121
+ const cur = doc.attachments.find((a) => a.id === mesh2.id);
2122
+ if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh2.id}" is not a region`);
1978
2123
  const droppedTracks = [];
1979
2124
  const animations = doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => {
1980
- const drop = t.target === "deform" && t.id === mesh.id;
2125
+ const drop = t.target === "deform" && t.id === mesh2.id;
1981
2126
  if (drop) droppedTracks.push(`${trackId2(t)} in ${a.id}`);
1982
2127
  return !drop;
1983
2128
  }) }));
1984
- return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh.id ? Attachment.parse(mesh) : a), animations }, droppedTracks };
2129
+ return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh2.id ? Attachment.parse(mesh2) : a), animations }, droppedTracks };
1985
2130
  }
1986
2131
  function removeAttachment(doc, id) {
1987
2132
  return {
1988
2133
  ...doc,
1989
2134
  attachments: doc.attachments.filter((a) => a.id !== id),
1990
- slots: doc.slots.map((s) => s.attachment === id ? { ...s, attachment: void 0 } : s),
2135
+ slots: doc.slots.map((s) => ({ ...s, attachment: s.attachment === id ? void 0 : s.attachment, clip: s.clip === id ? void 0 : s.clip })),
1991
2136
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "deform" && t.id === id)).map((t) => t.target === "slot" && t.prop === "attachment" ? { ...t, keys: t.keys.map((k) => k.v === id ? { ...k, v: null } : k) } : t) }))
1992
2137
  };
1993
2138
  }
1994
- function placementFromPivot(pivot, offset, w, h) {
1995
- const o = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, ...offset };
1996
- const [x, y] = apply(affineOf(o.x, o.y, o.rotation, o.scaleX, o.scaleY), (0.5 - pivot.x) * w, (0.5 - pivot.y) * h);
1997
- return { x: +x.toFixed(4) + 0, y: +y.toFixed(4) + 0, rotation: o.rotation, scaleX: o.scaleX, scaleY: o.scaleY };
1998
- }
1999
2139
  var CONSTRAINT_SCHEMA = { ik: IkConstraint, transform: TransformConstraint, path: PathConstraint };
2000
2140
  var article = (kind) => kind === "ik" ? "an" : "a";
2001
2141
  var constraintKind = (doc, id) => doc.ik.some((c) => c.id === id) ? "ik" : doc.transform.some((c) => c.id === id) ? "transform" : doc.path.some((c) => c.id === id) ? "path" : void 0;
@@ -2007,8 +2147,10 @@ function setConstraint(doc, kind, id, patch) {
2007
2147
  const given = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0));
2008
2148
  if (!cur && !(given.bones && given.target)) throw new Error(`no ${kind} constraint "${id}" yet: creating one needs at least bones and target`);
2009
2149
  const next = CONSTRAINT_SCHEMA[kind].parse({ ...cur, ...given, id });
2010
- if (kind === "ik" && next.bones.length > 2) throw new Error(`ik "${id}": 1 or 2 bones, got ${next.bones.length} (${next.bones.join(",")})`);
2011
2150
  const list = cur ? doc[kind].map((c) => c.id === id ? next : c) : [...doc[kind], next];
2151
+ if (kind === "ik") {
2152
+ for (let i = 1; i < next.bones.length; i++) if (doc.bones.find((b) => b.id === next.bones[i])?.parent !== next.bones[i - 1]) throw new Error(`ik "${id}": bones must form a contiguous parent-to-child chain`);
2153
+ }
2012
2154
  return { ...doc, [kind]: list };
2013
2155
  }
2014
2156
  function removeConstraint(doc, id) {
@@ -2023,8 +2165,10 @@ function removeConstraint(doc, id) {
2023
2165
  function addIkChain(doc, bone, opts = {}) {
2024
2166
  const id = `${bone}_ik`;
2025
2167
  if (constraintKind(doc, id)) throw new Error(`constraint "${id}" exists; remove it first (rig_remove_constraint) or patch it`);
2026
- const full = ikChainFor(doc, bone);
2168
+ const full = opts.bones ?? ikChainFor(doc, bone);
2169
+ if (!full.length || full.at(-1) !== bone) throw new Error("explicit chain must end at the named tip bone");
2027
2170
  const rootId = doc.bones.find((b) => !b.parent).id;
2171
+ if (opts.bones && full.includes(rootId)) throw new Error("explicit chain cannot include the root carrying its target");
2028
2172
  const note = full.includes(rootId) ? "single bone \u2014 the two-bone chain would include the root, which carries the target" : void 0;
2029
2173
  const chain = note ? [bone] : full;
2030
2174
  if (chain.length === 1 && !doc.bones.find((b) => b.id === bone).length)
@@ -2138,10 +2282,11 @@ function pathAnchorOp(doc, id, op, index, t = 0.5) {
2138
2282
  function setKeys(doc, animId, track, keys, duration, opts = {}) {
2139
2283
  const anim = doc.animations.find((a) => a.id === animId) ?? { id: animId, duration: duration ?? 1, loop: false, tracks: [], events: [] };
2140
2284
  const same = (t) => t.target === track.target && t.id === track.id && t.prop === track.prop;
2285
+ const old = anim.tracks.find(same);
2141
2286
  const given = keys.map((k) => ({ ...k, ease: k.ease ?? "ease_in_out" }));
2142
2287
  const kept = opts.merge ? (anim.tracks.find(same)?.keys ?? []).filter((k) => !given.some((g) => Math.abs(g.t - k.t) < 1e-9)) : [];
2143
2288
  const sorted = [...kept, ...given].sort((a, b) => a.t - b.t);
2144
- const edited = { ...track, keys: sorted };
2289
+ const edited = { ...old, ...track, interpolation: track.interpolation ?? old?.interpolation, keys: sorted };
2145
2290
  const tracks = anim.tracks.some(same) ? anim.tracks.map((t) => same(t) ? edited : t) : [...anim.tracks, edited];
2146
2291
  const dur = duration ?? Math.max(anim.duration, ...sorted.map((k) => k.t));
2147
2292
  const next = { ...anim, tracks, duration: dur, preset: void 0 };
@@ -2224,7 +2369,7 @@ function copyTracks(doc, animId, from, to, opts) {
2224
2369
  const shifted = round6(k.t + shift);
2225
2370
  return { t: shifted >= anim.duration ? shifted - anim.duration : shifted, v: mirror ? -k.v : k.v, ease: k.ease };
2226
2371
  });
2227
- next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop }, keys);
2372
+ next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop, interpolation: t.interpolation }, keys);
2228
2373
  copied++;
2229
2374
  }
2230
2375
  if (!any) empty.push(srcId);
@@ -2284,7 +2429,7 @@ function rename2(doc, kind, id, to) {
2284
2429
  return {
2285
2430
  ...doc,
2286
2431
  attachments: doc.attachments.map((a) => ({ ...a, id: sub(a.id) })),
2287
- slots: doc.slots.map((s) => s.attachment ? { ...s, attachment: sub(s.attachment) } : s)
2432
+ slots: doc.slots.map((s) => ({ ...s, attachment: s.attachment ? sub(s.attachment) : void 0, clip: s.clip ? sub(s.clip) : void 0 }))
2288
2433
  };
2289
2434
  }
2290
2435
  case "asset": {
@@ -2314,10 +2459,10 @@ function describeAttachment(doc, att) {
2314
2459
  }
2315
2460
  const n = vertexCount(att);
2316
2461
  const bones = att.weights ? [...new Set(att.weights.flat().map((i) => i.bone))] : [];
2317
- const skin = att.weights ? `skinned to ${bones.length} bone${bones.length === 1 ? "" : "s"} (${bones.join(", ")}), ${att.weights.reduce((t, l) => t + l.length, 0)} influences` : `unskinned (\`vertices\` in slot bone "${doc.slots.find((s) => s.id === att.slot)?.bone ?? "?"}"'s space)`;
2462
+ const skin2 = att.weights ? `skinned to ${bones.length} bone${bones.length === 1 ? "" : "s"} (${bones.join(", ")}), ${att.weights.reduce((t, l) => t + l.length, 0)} influences` : `unskinned (\`vertices\` in slot bone "${doc.slots.find((s) => s.id === att.slot)?.bone ?? "?"}"'s space)`;
2318
2463
  const shape = att.type === "mesh" ? `${n} vertices, ${att.triangles.length / 3} triangles, hull ${att.hull}` : `${n} control points = ${anchorCount(n)} anchors, ${att.closed ? "closed" : "open"}, ${att.constantSpeed ? "constantSpeed" : "stored lengths"}`;
2319
2464
  const keyed = doc.animations.filter((a) => a.tracks.some((t) => t.target === "deform" && t.id === att.id && t.prop === "vertices")).map((a) => a.id);
2320
- return `${head}: ${shape}; ${skin}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2465
+ return `${head}: ${shape}; ${skin2}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2321
2466
  }
2322
2467
  function describe(doc, opts = {}) {
2323
2468
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
@@ -2478,9 +2623,11 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2478
2623
  let specs;
2479
2624
  try {
2480
2625
  specs = JSON.parse(await readFile(join2(dir, "layers.json"), "utf8"));
2481
- } catch {
2626
+ } catch (e) {
2627
+ if (e.code !== "ENOENT") throw e;
2482
2628
  specs = (await readdir(dir)).filter((f) => f.endsWith(".png")).sort().map((file) => ({ file }));
2483
2629
  }
2630
+ if (!Array.isArray(specs)) throw new Error("layers.json must be an array");
2484
2631
  let next = doc;
2485
2632
  const parsed = [];
2486
2633
  for (const s of specs) {
@@ -2488,21 +2635,32 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2488
2635
  const [w, h] = pngSize(buf);
2489
2636
  const m = basename2(s.file, ".png").match(/^(?:(\d+)_)?(.+)$/);
2490
2637
  const id = s.id ?? m[2].toLowerCase().replace(/[^a-z0-9_]/g, "_");
2491
- const z3 = s.z ?? (m[1] ? +m[1] : parsed.length);
2638
+ const z4 = s.z ?? (m[1] ? +m[1] : parsed.length);
2492
2639
  const role = s.role ?? (DEFAULT_PARENT[id] || id === "root" ? id : void 0);
2493
2640
  const bbox = s.bbox ?? [0, 0, w, h];
2494
- parsed.push({ ...s, id, z: z3, role, w, h, bbox });
2641
+ if (bbox.length !== 4 || bbox.some((v) => !Number.isFinite(v)) || bbox[2] <= 0 || bbox[3] <= 0)
2642
+ throw new Error(`layer "${id}": bbox must have positive width and height`);
2643
+ parsed.push({ ...s, id, z: z4, role, w, h, bbox });
2495
2644
  }
2496
2645
  const ids = new Set(parsed.map((p) => p.id));
2497
2646
  const rootId = next.bones.find((b) => !b.parent).id;
2498
2647
  const exists3 = (id) => ids.has(id) || next.bones.some((b) => b.id === id);
2499
2648
  const resolveParent = (p) => {
2500
- if (p.parent && exists3(p.parent)) return p.parent;
2649
+ if (p.parent) {
2650
+ if (!exists3(p.parent)) throw new Error(`layer "${p.id}": unknown parent "${p.parent}"`);
2651
+ return p.parent;
2652
+ }
2501
2653
  let cur = p.role && DEFAULT_PARENT[p.role];
2502
2654
  while (cur && !exists3(cur)) cur = DEFAULT_PARENT[cur];
2503
2655
  return cur ?? rootId;
2504
2656
  };
2505
- const dependency = (p) => p.bone && exists3(p.bone) ? p.bone : resolveParent(p);
2657
+ const dependency = (p) => {
2658
+ if (p.bone) {
2659
+ if (!exists3(p.bone)) throw new Error(`layer "${p.id}": unknown bone "${p.bone}"`);
2660
+ return p.bone;
2661
+ }
2662
+ return resolveParent(p);
2663
+ };
2506
2664
  const pending3 = [...parsed];
2507
2665
  while (pending3.length) {
2508
2666
  const i = pending3.findIndex((p2) => next.bones.some((b) => b.id === dependency(p2)));
@@ -2511,36 +2669,45 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2511
2669
  if (!next.bones.some((b) => b.id === target)) throw new Error(`layer "${p.id}": bone "${target}" is neither in the rig nor in the import`);
2512
2670
  const [bx, by, bw, bh] = p.bbox;
2513
2671
  const joint = p.joint ?? [bx + bw / 2, by + bh / 2];
2514
- const host = worldOf(next, target);
2672
+ const host = evaluate(next, /* @__PURE__ */ new Map()).bones.get(target).world;
2673
+ if (Math.abs(host.a * host.d - host.b * host.c) < 1e-10) throw new Error(`layer "${p.id}": singular host transform`);
2515
2674
  next = { ...next, assets: [...next.assets, RigSchema.shape.assets.element.parse({
2516
2675
  id: p.id,
2517
2676
  src: `${relPrefix}/${p.file}`,
2518
2677
  width: p.w,
2519
2678
  height: p.h,
2520
- origin: { sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2679
+ origin: { bboxSpace: "document", sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2521
2680
  })] };
2522
- const pivot = { x: (joint[0] - bx) / bw, y: (joint[1] - by) / bh };
2681
+ const place = (slotId) => {
2682
+ const slot = next.slots.find((s) => s.id === slotId);
2683
+ const inv = invert(evaluate(next, /* @__PURE__ */ new Map()).bones.get(slot.bone).world);
2684
+ const [x, y] = apply(inv, bx + bw / 2, by + bh / 2);
2685
+ const m = { a: inv.a * bw / p.w, b: inv.b * bw / p.w, c: inv.c * bh / p.h, d: inv.d * bh / p.h, tx: x, ty: y };
2686
+ const sx = Math.hypot(m.a, m.b), sy = Math.hypot(m.c, m.d);
2687
+ if (Math.abs(m.a * m.c + m.b * m.d) > 1e-8 * sx * sy) {
2688
+ const vertices = [[bx, by], [bx + bw, by], [bx + bw, by + bh], [bx, by + bh]].flatMap(([x2, y2]) => apply(inv, x2, y2));
2689
+ const att = Attachment.parse({ id: p.id, slot: slotId, type: "mesh", asset: p.id, vertices, uvs: [0, 0, 1, 0, 1, 1, 0, 1], triangles: [0, 1, 2, 0, 2, 3], hull: 4 });
2690
+ next = { ...next, attachments: [...next.attachments.filter((a) => a.id !== p.id), att] };
2691
+ } else next = setAttachment(next, { id: p.id, slot: slotId, asset: p.id, x: +x.toFixed(12), y: +y.toFixed(12), rotation: Math.atan2(m.b, m.a) * 180 / Math.PI, scaleX: sx, scaleY: (m.a * m.d - m.b * m.c < 0 ? -1 : 1) * sy });
2692
+ };
2523
2693
  if (p.swap || p.bone) {
2524
2694
  const slotId = p.slot ?? p.id;
2525
2695
  if (!next.slots.some((s) => s.id === slotId)) next = setSlot(next, { id: slotId, bone: target, z: p.z, blend: p.blend });
2526
- next = setAttachment(next, { id: p.id, slot: slotId, asset: p.id, ...placementFromPivot(pivot, { x: joint[0] - host.x, y: joint[1] - host.y }, p.w, p.h) });
2696
+ place(slotId);
2527
2697
  if (!p.swap) next = setSlot(next, { id: slotId, attachment: p.id });
2528
2698
  continue;
2529
2699
  }
2530
- if (!next.bones.some((b) => b.id === p.id))
2531
- next = addBone(next, { id: p.id, parent: target, role: p.role, transform: { x: joint[0] - host.x, y: joint[1] - host.y, rotation: 0, scaleX: 1, scaleY: 1 } });
2700
+ if (!next.bones.some((b) => b.id === p.id)) {
2701
+ const [x, y] = apply(invert(host), ...joint);
2702
+ next = addBone(next, { id: p.id, parent: target, role: p.role, transform: { x, y } });
2703
+ }
2532
2704
  next = setSlot(next, { id: p.id, bone: p.id, z: p.z, blend: p.blend });
2533
- next = setAttachment(next, { id: p.id, slot: p.id, asset: p.id, ...placementFromPivot(pivot, {}, p.w, p.h) });
2705
+ place(p.id);
2534
2706
  next = setSlot(next, { id: p.id, attachment: p.id });
2535
2707
  }
2536
2708
  return next;
2537
2709
  }
2538
2710
  var s_bbox = (p) => p.bbox;
2539
- function worldOf(doc, id) {
2540
- const b = doc.bones.find((b2) => b2.id === id);
2541
- const p = b.parent ? worldOf(doc, b.parent) : { x: 0, y: 0 };
2542
- return { x: p.x + b.transform.x, y: p.y + b.transform.y };
2543
- }
2544
2711
 
2545
2712
  // src/rig-presets.ts
2546
2713
  var root = (doc) => doc.bones.find((b) => !b.parent);
@@ -2748,18 +2915,57 @@ function applyPreset(doc, name, params = {}) {
2748
2915
  return { ...doc, animations };
2749
2916
  }
2750
2917
 
2918
+ // src/preview/export-video.ts
2919
+ import { spawn } from "node:child_process";
2920
+ import { once } from "node:events";
2921
+ import { mkdtemp, writeFile as writeFile2, rm as rm2, rename as rename3, mkdir as mkdir2 } from "node:fs/promises";
2922
+ import { dirname as dirname4, join as join5 } from "node:path";
2923
+
2751
2924
  // src/preview/render-preview.ts
2752
2925
  import { chromium } from "playwright";
2753
- import { readFile as readFile2 } from "node:fs/promises";
2754
- import { join as join3, dirname as dirname2 } from "node:path";
2926
+ import { readFile as readFile3 } from "node:fs/promises";
2927
+ import { join as join4, dirname as dirname3 } from "node:path";
2755
2928
  import { fileURLToPath } from "node:url";
2756
- var HERE = dirname2(fileURLToPath(import.meta.url));
2929
+
2930
+ // src/rig-qa.ts
2931
+ import { createHash } from "node:crypto";
2932
+ import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises";
2933
+ import { dirname as dirname2, join as join3 } from "node:path";
2934
+ var sha256 = (data) => createHash("sha256").update(data).digest("hex");
2935
+ async function verifyRenderManifest(rigPath, framesDir, expected) {
2936
+ const m = JSON.parse(await readFile2(join3(framesDir, "manifest.json"), "utf8"));
2937
+ if (m.version !== 1 || !m.frames?.length || !m.times?.length) throw new Error("missing or empty capture manifest");
2938
+ const raw = await readFile2(rigPath, "utf8");
2939
+ if (sha256(raw) !== m.rigFile) throw new Error("stale render: rig file changed");
2940
+ const doc = parse(JSON.parse(raw));
2941
+ if (sha256(JSON.stringify(doc)) !== m.identity.document) throw new Error("stale render: rig changed");
2942
+ if (sha256(await readFile2(expected.renderer)) !== m.identity.renderer) throw new Error("stale render: renderer changed");
2943
+ for (const a of doc.assets) if (sha256(await readFile2(join3(dirname2(rigPath), a.src))) !== m.identity.assets[a.src]) throw new Error(`stale render: texture ${a.src} changed`);
2944
+ for (const [path, hash] of Object.entries(m.identity.masks)) if (sha256(await readFile2(join3(dirname2(rigPath), path))) !== hash) throw new Error(`stale render: fill mask ${path} changed`);
2945
+ if (m.animation !== (expected.animation ?? null) || JSON.stringify(m.times) !== JSON.stringify(expected.times)) throw new Error("capture does not cover the requested animation/times");
2946
+ if (expected.transparent !== void 0 && m.transparent !== expected.transparent) throw new Error("capture has the wrong alpha mode");
2947
+ if (m.frames.length !== expected.times.length || new Set(m.frames.map((f) => f.file)).size !== m.frames.length) throw new Error("capture has missing or duplicate frames");
2948
+ const files2 = (await readdir2(framesDir)).filter((f) => f.endsWith(".png")).sort();
2949
+ if (JSON.stringify(files2) !== JSON.stringify(m.frames.map((f) => f.file).sort())) throw new Error("capture directory has missing or unmanifested frames");
2950
+ for (const f of m.frames) {
2951
+ if (!/^\d{6}\.png$/.test(f.file)) throw new Error("invalid capture filename");
2952
+ if (sha256(await readFile2(join3(framesDir, f.file))) !== f.sha256) throw new Error(`frame ${f.file} changed since capture`);
2953
+ }
2954
+ return m;
2955
+ }
2956
+
2957
+ // src/preview/render-preview.ts
2958
+ import { PNG } from "pngjs";
2959
+ var HERE = dirname3(fileURLToPath(import.meta.url));
2757
2960
  var browser;
2758
2961
  var page;
2759
2962
  var harnessJs;
2760
2963
  var pending2 = Promise.resolve();
2761
2964
  async function getPage() {
2762
- if (page && !page.isClosed()) return page;
2965
+ const latestHarness = await readFile3(join4(HERE, "harness.js"), "utf8");
2966
+ if (page && !page.isClosed() && latestHarness === harnessJs) return page;
2967
+ if (page && !page.isClosed()) await page.close();
2968
+ harnessJs = latestHarness;
2763
2969
  if (browser && !browser.isConnected()) browser = void 0;
2764
2970
  browser ??= await chromium.launch({
2765
2971
  headless: true,
@@ -2769,7 +2975,7 @@ async function getPage() {
2769
2975
  try {
2770
2976
  next.on("pageerror", (e) => console.error("[harness]", e.message));
2771
2977
  await next.setContent(`<!doctype html><html><body style="margin:0;background:transparent"></body></html>`);
2772
- harnessJs ??= await readFile2(join3(HERE, "harness.js"), "utf8");
2978
+ harnessJs ??= await readFile3(join4(HERE, "harness.js"), "utf8");
2773
2979
  await next.addScriptTag({ content: harnessJs });
2774
2980
  page = next;
2775
2981
  return next;
@@ -2800,6 +3006,8 @@ async function render(doc, opts) {
2800
3006
  if (opts.count !== void 0) positive(opts.count, "count", true);
2801
3007
  const anim = opts.animation === void 0 ? void 0 : doc.animations.find((a) => a.id === opts.animation);
2802
3008
  if (opts.animation !== void 0 && !anim) throw new Error(`unknown animation "${opts.animation}"`);
3009
+ if (opts.weightBone && !doc.bones.some((b) => b.id === opts.weightBone)) throw new Error(`unknown weight bone "${opts.weightBone}"`);
3010
+ for (const src of Object.keys(opts.debugFillMasks ?? {})) if (!doc.assets.some((a) => a.src === src)) throw new Error(`fill mask refers to unknown texture "${src}"`);
2803
3011
  if (opts.times && (!opts.times.length || opts.times.some((t) => !Number.isFinite(t) || t < 0)))
2804
3012
  throw new Error("times must contain finite, nonnegative sample times");
2805
3013
  if (opts.region && (opts.region.length !== 4 || opts.region.some((v) => !Number.isFinite(v)) || opts.region[2] <= 0 || opts.region[3] <= 0))
@@ -2816,12 +3024,27 @@ async function render(doc, opts) {
2816
3024
  }
2817
3025
  }
2818
3026
  const assets = {};
3027
+ const identity = { document: sha256(JSON.stringify(doc)), assets: {}, renderer: "", masks: {} };
2819
3028
  for (const a of doc.assets) {
2820
3029
  if (assets[a.src]) continue;
2821
- const buf = await readFile2(join3(opts.assetsDir, a.src));
3030
+ let buf = await readFile3(join4(opts.assetsDir, a.src));
3031
+ identity.assets[a.src] = sha256(buf);
3032
+ if (opts.debugFillMasks?.[a.src]) {
3033
+ const maskData = await readFile3(join4(opts.assetsDir, opts.debugFillMasks[a.src]));
3034
+ identity.masks[opts.debugFillMasks[a.src]] = sha256(maskData);
3035
+ const png = PNG.sync.read(buf), mask = PNG.sync.read(maskData);
3036
+ if (png.width !== mask.width || png.height !== mask.height) throw new Error(`fill mask dimensions differ for ${a.src}`);
3037
+ for (let i = 0; i < png.data.length; i += 4) if (mask.data[i] > 127 && mask.data[i + 3] > 0) {
3038
+ png.data[i] = 255;
3039
+ png.data[i + 1] = 0;
3040
+ png.data[i + 2] = 255;
3041
+ }
3042
+ buf = PNG.sync.write(png);
3043
+ }
2822
3044
  assets[a.src] = `data:${mime(a.src)};base64,${buf.toString("base64")}`;
2823
3045
  }
2824
3046
  const p = await getPage();
3047
+ identity.renderer = sha256(harnessJs);
2825
3048
  const res = await p.evaluate(
2826
3049
  async ({ doc: doc2, assets: assets2, scale, ro }) => {
2827
3050
  const h = window.rigHarness;
@@ -2832,10 +3055,11 @@ async function render(doc, opts) {
2832
3055
  doc,
2833
3056
  assets,
2834
3057
  scale: opts.scale ?? 1,
2835
- ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
3058
+ ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, transparent: opts.transparent, weightBone: opts.weightBone, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
2836
3059
  }
2837
3060
  );
2838
3061
  return {
3062
+ identity,
2839
3063
  frames: res.frames.map(fromDataUrl),
2840
3064
  sheet: fromDataUrl(res.sheet),
2841
3065
  times: times2,
@@ -2844,6 +3068,146 @@ async function render(doc, opts) {
2844
3068
  };
2845
3069
  }
2846
3070
 
3071
+ // src/preview/export-video.ts
3072
+ async function exportVideo(doc, opts) {
3073
+ const anim = doc.animations.find((a) => a.id === opts.animation), fps = opts.fps ?? 60;
3074
+ if (!anim) throw new Error(`unknown animation "${opts.animation}"`);
3075
+ if (!Number.isInteger(fps) || fps < 1 || fps > 240) throw new Error("video fps must be an integer between 1 and 240");
3076
+ const count = Math.max(1, Math.round(anim.duration * fps));
3077
+ if (count > 1e4) throw new Error("video exceeds 10000 frames; export shorter clips");
3078
+ await mkdir2(dirname4(opts.outFile), { recursive: true });
3079
+ const temp = await mkdtemp(join5(dirname4(opts.outFile), ".golem-video-"));
3080
+ try {
3081
+ for (let start = 0; start < count; start += 24) {
3082
+ const times2 = Array.from({ length: Math.min(24, count - start) }, (_, i) => (start + i) / fps);
3083
+ const rendered = await renderPreview(doc, { ...opts, transparent: false, times: times2 });
3084
+ await Promise.all(rendered.frames.map((frame, i) => writeFile2(join5(temp, `${String(start + i).padStart(6, "0")}.png`), frame)));
3085
+ }
3086
+ const output = join5(temp, "preview.mp4");
3087
+ const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-framerate", String(fps), "-i", join5(temp, "%06d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", output], { stdio: ["ignore", "ignore", "pipe"] });
3088
+ let diagnostic = "";
3089
+ proc.stderr.on("data", (chunk) => {
3090
+ diagnostic = (diagnostic + chunk).slice(-8e3);
3091
+ });
3092
+ const [code] = await once(proc, "close");
3093
+ if (code !== 0) throw new Error(`MP4 encoder failed: ${diagnostic}`);
3094
+ await rename3(output, opts.outFile);
3095
+ return { frames: count, fps, duration: count / fps };
3096
+ } finally {
3097
+ await rm2(temp, { recursive: true, force: true });
3098
+ }
3099
+ }
3100
+
3101
+ // src/rig-quality.ts
3102
+ var values = (v) => typeof v === "number" ? [v] : Array.isArray(v) && v.every((n) => typeof n === "number") ? v : void 0;
3103
+ var delta = (a, b) => a.map((v, i) => v - b[i]);
3104
+ var magnitude = (a) => Math.max(0, ...a.map(Math.abs));
3105
+ function motionQuality(doc, anim, opts = {}) {
3106
+ const out = [], D = anim.duration;
3107
+ const report = (code, subject, message, time, level = "warn") => out.push({ level, code, subject, animation: anim.id, time, message });
3108
+ for (const tr of anim.tracks) {
3109
+ const id = `${tr.target}.${tr.id}.${tr.prop}`, looping = anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === D;
3110
+ const at = (t) => sample(tr, t, looping), numeric = tr.keys.every((k) => values(k.v) !== void 0);
3111
+ if (anim.loop) {
3112
+ const first = at(0), last = at(D), av = values(first), bv = values(last);
3113
+ const diff = av && bv && av.length === bv.length ? magnitude(delta(av, bv)) : first === last || JSON.stringify(first) === JSON.stringify(last) ? 0 : Infinity;
3114
+ if (diff > 1e-3 && typeof first !== "number") report("loop_seam", id, `loop endpoint values differ (${Number.isFinite(diff) ? `max delta ${diff.toFixed(4)}` : "discrete state"}); all channels must close`);
3115
+ if (numeric && diff <= 1e-3 && tr.keys.length > 1) {
3116
+ const h = Math.min(D / 1e4, (tr.keys[1].t - tr.keys[0].t) / 100, (tr.keys.at(-1).t - tr.keys.at(-2).t) / 100);
3117
+ const va = delta(values(at(h)), values(at(0))).map((v) => v / h), vb = delta(values(at(D)), values(at(D - h))).map((v) => v / h);
3118
+ const dv = magnitude(delta(va, vb));
3119
+ if (dv > Math.max(0.05, 0.05 * Math.max(magnitude(va), magnitude(vb)))) report("loop_velocity", id, `loop velocity changes by ${dv.toFixed(3)} units/s at the wrap`);
3120
+ }
3121
+ }
3122
+ if (!numeric) continue;
3123
+ for (let i = 1; i < tr.keys.length - 1; i++) {
3124
+ const p = tr.keys[i - 1], k = tr.keys[i], n = tr.keys[i + 1];
3125
+ if (p.ease === "step" || k.ease === "step") continue;
3126
+ const h = Math.min(k.t - p.t, n.t - k.t) / 1e3;
3127
+ const left = delta(values(at(k.t)), values(at(k.t - h))).map((v) => v / h), right = delta(values(at(k.t + h)), values(at(k.t))).map((v) => v / h);
3128
+ const before = delta(values(k.v), values(p.v)), after = delta(values(n.v), values(k.v));
3129
+ const pass = before.some((v, j2) => v * after[j2] > 1e-6);
3130
+ const speed = Math.max(magnitude(before) / (k.t - p.t), magnitude(after) / (n.t - k.t));
3131
+ if (pass && Math.max(magnitude(left), magnitude(right)) < speed * 0.02) report("pass_key_stop", id, `motion stops at a pass-through key; use track interpolation pchip or matched tangents`, k.t);
3132
+ const jump = magnitude(delta(left, right));
3133
+ if (jump > Math.max(0.1, speed * 0.1)) report("velocity_jump", id, `velocity jumps ${jump.toFixed(3)} units/s across this key`, k.t);
3134
+ const acceleration = (a, b, c) => values(at(b)).map((v, j2) => (values(at(c))[j2] - 2 * v + values(at(a))[j2]) / (h * h));
3135
+ const al = acceleration(k.t - 2 * h, k.t - h, k.t), ar = acceleration(k.t, k.t + h, k.t + 2 * h);
3136
+ const aj = magnitude(delta(al, ar));
3137
+ if (aj > Math.max(1, speed / Math.min(k.t - p.t, n.t - k.t) * 2)) report("acceleration_jump", id, `acceleration changes by ${aj.toFixed(2)} units/s\xB2 at this key; PCHIP guarantees velocity, not acceleration continuity`, k.t, "info");
3138
+ }
3139
+ }
3140
+ const steps = Math.max(2, Math.ceil(D * 60));
3141
+ const times2 = new Set(Array.from({ length: steps + 1 }, (_, i) => i * D / steps));
3142
+ for (const tr of anim.tracks) for (const k of tr.keys) {
3143
+ times2.add(k.t);
3144
+ if (k.t > 0) times2.add(Math.max(0, k.t - 1e-5));
3145
+ }
3146
+ const samples = [...times2].sort((a, b) => a - b).map((t) => ({ t, pose: poseOf(anim, t) }));
3147
+ for (const bone of opts.secondaryBones ?? []) {
3148
+ if (!doc.bones.some((b) => b.id === bone)) throw new Error(`unknown secondary bone "${bone}"`);
3149
+ const local = samples.map((s) => evaluate(doc, s.pose).bones.get(bone));
3150
+ const curves = ["rotation", "x", "y"].map((prop) => local.map((b) => b[prop]));
3151
+ if (curves.every((v) => Math.max(...v) - Math.min(...v) < 1e-3)) report("secondary_frozen", bone, `declared secondary bone has no local motion in this animation`);
3152
+ }
3153
+ const reported = /* @__PURE__ */ new Set();
3154
+ for (const [front, back] of opts.frontOf ?? []) {
3155
+ if (![front, back].every((id) => doc.slots.some((s) => s.id === id))) throw new Error(`unknown frontOf slots: ${front}, ${back}`);
3156
+ for (const { t, pose } of samples) {
3157
+ const skel = evaluate(doc, pose);
3158
+ if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3159
+ const visible = (id) => {
3160
+ const slot = doc.slots.find((s) => s.id === id);
3161
+ const color = pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3162
+ return Number(pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3163
+ };
3164
+ if (!visible(front) || !visible(back)) continue;
3165
+ const order = pose.get(pk("drawOrder", "order", "order"));
3166
+ const z4 = (id) => order ? order.indexOf(id) : Number(pose.get(pk("slot", id, "z")) ?? doc.slots.find((s) => s.id === id).z);
3167
+ const pair = JSON.stringify([front, back]);
3168
+ if (z4(front) <= z4(back) && !reported.has(pair)) {
3169
+ reported.add(pair);
3170
+ report("draw_order_rule", front, `"${front}" must stay explicitly in front of "${back}" according to the brief (equal z is ambiguous)`, t);
3171
+ }
3172
+ }
3173
+ }
3174
+ for (const tr of anim.tracks.filter((t) => t.target === "slot" && t.prop === "attachment")) for (const key of tr.keys.filter((k) => k.t > 0)) {
3175
+ const a = evaluate(doc, poseOf(anim, Math.max(0, key.t - 1e-6))), b = evaluate(doc, poseOf(anim, key.t));
3176
+ const before = a.attachment.get(tr.id), after = b.attachment.get(tr.id);
3177
+ if (!before || !after || before.id === after.id) continue;
3178
+ const landmarksA = opts.landmarks?.[before.id], landmarksB = opts.landmarks?.[after.id];
3179
+ if (!landmarksA && !landmarksB) continue;
3180
+ if (!landmarksA || !landmarksB || !Object.keys(landmarksA).some((name) => landmarksB[name])) {
3181
+ report("landmark_unmeasured", tr.id, `swap ${before.id} \u2192 ${after.id} has no shared declared landmarks`, key.t);
3182
+ continue;
3183
+ }
3184
+ const point = (skel, att, uv) => {
3185
+ if (att.type === "region") {
3186
+ const [w, h] = regionSize(doc, att);
3187
+ return apply(regionMatrix(skel, att.slot, att), (uv[0] - 0.5) * w, (uv[1] - 0.5) * h);
3188
+ }
3189
+ if (att.type !== "mesh") return;
3190
+ const v = attachmentWorldVertices(skel, att.slot, att);
3191
+ for (let i = 0; i < att.triangles.length; i += 3) {
3192
+ const [ia, ib, ic] = att.triangles.slice(i, i + 3), ux = att.uvs[ia * 2], uy = att.uvs[ia * 2 + 1], bx = att.uvs[ib * 2] - ux, by = att.uvs[ib * 2 + 1] - uy, cx = att.uvs[ic * 2] - ux, cy = att.uvs[ic * 2 + 1] - uy, det = bx * cy - by * cx;
3193
+ if (Math.abs(det) < 1e-12) continue;
3194
+ const s = ((uv[0] - ux) * cy - (uv[1] - uy) * cx) / det, r = (bx * (uv[1] - uy) - by * (uv[0] - ux)) / det;
3195
+ if (s >= -1e-6 && r >= -1e-6 && s + r <= 1 + 1e-6) return [v[ia * 2] * (1 - s - r) + v[ib * 2] * s + v[ic * 2] * r, v[ia * 2 + 1] * (1 - s - r) + v[ib * 2 + 1] * s + v[ic * 2 + 1] * r];
3196
+ }
3197
+ };
3198
+ for (const [name, uv] of Object.entries(landmarksA)) if (landmarksB[name]) {
3199
+ const p = point(a, before, uv), q = point(b, after, landmarksB[name]);
3200
+ if (!p || !q) {
3201
+ report("landmark_unmeasured", tr.id, `landmark "${name}" is outside attachment geometry`, key.t);
3202
+ continue;
3203
+ }
3204
+ const distance = Math.hypot(p[0] - q[0], p[1] - q[1]);
3205
+ if (distance > (opts.landmarkTolerance ?? 2)) report("attachment_landmark_jump", tr.id, `"${name}" moves ${distance.toFixed(2)}px when ${before.id} changes to ${after.id}`, key.t);
3206
+ }
3207
+ }
3208
+ return out;
3209
+ }
3210
+
2847
3211
  // src/rig-check.ts
2848
3212
  var FOLD_ERROR_RATIO = 0.05;
2849
3213
  var IK_SLACK = 1;
@@ -2852,15 +3216,22 @@ var isNumeric = (t) => t.target !== "slot" || t.prop !== "attachment";
2852
3216
  var trackId3 = (t) => `${t.target}.${t.id}.${t.prop}`;
2853
3217
  var fmt = (n) => String(Math.round(n * 1e3) / 1e3);
2854
3218
  var times = (a, fps) => {
2855
- const n = Math.max(1, Math.round(a.duration * fps)), out = [];
2856
- for (let i = 0; i <= n; i++) out.push(Math.min(a.duration, i / fps));
3219
+ const n = Math.max(1, Math.ceil(a.duration * fps)), out = [];
3220
+ for (let i = 0; i <= n; i++) out.push(a.duration * i / n);
2857
3221
  return out;
2858
3222
  };
2859
3223
  function checkRig(doc, opts = {}) {
2860
3224
  const fps = opts.fps ?? 30, dfps = opts.deformFps ?? 60, overlapMin = opts.overlap ?? 0.6, margin = opts.margin ?? 1;
3225
+ if (![fps, dfps].every((v) => Number.isFinite(v) && v > 0)) throw new Error("check sampling rates must be positive");
2861
3226
  const anims = opts.animation === void 0 ? doc.animations : doc.animations.filter((a) => a.id === opts.animation);
2862
3227
  if (opts.animation !== void 0 && !anims.length) throw new Error(`unknown animation "${opts.animation}"`);
2863
3228
  const out = [];
3229
+ for (const e of constraintDependencies(doc).edges.filter((e2) => e2.late)) out.push({
3230
+ level: "warn",
3231
+ code: "constraint_order",
3232
+ subject: e.reader,
3233
+ message: `constraint "${e.reader}" reads bone "${e.bone}" before later constraint "${e.writer}" moves it; use rig_order_constraints or set explicit order`
3234
+ });
2864
3235
  const W = doc.meta.width, H = doc.meta.height;
2865
3236
  const overflowOf = (pose, slotId, skel) => {
2866
3237
  const pts = slotOutline(doc, pose, slotId, 4, skel);
@@ -3032,6 +3403,7 @@ function checkRig(doc, opts = {}) {
3032
3403
  return rest;
3033
3404
  };
3034
3405
  for (const a of anims) {
3406
+ out.push(...motionQuality(doc, a, opts));
3035
3407
  for (const t of a.tracks) {
3036
3408
  if (!isNumeric(t)) continue;
3037
3409
  const vals = t.keys.map((k) => k.v);
@@ -3139,7 +3511,9 @@ function checkRig(doc, opts = {}) {
3139
3511
  if (!sorted.length) continue;
3140
3512
  const typical = sorted[Math.floor(sorted.length / 2)];
3141
3513
  const worst2 = ds.reduce((m, x) => x.d > m.d ? x : m, ds[0]);
3142
- if (worst2.d > Math.max(6, 5 * typical)) {
3514
+ const index = ds.indexOf(worst2);
3515
+ const neighbours = Math.max(ds[index - 1]?.d ?? 0, ds[index + 1]?.d ?? 0);
3516
+ if (worst2.d > Math.max(6, 5 * typical, 3 * neighbours)) {
3143
3517
  const stepped = stepTimes.some((st) => st > worst2.t - 1 / dfps - 1e-9 && st <= worst2.t + 1e-9);
3144
3518
  out.push({
3145
3519
  level: stepped ? "info" : "warn",
@@ -3190,7 +3564,7 @@ function checkRig(doc, opts = {}) {
3190
3564
  for (const att of doc.attachments) {
3191
3565
  if (att.type !== "region") continue;
3192
3566
  const asset = doc.assets.find((a) => a.id === att.asset), bbox = asset?.origin?.bbox;
3193
- if (!asset || !bbox || !doc.slots.some((s) => s.id === att.slot)) continue;
3567
+ if (!asset || !bbox || asset.origin?.bboxSpace === "source" || !doc.slots.some((s) => s.id === att.slot)) continue;
3194
3568
  const [cx, cy] = apply(regionMatrix(restWorld, att.slot, att), 0, 0);
3195
3569
  const ex = bbox[0] + bbox[2] / 2, ey = bbox[1] + bbox[3] / 2, dx = cx - ex, dy = cy - ey;
3196
3570
  const dist = Math.hypot(dx, dy);
@@ -3235,7 +3609,8 @@ function checkRig(doc, opts = {}) {
3235
3609
  for (const att of doc.attachments) if (att.type !== "path" && shownAttachments.has(att.id)) used.add(att.asset);
3236
3610
  for (const asset of doc.assets) if (!used.has(asset.id))
3237
3611
  out.push({ level: "info", code: "unused_asset", subject: asset.id, message: `asset "${asset.id}" is not shown by any attachment at rest or in any animation` });
3238
- for (const s of doc.slots) if (!s.attachment && !swappedIn.has(s.id))
3612
+ const maskSlots = new Set(doc.slots.flatMap((s) => s.clip ? [doc.attachments.find((a) => a.id === s.clip)?.slot] : []));
3613
+ for (const s of doc.slots) if (!s.attachment && !swappedIn.has(s.id) && !maskSlots.has(s.id))
3239
3614
  out.push({ level: "warn", code: "never_visible", subject: s.id, message: `slot "${s.id}" has no attachment and no animation ever shows one` });
3240
3615
  return out;
3241
3616
  }
@@ -3263,14 +3638,14 @@ function summarize(findings, max = 12) {
3263
3638
  }
3264
3639
 
3265
3640
  // src/rig-history.ts
3266
- import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, writeFile as writeFile2, unlink } from "node:fs/promises";
3267
- import { basename as basename3, dirname as dirname3, join as join4 } from "node:path";
3641
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile4, writeFile as writeFile3, unlink } from "node:fs/promises";
3642
+ import { basename as basename3, dirname as dirname5, join as join6 } from "node:path";
3268
3643
  function historyDir(rigPath) {
3269
- return join4(dirname3(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3644
+ return join6(dirname5(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3270
3645
  }
3271
3646
  async function files(dir) {
3272
3647
  try {
3273
- return (await readdir2(dir)).filter((f) => /^\d{4,}\.json$/.test(f)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
3648
+ return (await readdir3(dir)).filter((f) => /^\d{4,}\.json$/.test(f)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
3274
3649
  } catch {
3275
3650
  return [];
3276
3651
  }
@@ -3279,22 +3654,22 @@ async function snapshots(rigPath) {
3279
3654
  return files(historyDir(rigPath));
3280
3655
  }
3281
3656
  async function listHistory(rigPath) {
3282
- return (await snapshots(rigPath)).map((f) => join4(historyDir(rigPath), f));
3657
+ return (await snapshots(rigPath)).map((f) => join6(historyDir(rigPath), f));
3283
3658
  }
3284
- var redoDir = (rigPath) => join4(historyDir(rigPath), "redo");
3659
+ var redoDir = (rigPath) => join6(historyDir(rigPath), "redo");
3285
3660
  async function listRedo(rigPath) {
3286
- return (await files(redoDir(rigPath))).map((f) => join4(redoDir(rigPath), f));
3661
+ return (await files(redoDir(rigPath))).map((f) => join6(redoDir(rigPath), f));
3287
3662
  }
3288
3663
  async function clearRedo(rigPath) {
3289
3664
  for (const f of await listRedo(rigPath)) await unlink(f);
3290
3665
  }
3291
3666
  async function pushInto(dir, contents, keep) {
3292
- await mkdir2(dir, { recursive: true });
3667
+ await mkdir3(dir, { recursive: true });
3293
3668
  const fs = await files(dir);
3294
3669
  const next = fs.length ? +fs[fs.length - 1].slice(0, -5) + 1 : 1;
3295
- const file = join4(dir, `${String(next).padStart(4, "0")}.json`);
3296
- await writeFile2(file, contents);
3297
- for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join4(dir, f));
3670
+ const file = join6(dir, `${String(next).padStart(4, "0")}.json`);
3671
+ await writeFile3(file, contents);
3672
+ for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join6(dir, f));
3298
3673
  return file;
3299
3674
  }
3300
3675
  async function pushHistory(rigPath, contents, keep = 50, opts = {}) {
@@ -3307,12 +3682,12 @@ async function popHistory(rigPath, steps = 1) {
3307
3682
  if (!Number.isInteger(steps) || steps < 1 || steps > files2.length)
3308
3683
  throw new Error(`only ${files2.length} snapshot${files2.length === 1 ? "" : "s"} available, cannot undo ${steps}`);
3309
3684
  const dir = historyDir(rigPath);
3310
- const contents = await readFile3(join4(dir, files2[files2.length - steps]), "utf8");
3311
- for (const f of files2.slice(files2.length - steps)) await unlink(join4(dir, f));
3685
+ const contents = await readFile4(join6(dir, files2[files2.length - steps]), "utf8");
3686
+ for (const f of files2.slice(files2.length - steps)) await unlink(join6(dir, f));
3312
3687
  return contents;
3313
3688
  }
3314
3689
  async function undo(rigPath, steps = 1) {
3315
- const current = await readFile3(rigPath, "utf8");
3690
+ const current = await readFile4(rigPath, "utf8");
3316
3691
  const restored = await popHistory(rigPath, steps);
3317
3692
  await pushInto(redoDir(rigPath), current, 50);
3318
3693
  return restored;
@@ -3321,8 +3696,8 @@ async function redo(rigPath) {
3321
3696
  const entries2 = await listRedo(rigPath);
3322
3697
  if (!entries2.length) throw new Error("nothing to redo");
3323
3698
  const file = entries2[entries2.length - 1];
3324
- const contents = await readFile3(file, "utf8");
3325
- await pushHistory(rigPath, await readFile3(rigPath, "utf8"), 50, { keepRedo: true });
3699
+ const contents = await readFile4(file, "utf8");
3700
+ await pushHistory(rigPath, await readFile4(rigPath, "utf8"), 50, { keepRedo: true });
3326
3701
  await unlink(file);
3327
3702
  return contents;
3328
3703
  }
@@ -3416,9 +3791,9 @@ function diffDocs(a, b) {
3416
3791
  }
3417
3792
 
3418
3793
  // src/rig-atlas.ts
3419
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
3420
- import { join as join5 } from "node:path";
3421
- import { PNG } from "pngjs";
3794
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
3795
+ import { join as join7 } from "node:path";
3796
+ import { PNG as PNG2 } from "pngjs";
3422
3797
  function packRects(rects, opts = {}) {
3423
3798
  const extrude = opts.extrude ?? 1, pad = Math.max(opts.padding ?? 2, 2 * extrude), max = opts.maxSize ?? 4096;
3424
3799
  const sorted = [...rects].sort((a, b) => b.height - a.height || b.width - a.width || a.id.localeCompare(b.id));
@@ -3476,10 +3851,10 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3476
3851
  for (const a of doc.assets) {
3477
3852
  if (!/\.png$/i.test(a.src)) throw new Error(`atlas requires PNG assets: "${a.src}" (asset "${a.id}") is not a PNG`);
3478
3853
  let png = files2.get(a.src);
3479
- if (!png) files2.set(a.src, png = PNG.sync.read(await readFile4(join5(assetsDir, a.src))));
3854
+ if (!png) files2.set(a.src, png = PNG2.sync.read(await readFile5(join7(assetsDir, a.src))));
3480
3855
  if (a.frame) {
3481
- const [fx, fy, fw, fh] = a.frame, sub = new PNG({ width: fw, height: fh });
3482
- PNG.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
3856
+ const [fx, fy, fw, fh] = a.frame, sub = new PNG2({ width: fw, height: fh });
3857
+ PNG2.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
3483
3858
  png = sub;
3484
3859
  }
3485
3860
  if (png.width !== a.width || png.height !== a.height)
@@ -3487,26 +3862,26 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3487
3862
  images.set(a.id, png);
3488
3863
  }
3489
3864
  const packed = packRects(doc.assets.map((a) => ({ id: a.id, width: a.width, height: a.height })), opts);
3490
- const sheet = new PNG({ width: packed.width, height: packed.height });
3865
+ const sheet = new PNG2({ width: packed.width, height: packed.height });
3491
3866
  for (const [id, [x, y, w, h]] of packed.frames) {
3492
- PNG.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
3867
+ PNG2.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
3493
3868
  extrudeFrame(sheet, x, y, w, h, opts.extrude ?? 1);
3494
3869
  }
3495
- await mkdir3(outDir, { recursive: true });
3496
- await writeFile3(join5(outDir, "atlas.png"), PNG.sync.write(sheet));
3870
+ await mkdir4(outDir, { recursive: true });
3871
+ await writeFile4(join7(outDir, "atlas.png"), PNG2.sync.write(sheet));
3497
3872
  const out = { ...doc, assets: doc.assets.map((a) => ({ ...a, src: "atlas.png", frame: packed.frames.get(a.id) })) };
3498
- await saveJson(join5(outDir, "rig.json"), out);
3873
+ await saveJson(join7(outDir, "rig.json"), out);
3499
3874
  return out;
3500
3875
  }
3501
3876
 
3502
3877
  // src/spine-import.ts
3503
- import { readFile as readFile6, stat, writeFile as writeFile4 } from "node:fs/promises";
3504
- import { basename as basename4, dirname as dirname4, extname, join as join6, relative } from "node:path";
3878
+ import { readFile as readFile7, stat, writeFile as writeFile5 } from "node:fs/promises";
3879
+ import { basename as basename4, dirname as dirname6, extname, join as join8, relative } from "node:path";
3505
3880
 
3506
3881
  // src/ktx2.ts
3507
- import { readFile as readFile5 } from "node:fs/promises";
3882
+ import { readFile as readFile6 } from "node:fs/promises";
3508
3883
  import { createRequire } from "node:module";
3509
- import { PNG as PNG2 } from "pngjs";
3884
+ import { PNG as PNG3 } from "pngjs";
3510
3885
  var require2 = createRequire(import.meta.url);
3511
3886
  var basisPromise;
3512
3887
  async function basis2() {
@@ -3518,8 +3893,8 @@ async function basis2() {
3518
3893
  } catch {
3519
3894
  throw new Error("KTX2 pages need the basis transcoder from three.js: `npm i -D three`, or convert the page to PNG yourself");
3520
3895
  }
3521
- const wasm = await readFile5(jsPath.replace(/\.js$/, ".wasm"));
3522
- const src = await readFile5(jsPath, "utf8");
3896
+ const wasm = await readFile6(jsPath.replace(/\.js$/, ".wasm"));
3897
+ const src = await readFile6(jsPath, "utf8");
3523
3898
  const mod = { exports: {} };
3524
3899
  new Function("require", "module", "exports", "__dirname", "__filename", src)(require2, mod, mod.exports, jsPath.replace(/\/[^/]+$/, ""), jsPath);
3525
3900
  const Basis = await mod.exports({ wasmBinary: wasm });
@@ -3546,9 +3921,9 @@ async function ktx2ToRgba(data) {
3546
3921
  }
3547
3922
  async function ktx2ToPng(data) {
3548
3923
  const { rgba, ...info } = await ktx2ToRgba(data);
3549
- const png = new PNG2({ width: info.width, height: info.height });
3924
+ const png = new PNG3({ width: info.width, height: info.height });
3550
3925
  png.data = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
3551
- return { ...info, png: PNG2.sync.write(png) };
3926
+ return { ...info, png: PNG3.sync.write(png) };
3552
3927
  }
3553
3928
 
3554
3929
  // src/spine-import.ts
@@ -3688,9 +4063,9 @@ function importSpine(json, atlasText, opts = {}) {
3688
4063
  };
3689
4064
  const sequences = /* @__PURE__ */ new Map();
3690
4065
  const skinnedName = /* @__PURE__ */ new Map();
3691
- for (const skin of skins) {
3692
- const prefix = skin.name === "default" ? "" : `${skin.name}_`;
3693
- for (const [slotName, atts] of Object.entries(skin.attachments ?? {}))
4066
+ for (const skin2 of skins) {
4067
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4068
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
3694
4069
  for (const [name, a] of Object.entries(atts)) {
3695
4070
  const key = `${slotName} ${name}`;
3696
4071
  const seq = a.sequence;
@@ -3755,11 +4130,11 @@ function importSpine(json, atlasText, opts = {}) {
3755
4130
  }
3756
4131
  return { weights };
3757
4132
  };
3758
- for (const skin of skins) {
3759
- const prefix = skin.name === "default" ? "" : `${skin.name}_`;
3760
- for (const [slotName, atts] of Object.entries(skin.attachments ?? {})) {
4133
+ for (const skin2 of skins) {
4134
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4135
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
3761
4136
  if (!slotIndexByName.has(slotName)) {
3762
- warnings.push(`skin "${skin.name}": unknown slot "${slotName}"`);
4137
+ warnings.push(`skin "${skin2.name}": unknown slot "${slotName}"`);
3763
4138
  continue;
3764
4139
  }
3765
4140
  const slot = slotIds.get(slotName);
@@ -4090,9 +4465,9 @@ function importSpine(json, atlasText, opts = {}) {
4090
4465
  const attFrames = attachmentFrames.get(slotName);
4091
4466
  const setupName = spineSlots.find((x) => x.name === slotName)?.attachment ?? null;
4092
4467
  const at = (xs, t) => {
4093
- let hit;
4094
- for (const x of xs) if (x.t <= t + 1e-9) hit = x;
4095
- return hit;
4468
+ let hit2;
4469
+ for (const x of xs) if (x.t <= t + 1e-9) hit2 = x;
4470
+ return hit2;
4096
4471
  };
4097
4472
  const shownAt = (t) => {
4098
4473
  let name = setupName;
@@ -4198,21 +4573,21 @@ async function firstExisting(candidates, what) {
4198
4573
  throw new Error(`${what} not found, tried: ${candidates.join(", ")}`);
4199
4574
  }
4200
4575
  async function importSpineProject(opts) {
4201
- const json = JSON.parse(await readFile6(opts.json, "utf8"));
4576
+ const json = JSON.parse(await readFile7(opts.json, "utf8"));
4202
4577
  const stem = opts.json.replace(/\.json$/i, "");
4203
4578
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4204
- const atlasText = await readFile6(atlasPath, "utf8");
4579
+ const atlasText = await readFile7(atlasPath, "utf8");
4205
4580
  const { pages } = parseAtlas(atlasText);
4206
4581
  if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
4207
- const pageStem = join6(dirname4(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4208
- const imagePath = opts.image ?? await firstExisting([join6(dirname4(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4209
- const rigDir = dirname4(opts.rigPath);
4582
+ const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4583
+ const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4584
+ const rigDir = dirname6(opts.rigPath);
4210
4585
  let pngPath = imagePath;
4211
4586
  if (extname(imagePath).toLowerCase() === ".ktx2") {
4212
- pngPath = join6(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4587
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4213
4588
  if (!await exists(pngPath)) {
4214
- const { png } = await ktx2ToPng(new Uint8Array(await readFile6(imagePath)));
4215
- await writeFile4(pngPath, png);
4589
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4590
+ await writeFile5(pngPath, png);
4216
4591
  }
4217
4592
  }
4218
4593
  const pageImage = relative(rigDir, pngPath).split("\\").join("/");
@@ -4332,11 +4707,11 @@ function describeTemplate(t) {
4332
4707
  }
4333
4708
 
4334
4709
  // src/rig-template-library.ts
4335
- import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir4 } from "node:fs/promises";
4336
- import { dirname as dirname5 } from "node:path";
4710
+ import { readFile as readFile8, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
4711
+ import { dirname as dirname7 } from "node:path";
4337
4712
  async function loadLibrary(path) {
4338
4713
  try {
4339
- return JSON.parse(await readFile7(path, "utf8"));
4714
+ return JSON.parse(await readFile8(path, "utf8"));
4340
4715
  } catch (e) {
4341
4716
  if (e.code === "ENOENT") return [];
4342
4717
  throw e;
@@ -4347,14 +4722,14 @@ async function saveTemplate(path, tpl) {
4347
4722
  const i = all.findIndex((t) => t.name === tpl.name);
4348
4723
  if (i >= 0) all[i] = tpl;
4349
4724
  else all.push(tpl);
4350
- await mkdir4(dirname5(path), { recursive: true });
4351
- await writeFile5(path, JSON.stringify(all, null, 2));
4725
+ await mkdir5(dirname7(path), { recursive: true });
4726
+ await writeFile6(path, JSON.stringify(all, null, 2));
4352
4727
  }
4353
4728
 
4354
4729
  // src/rig-mesh-image.ts
4355
- import { PNG as PNG3 } from "pngjs";
4730
+ import { PNG as PNG4 } from "pngjs";
4356
4731
  function alphaMaskFromPng(buf, frame) {
4357
- const png = PNG3.sync.read(buf);
4732
+ const png = PNG4.sync.read(buf);
4358
4733
  const [fx, fy, w, h] = frame ?? [0, 0, png.width, png.height];
4359
4734
  if (fx < 0 || fy < 0 || w < 1 || h < 1 || fx + w > png.width || fy + h > png.height) {
4360
4735
  throw new Error(`frame [${fx}, ${fy}, ${w}, ${h}] is outside the ${png.width}x${png.height} image`);
@@ -4364,11 +4739,479 @@ function alphaMaskFromPng(buf, frame) {
4364
4739
  return { width: w, height: h, alpha };
4365
4740
  }
4366
4741
 
4742
+ // src/rig-gates.ts
4743
+ import { z as z2 } from "zod";
4744
+
4745
+ // src/rig-state.ts
4746
+ var AnimationState = class {
4747
+ constructor(sink, opts = {}) {
4748
+ this.sink = sink;
4749
+ this.opts = opts;
4750
+ this.defaultMix = opts.mix ?? 0;
4751
+ }
4752
+ onEvent;
4753
+ base;
4754
+ main;
4755
+ queue = [];
4756
+ overlays = /* @__PURE__ */ new Map();
4757
+ /** last composed pose without overlays — mix snapshots start here */
4758
+ pose = /* @__PURE__ */ new Map();
4759
+ from;
4760
+ mixLeft = 0;
4761
+ mixTotal = 0;
4762
+ /** a mix begun in this update shows weight 0 for the frame it starts on */
4763
+ mixFresh = false;
4764
+ defaultMix;
4765
+ /** id of the animation currently shown (main, else base) */
4766
+ get current() {
4767
+ return (this.main ?? this.base)?.anim.id;
4768
+ }
4769
+ get baseId() {
4770
+ return this.base?.anim.id;
4771
+ }
4772
+ channel(id, loop, priority = 0) {
4773
+ const anim = this.sink.doc.animations.find((a) => a.id === id);
4774
+ if (!anim) {
4775
+ const message = `unknown animation "${id}"; available: ${this.sink.doc.animations.map((a) => a.id).join(", ")}`;
4776
+ if (this.opts.missingAnimation === "throw") throw new Error(message);
4777
+ (this.opts.onWarning ?? console.warn)(message);
4778
+ return void 0;
4779
+ }
4780
+ return { anim, time: 0, loop: loop ?? anim.loop, done: false, priority, fresh: true };
4781
+ }
4782
+ setBase(id, opts = {}) {
4783
+ const next = id === void 0 ? void 0 : this.channel(id);
4784
+ if (id !== void 0 && !next) return;
4785
+ this.base = next;
4786
+ if (!this.main || this.main.done) {
4787
+ this.main = void 0;
4788
+ this.beginMix(opts.mix);
4789
+ this.update(0);
4790
+ }
4791
+ }
4792
+ play(id, opts = {}) {
4793
+ const ch = this.channel(id, opts.loop, opts.priority ?? 0);
4794
+ if (!ch) {
4795
+ this.main?.settle?.("interrupted");
4796
+ for (const queued of this.queue) queued.settle?.("dropped");
4797
+ this.main = void 0;
4798
+ this.queue = [];
4799
+ this.beginMix(opts.mix);
4800
+ this.update(0);
4801
+ opts.onComplete?.("dropped");
4802
+ return Promise.resolve("dropped");
4803
+ }
4804
+ const promise = new Promise((resolve3) => {
4805
+ ch.settle = (r) => {
4806
+ ch.settle = void 0;
4807
+ opts.onComplete?.(r);
4808
+ resolve3(r);
4809
+ };
4810
+ });
4811
+ const cur = this.main, busy = cur !== void 0 && !cur.done, mode = opts.ifBusy ?? "queue";
4812
+ if (!busy || mode === "interrupt" || ch.priority >= cur.priority) {
4813
+ if (busy) cur.settle?.("interrupted");
4814
+ this.main = ch;
4815
+ this.beginMix(opts.mix);
4816
+ this.update(0);
4817
+ } else if (mode === "drop") ch.settle("dropped");
4818
+ else this.queue.push(ch);
4819
+ return promise;
4820
+ }
4821
+ /** Independent loop on top of the shown pose; `undefined` removes it. */
4822
+ setOverlay(name, id) {
4823
+ if (id === void 0) this.overlays.delete(name);
4824
+ else {
4825
+ const ch = this.channel(id);
4826
+ if (ch) this.overlays.set(name, ch);
4827
+ }
4828
+ }
4829
+ /** Clear everything and show the setup pose. Pending plays resolve as "stopped". */
4830
+ stop() {
4831
+ this.main?.settle?.("stopped");
4832
+ for (const ch of this.queue) ch.settle?.("stopped");
4833
+ this.main = this.base = void 0;
4834
+ this.queue = [];
4835
+ this.overlays.clear();
4836
+ this.from = void 0;
4837
+ this.mixLeft = this.mixTotal = 0;
4838
+ this.mixFresh = false;
4839
+ this.pose = /* @__PURE__ */ new Map();
4840
+ this.sink.setPose(/* @__PURE__ */ new Map());
4841
+ }
4842
+ update(dt) {
4843
+ if (this.base) this.advance(this.base, dt, this.main === void 0);
4844
+ if (this.main) {
4845
+ const wasDone = this.main.done;
4846
+ this.advance(this.main, dt, true);
4847
+ if (this.main.done && !wasDone) {
4848
+ this.main.settle?.("completed");
4849
+ const next = this.queue.shift();
4850
+ if (next || this.base) {
4851
+ this.main = next;
4852
+ this.beginMix();
4853
+ }
4854
+ }
4855
+ }
4856
+ const shown = this.main ?? this.base;
4857
+ let pose = shown ? poseOf(shown.anim, shown.time) : /* @__PURE__ */ new Map();
4858
+ if (this.from) {
4859
+ if (!this.mixFresh) this.mixLeft = Math.max(0, this.mixLeft - dt);
4860
+ this.mixFresh = false;
4861
+ const w = this.mixTotal > 0 ? 1 - this.mixLeft / this.mixTotal : 1;
4862
+ pose = blendPoses(this.from, pose, w, (k) => restValue(this.sink.doc, k));
4863
+ if (this.mixLeft === 0) this.from = void 0;
4864
+ }
4865
+ this.pose = pose;
4866
+ if (this.overlays.size) {
4867
+ pose = new Map(pose);
4868
+ for (const [name, ov] of this.overlays) {
4869
+ this.advance(ov, dt, false);
4870
+ poseOf(ov.anim, ov.time, pose);
4871
+ if (ov.done) this.overlays.delete(name);
4872
+ }
4873
+ }
4874
+ this.sink.setPose(pose);
4875
+ }
4876
+ beginMix(mix = this.defaultMix) {
4877
+ if (mix > 0) {
4878
+ this.from = new Map(this.pose);
4879
+ this.mixLeft = this.mixTotal = mix;
4880
+ this.mixFresh = true;
4881
+ } else {
4882
+ this.from = void 0;
4883
+ this.mixLeft = this.mixTotal = 0;
4884
+ this.mixFresh = false;
4885
+ }
4886
+ }
4887
+ advance(ch, dt, emit) {
4888
+ if (ch.done) return;
4889
+ const prev = ch.fresh ? -1 : ch.time, d = ch.anim.duration;
4890
+ ch.fresh = false;
4891
+ ch.time += dt;
4892
+ if (emit) {
4893
+ for (const e of ch.anim.events) if (hit(e.t, prev, ch.time, d, ch.loop)) this.onEvent?.(e.name, e.payload);
4894
+ }
4895
+ if (ch.time >= d) {
4896
+ if (ch.loop) ch.time %= d;
4897
+ else {
4898
+ ch.time = d;
4899
+ ch.done = true;
4900
+ }
4901
+ }
4902
+ }
4903
+ };
4904
+ function hit(t, prev, cur, d, loop) {
4905
+ if (cur < d || !loop) return t > prev && t <= cur;
4906
+ return t > prev && t <= d || t <= cur % d;
4907
+ }
4908
+
4909
+ // src/rig-gates.ts
4910
+ var Limit = z2.number().nonnegative().optional();
4911
+ var Window = z2.tuple([z2.number().nonnegative(), z2.number().nonnegative()]).optional();
4912
+ var GateConfig = z2.object({
4913
+ animation: z2.string(),
4914
+ fps: z2.number().int().min(1).max(240).default(60),
4915
+ requiredAnimations: z2.array(z2.string()).default([]),
4916
+ motion: z2.array(z2.object({ bone: z2.string(), channel: z2.enum(["position", "rotation"]).default("position"), space: z2.enum(["world", "local"]).default("world"), minAmplitude: Limit, maxSpeed: Limit, maxAcceleration: Limit, maxJerk: Limit, maxStep: Limit, maxSetupDrift: Limit, during: Window })).default([]),
4917
+ distances: z2.array(z2.object({ a: z2.string(), b: z2.string(), min: Limit, max: Limit, minRatio: Limit, maxRatio: Limit, during: Window })).default([]),
4918
+ conditions: z2.array(z2.object({ whenSlotVisible: z2.string(), bone: z2.string(), prop: z2.enum(["x", "y", "rotation"]), space: z2.enum(["world", "local"]).default("world"), min: z2.number().optional(), max: z2.number().optional(), required: z2.boolean().default(true) })).default([]),
4919
+ transitions: z2.array(z2.object({ from: z2.string(), to: z2.string(), fromTime: z2.number().nonnegative().default(0), mix: z2.number().nonnegative().default(0.18), maxStep: z2.number().nonnegative(), duration: z2.number().positive().default(0.5) })).default([])
4920
+ }).strict();
4921
+ var norm = (v) => Math.hypot(...v);
4922
+ var subtract = (a, b) => a.map((v, i) => v - b[i]);
4923
+ var wrap = (v) => ((v + 180) % 360 + 360) % 360 - 180;
4924
+ function runGates(doc, config) {
4925
+ const cfg = GateConfig.parse(config), animation = doc.animations.find((a) => a.id === cfg.animation);
4926
+ if (!animation) throw new Error(`unknown animation "${cfg.animation}"`);
4927
+ if (!cfg.requiredAnimations.length && !cfg.motion.length && !cfg.distances.length && !cfg.conditions.length && !cfg.transitions.length) throw new Error("gate config needs at least one explicit rule");
4928
+ const failures = [];
4929
+ const metrics = {};
4930
+ for (const id of cfg.requiredAnimations) if (!doc.animations.some((a) => a.id === id)) failures.push(`required animation missing: ${id}`);
4931
+ const n = Math.max(1, Math.ceil(animation.duration * cfg.fps)), dt = animation.duration / n;
4932
+ const frames = Array.from({ length: n + 1 }, (_, i) => {
4933
+ const t = i * dt, pose = poseOf(animation, t);
4934
+ return { t, pose, skel: evaluate(doc, pose) };
4935
+ });
4936
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
4937
+ const bone = (s, id, channel, space) => {
4938
+ const b = s.bones.get(id);
4939
+ if (!b) throw new Error(`unknown gate bone "${id}"`);
4940
+ return channel === "rotation" ? [space === "local" ? b.rotation : Math.atan2(b.world.b, b.world.a) * 180 / Math.PI] : space === "local" ? [b.x, b.y] : [b.world.tx, b.world.ty];
4941
+ };
4942
+ const inside = (t, w) => !w || t >= w[0] - 1e-9 && t <= w[1] + 1e-9;
4943
+ const windowCheck = (w) => {
4944
+ if (w && (w[0] > w[1] || w[1] > animation.duration)) throw new Error("invalid gate time window");
4945
+ };
4946
+ for (const rule of cfg.motion) {
4947
+ if ([rule.minAmplitude, rule.maxSpeed, rule.maxAcceleration, rule.maxJerk, rule.maxStep, rule.maxSetupDrift].every((v) => v === void 0)) throw new Error("motion gate needs an explicit threshold");
4948
+ windowCheck(rule.during);
4949
+ const fs = frames.filter((f) => inside(f.t, rule.during));
4950
+ if (fs.length < 4) throw new Error(`motion gate ${rule.bone} needs at least four samples`);
4951
+ const vs = fs.map((f) => bone(f.skel, rule.bone, rule.channel, rule.space));
4952
+ if (rule.channel === "rotation") for (let i = 1; i < vs.length; i++) vs[i][0] = vs[i - 1][0] + wrap(vs[i][0] - vs[i - 1][0]);
4953
+ const derivative = (xs) => xs.slice(1).map((v, i) => subtract(v, xs[i]).map((v2) => v2 / dt));
4954
+ const velocity = derivative(vs), acceleration = derivative(velocity), jerk = derivative(acceleration);
4955
+ const range = vs[0].map((_, i) => Math.max(...vs.map((v) => v[i])) - Math.min(...vs.map((v) => v[i])));
4956
+ const setup = bone(rest, rule.bone, rule.channel, rule.space);
4957
+ const m = { samples: vs.length, amplitude: norm(range), speed: Math.max(...velocity.map(norm)), acceleration: Math.max(...acceleration.map(norm)), jerk: Math.max(...jerk.map(norm)), step: Math.max(...velocity.map(norm)) * dt, setupDrift: Math.max(...vs.map((v) => norm(subtract(v, setup)))) };
4958
+ const id = `${rule.bone}.${rule.channel}.${rule.space}`;
4959
+ metrics[id] = m;
4960
+ if (rule.minAmplitude !== void 0 && m.amplitude < rule.minAmplitude) failures.push(`${id}: amplitude ${m.amplitude} < ${rule.minAmplitude}`);
4961
+ for (const [limit, key] of [["maxSpeed", "speed"], ["maxAcceleration", "acceleration"], ["maxJerk", "jerk"], ["maxStep", "step"], ["maxSetupDrift", "setupDrift"]])
4962
+ if (rule[limit] !== void 0 && m[key] > rule[limit]) failures.push(`${id}: ${key} ${m[key]} > ${rule[limit]}`);
4963
+ }
4964
+ for (const rule of cfg.distances) {
4965
+ if ([rule.min, rule.max, rule.minRatio, rule.maxRatio].every((v) => v === void 0)) throw new Error("distance gate needs an explicit threshold");
4966
+ windowCheck(rule.during);
4967
+ const fs = frames.filter((f) => inside(f.t, rule.during));
4968
+ if (!fs.length) throw new Error("distance gate has no samples");
4969
+ const distance = (s) => norm(subtract(bone(s, rule.a, "position", "world"), bone(s, rule.b, "position", "world")));
4970
+ const setup = distance(rest), values2 = fs.map((f) => distance(f.skel)), lo = Math.min(...values2), hi = Math.max(...values2);
4971
+ metrics[`${rule.a}-${rule.b}`] = { samples: fs.length, min: lo, max: hi, setup };
4972
+ if ((rule.minRatio !== void 0 || rule.maxRatio !== void 0) && setup < 1e-9) throw new Error("distance ratio needs noncoincident setup landmarks");
4973
+ if (rule.min !== void 0 && lo < rule.min || rule.max !== void 0 && hi > rule.max || rule.minRatio !== void 0 && lo < setup * rule.minRatio || rule.maxRatio !== void 0 && hi > setup * rule.maxRatio) failures.push(`distance ${rule.a}-${rule.b} outside allowed range`);
4974
+ }
4975
+ for (const rule of cfg.conditions) {
4976
+ if (rule.min === void 0 && rule.max === void 0) throw new Error("condition gate needs an explicit threshold");
4977
+ const slot = doc.slots.find((s) => s.id === rule.whenSlotVisible);
4978
+ if (!slot) throw new Error(`unknown gate slot "${rule.whenSlotVisible}"`);
4979
+ const ts = new Set(frames.map((f) => f.t));
4980
+ for (const tr of animation.tracks) for (const k of tr.keys) {
4981
+ ts.add(k.t);
4982
+ if (k.t > 0) ts.add(Math.max(0, k.t - 1e-6));
4983
+ }
4984
+ let active2 = 0, bad = 0;
4985
+ for (const t of ts) {
4986
+ const pose = poseOf(animation, t), s = evaluate(doc, pose);
4987
+ const color = pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
4988
+ const alpha = Number(pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
4989
+ if (!s.attachment.get(slot.id) || alpha <= 0.01) continue;
4990
+ active2++;
4991
+ const v = bone(s, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
4992
+ if (rule.min !== void 0 && v < rule.min || rule.max !== void 0 && v > rule.max) bad++;
4993
+ }
4994
+ metrics[`condition:${slot.id}:${rule.bone}.${rule.prop}`] = { active: active2, bad };
4995
+ if (bad || !active2 && rule.required) failures.push(`${slot.id} condition: ${bad} invalid visible samples, ${active2} active samples`);
4996
+ }
4997
+ for (const tr of cfg.transitions) {
4998
+ for (const id of [tr.from, tr.to]) if (!doc.animations.some((a) => a.id === id)) throw new Error(`missing transition animation ${id}`);
4999
+ const sink = { doc, pose: /* @__PURE__ */ new Map(), setPose(p) {
5000
+ this.pose = p;
5001
+ } }, state = new AnimationState(sink, { mix: tr.mix, missingAnimation: "throw" });
5002
+ state.setBase(tr.from, { mix: 0 });
5003
+ state.update(tr.fromTime);
5004
+ let previous = evaluate(doc, sink.pose), previousPose = sink.pose, maxStep = 0;
5005
+ state.play(tr.to);
5006
+ const measure = () => {
5007
+ const next = evaluate(doc, sink.pose);
5008
+ for (const b of doc.bones) {
5009
+ const a = previous.bones.get(b.id).world, c = next.bones.get(b.id).world;
5010
+ const p = apply(a, b.length, 0), q = apply(c, b.length, 0);
5011
+ maxStep = Math.max(maxStep, Math.hypot(c.tx - a.tx, c.ty - a.ty), Math.hypot(q[0] - p[0], q[1] - p[1]));
5012
+ }
5013
+ for (const s of doc.slots) {
5014
+ if (previous.attachment.get(s.id)?.id !== next.attachment.get(s.id)?.id) continue;
5015
+ const before = slotOutline(doc, previousPose, s.id, 1, previous), after = slotOutline(doc, sink.pose, s.id, 1, next);
5016
+ if (before && after && before.length === after.length) for (let i = 0; i < before.length; i++) maxStep = Math.max(maxStep, Math.hypot(after[i][0] - before[i][0], after[i][1] - before[i][1]));
5017
+ }
5018
+ previous = next;
5019
+ previousPose = sink.pose;
5020
+ };
5021
+ measure();
5022
+ const count = Math.ceil(tr.duration * cfg.fps);
5023
+ for (let i = 0; i < count; i++) {
5024
+ state.update(1 / cfg.fps);
5025
+ measure();
5026
+ }
5027
+ state.stop();
5028
+ metrics[`transition:${tr.from}->${tr.to}`] = { samples: count + 1, maxStep, mix: tr.mix };
5029
+ if (maxStep > tr.maxStep) failures.push(`transition ${tr.from}->${tr.to}: step ${maxStep} > ${tr.maxStep}`);
5030
+ }
5031
+ return { pass: failures.length === 0, animation: animation.id, fps: cfg.fps, interval: dt, expected: n + 1, inspected: frames.length, metrics, failures };
5032
+ }
5033
+
5034
+ // src/rig-api.ts
5035
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5036
+
5037
+ // src/rig-motion.ts
5038
+ var wrap2 = (x) => ((x + 180) % 360 + 360) % 360 - 180;
5039
+ var mesh = (doc, id) => {
5040
+ const a = doc.attachments.find((a2) => a2.id === id);
5041
+ if (a?.type !== "mesh") throw new Error(`expected mesh "${id}"`);
5042
+ return a;
5043
+ };
5044
+ function skin(doc, a, weights) {
5045
+ const skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5046
+ return weights.map((list, i) => {
5047
+ const sum = [...list.values()].reduce((a2, b) => a2 + b, 0);
5048
+ if (!(sum > 0)) throw new Error(`vertex ${i}: weights sum to zero`);
5049
+ return [...list].filter(([, w]) => w > 1e-9).map(([bone, w]) => {
5050
+ const b = skel.bones.get(bone);
5051
+ if (!b) throw new Error(`unknown weight bone "${bone}"`);
5052
+ const [x, y] = apply(invert(b.world), world[i * 2], world[i * 2 + 1]);
5053
+ return { bone, x, y, w: w / sum };
5054
+ });
5055
+ });
5056
+ }
5057
+ function gradientWeights(doc, id, bones) {
5058
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map());
5059
+ if (bones.length < 2 || new Set(bones).size !== bones.length) throw new Error("gradient needs at least two distinct bones");
5060
+ const points = bones.map((id2) => {
5061
+ const b = skel.bones.get(id2);
5062
+ if (!b) throw new Error(`unknown bone "${id2}"`);
5063
+ return [b.world.tx, b.world.ty];
5064
+ });
5065
+ const world = attachmentWorldVertices(skel, a.slot, a), weights = [];
5066
+ for (let v = 0; v < world.length; v += 2) {
5067
+ let best = Infinity, segment = 0, u = 0;
5068
+ for (let i = 0; i < points.length - 1; i++) {
5069
+ const [x, y] = points[i], dx = points[i + 1][0] - x, dy = points[i + 1][1] - y, l2 = dx * dx + dy * dy;
5070
+ if (l2 < 1e-12) throw new Error("gradient chain contains coincident joints");
5071
+ const t = Math.max(0, Math.min(1, ((world[v] - x) * dx + (world[v + 1] - y) * dy) / l2));
5072
+ const d = (world[v] - x - t * dx) ** 2 + (world[v + 1] - y - t * dy) ** 2;
5073
+ if (d < best) {
5074
+ best = d;
5075
+ segment = i;
5076
+ u = t;
5077
+ }
5078
+ }
5079
+ weights.push(/* @__PURE__ */ new Map([[bones[segment], 1 - u], [bones[segment + 1], u]]));
5080
+ }
5081
+ return setWeights(doc, id, skin(doc, a, weights)).doc;
5082
+ }
5083
+ function smoothWeights(doc, id, iterations = 3, strength = 0.5, locked = []) {
5084
+ const a = mesh(doc, id);
5085
+ if (!a.weights) throw new Error("skin the mesh before smoothing weights");
5086
+ if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100 || strength < 0 || strength > 1) throw new Error("invalid smoothing options");
5087
+ const fixed = new Set(locked), neighbours = a.weights.map(() => /* @__PURE__ */ new Set());
5088
+ for (const v of fixed) if (!Number.isInteger(v) || v < 0 || v >= neighbours.length) throw new Error(`invalid locked vertex ${v}`);
5089
+ for (let i = 0; i < a.triangles.length; i += 3) for (const v of a.triangles.slice(i, i + 3)) for (const n of a.triangles.slice(i, i + 3)) if (v !== n) neighbours[v].add(n);
5090
+ let w = a.weights.map((list) => new Map(list.map((i) => [i.bone, i.w])));
5091
+ for (let iter = 0; iter < iterations; iter++) w = w.map((own, i) => {
5092
+ if (fixed.has(i) || !neighbours[i].size) return own;
5093
+ const next = new Map([...own].map(([b, v]) => [b, v * (1 - strength)]));
5094
+ for (const n of neighbours[i]) for (const [b, v] of w[n]) next.set(b, (next.get(b) ?? 0) + strength * v / neighbours[i].size);
5095
+ return next;
5096
+ });
5097
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5098
+ }
5099
+ function pinWeights(doc, id, bone, vertices, region) {
5100
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5101
+ if (!skel.bones.has(bone)) throw new Error(`unknown bone "${bone}"`);
5102
+ if (!vertices?.length && !region) throw new Error("select vertices or a document-space region");
5103
+ const selected = new Set(vertices);
5104
+ for (const v of selected) if (!Number.isInteger(v) || v < 0 || v >= world.length / 2) throw new Error(`invalid vertex ${v}`);
5105
+ const host = doc.slots.find((s) => s.id === a.slot).bone;
5106
+ let count = 0;
5107
+ const w = Array.from({ length: world.length / 2 }, (_, i) => {
5108
+ const x = world[i * 2], y = world[i * 2 + 1];
5109
+ const inside = region && x >= region[0] && y >= region[1] && x <= region[0] + region[2] && y <= region[1] + region[3];
5110
+ if (selected.has(i) || inside) {
5111
+ count++;
5112
+ return /* @__PURE__ */ new Map([[bone, 1]]);
5113
+ }
5114
+ return new Map(a.weights?.[i].map((inf) => [inf.bone, inf.w]) ?? [[host, 1]]);
5115
+ });
5116
+ if (!count) throw new Error("selection contains no mesh vertices");
5117
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5118
+ }
5119
+ function attachToBone(doc, animation, bone, target, t) {
5120
+ const anim = doc.animations.find((a) => a.id === animation);
5121
+ if (!anim || t < 0 || t > anim.duration) throw new Error("attach requires an existing animation and time within its duration");
5122
+ const skel = evaluate(doc, poseOf(anim, t)), from = skel.bones.get(bone)?.world, to = skel.bones.get(target)?.world;
5123
+ if (!from || !to || bone === target) throw new Error("attach needs distinct existing bones");
5124
+ for (let b = doc.bones.find((b2) => b2.id === target); b; b = doc.bones.find((x2) => x2.id === b.parent)) if (b.id === bone) throw new Error("cannot attach to a descendant");
5125
+ if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5126
+ const [x, y] = apply(invert(to), from.tx, from.ty);
5127
+ const id = `attach_${bone}_${target}`;
5128
+ const offset = {
5129
+ x,
5130
+ y,
5131
+ rotation: wrap2(rotationOf(from) - rotationOf(to)),
5132
+ scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5133
+ scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5134
+ shearY: wrap2((Math.atan2(from.d, from.c) - Math.atan2(from.b, from.a) - Math.atan2(to.d, to.c) + Math.atan2(to.b, to.a)) * 180 / Math.PI)
5135
+ };
5136
+ const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5137
+ let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
5138
+ for (const c of next.transform.filter((c2) => c2.id.startsWith(`attach_${bone}_`) && c2.bones.length === 1 && c2.bones[0] === bone)) {
5139
+ for (const prop of ["mixX", "mixY", "mixRotate", "mixScaleX", "mixScaleY", "mixShearY"]) {
5140
+ const has = anim.tracks.some((tr) => tr.target === "transform" && tr.id === c.id && tr.prop === prop);
5141
+ const keys = [...!has && t > 0 ? [{ t: 0, v: 0, ease: "step" }] : [], { t, v: c.id === id ? 1 : 0, ease: "step" }];
5142
+ next = setKeys(next, animation, { target: "transform", id: c.id, prop }, keys, void 0, { merge: true });
5143
+ }
5144
+ }
5145
+ for (const [prop, v] of Object.entries(offset)) next = setKeys(next, animation, { target: "transform", id, prop }, [{ t, v, ease: "step" }], void 0, { merge: true });
5146
+ return next;
5147
+ }
5148
+ function bakeSecondary(doc, animation, opts) {
5149
+ const anim = doc.animations.find((a) => a.id === animation);
5150
+ if (!anim) throw new Error(`unknown animation "${animation}"`);
5151
+ const fps = opts.fps ?? 60, hz = opts.frequency ?? 2.5, damping = opts.damping ?? 0.65, gain = opts.gain ?? 0.2, maxAngle = opts.maxAngle ?? 30;
5152
+ if (fps <= 0 || hz <= 0 || damping <= 0 || maxAngle <= 0 || !opts.bones.length) throw new Error("invalid spring parameters");
5153
+ if (!doc.bones.some((b) => b.id === opts.driver) || opts.bones.includes(opts.driver)) throw new Error("driver must exist and be outside the secondary chain");
5154
+ if (new Set(opts.bones).size !== opts.bones.length) throw new Error("secondary bones must be distinct");
5155
+ for (let b = doc.bones.find((b2) => b2.id === opts.driver); b; b = doc.bones.find((x) => x.id === b.parent)) {
5156
+ if (opts.bones.includes(b.id)) throw new Error("secondary bones cannot be ancestors of the driver");
5157
+ }
5158
+ const n = Math.max(2, Math.ceil(anim.duration * fps)), dt = anim.duration / n;
5159
+ const base = { ...anim, tracks: anim.tracks.filter((t) => !(t.target === "bone" && opts.bones.includes(t.id) && t.prop === "rotation")) };
5160
+ const angular = (opts.driverProp ?? "rotation") === "rotation";
5161
+ const angles = Array.from({ length: n + 1 }, (_, i) => {
5162
+ const world = evaluate(doc, poseOf(base, dt * i)).bones.get(opts.driver).world;
5163
+ return angular ? rotationOf(world) : opts.driverProp === "x" ? world.tx : world.ty;
5164
+ });
5165
+ const seam = angles[n] - angles[0];
5166
+ if (anim.loop && Math.abs(angular ? wrap2(seam) : seam) > 1e-3) throw new Error("loop driver must return to its starting world pose before baking");
5167
+ let next = doc;
5168
+ for (let bi = 0; bi < opts.bones.length; bi++) {
5169
+ const bone = doc.bones.find((b) => b.id === opts.bones[bi]);
5170
+ if (!bone) throw new Error(`unknown secondary bone "${opts.bones[bi]}"`);
5171
+ const omega = 2 * Math.PI * hz / (1 + bi * 0.25), sub = Math.max(1, Math.ceil(dt * Math.max(240, omega * 12))), h = dt / sub;
5172
+ let x = 0, velocity = 0, samples = [];
5173
+ const cycle = () => {
5174
+ samples = [x];
5175
+ for (let i = 1; i <= n; i++) {
5176
+ const difference = angles[i] - angles[i - 1];
5177
+ const drive = (angular ? wrap2(difference) : difference) / dt;
5178
+ for (let j2 = 0; j2 < sub; j2++) {
5179
+ velocity += (-omega * omega * x - 2 * damping * omega * velocity - gain * omega * drive) * h;
5180
+ x += velocity * h;
5181
+ if (Math.abs(x) > maxAngle) {
5182
+ x = Math.sign(x) * maxAngle;
5183
+ velocity = 0;
5184
+ }
5185
+ }
5186
+ samples.push(x);
5187
+ }
5188
+ };
5189
+ if (anim.loop) {
5190
+ let converged = false;
5191
+ for (let i = 0; i < 200; i++) {
5192
+ const oldX = x, oldV = velocity;
5193
+ cycle();
5194
+ if (Math.abs(x - oldX) < 1e-7 && Math.abs(velocity - oldV) < 1e-7) {
5195
+ converged = true;
5196
+ break;
5197
+ }
5198
+ }
5199
+ if (!converged) throw new Error("spring loop did not converge; increase damping or duration");
5200
+ samples[n] = samples[0];
5201
+ } else cycle();
5202
+ const origin = anim.loop ? samples[0] : 0;
5203
+ const amplitude = Math.max(...samples.map((v) => Math.abs(v - origin)));
5204
+ const scale = Math.min(1, maxAngle / Math.max(amplitude, 1e-12));
5205
+ next = setKeys(next, animation, { target: "bone", id: bone.id, prop: "rotation", interpolation: "pchip" }, samples.map((v, i) => ({ t: i * dt, v: bone.transform.rotation + (v - origin) * scale, ease: "linear" })), anim.duration);
5206
+ }
5207
+ return next;
5208
+ }
5209
+
4367
5210
  // src/rig-api.ts
4368
5211
  var tools = [];
4369
5212
  function tool(name, description, input, run) {
4370
5213
  const handler = run;
4371
- tools.push({ name, description, input: z2.object(input).strict(), run: (args) => typeof args.path === "string" ? withRigQueue(args.path, () => handler(args)) : handler(args) });
5214
+ tools.push({ name, description, input: z3.object(input).strict(), run: (args) => typeof args.path === "string" ? withRigQueue(args.path, () => handler(args)) : handler(args) });
4372
5215
  }
4373
5216
  async function callTool(name, args) {
4374
5217
  const t = tools.find((t2) => t2.name === name);
@@ -4383,24 +5226,24 @@ async function callTool(name, args) {
4383
5226
  }
4384
5227
  var text = (s, doc) => ({ text: s, doc });
4385
5228
  var fail = (e) => ({ text: `ERROR: ${e instanceof Error ? e.message : String(e)}`, isError: true });
4386
- var Path = z2.string().describe("path to rig.json; assets are resolved relative to its directory");
4387
- var Library = z2.string().optional().describe("template library JSON; default presets.json next to rig.json");
5229
+ var Path = z3.string().describe("path to rig.json; assets are resolved relative to its directory");
5230
+ var Library = z3.string().optional().describe("template library JSON; default presets.json next to rig.json");
4388
5231
  async function load(path) {
4389
- return RigDocument.parse(JSON.parse(await readFile8(path, "utf8")));
5232
+ return RigDocument.parse(JSON.parse(await readFile9(path, "utf8")));
4390
5233
  }
4391
5234
  var save = (path, doc) => saveJson(path, doc);
4392
5235
  var exists2 = (p) => stat2(p).then(() => true, () => false);
4393
- var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join7(dirname6(rigPath), p);
4394
- var srcFor = (rigPath, file) => relative2(dirname6(rigPath), file).split("\\").join("/");
4395
- var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join7(dirname6(rigPath), "presets.json");
5236
+ var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join9(dirname8(rigPath), p);
5237
+ var srcFor = (rigPath, file) => relative2(dirname8(rigPath), file).split("\\").join("/");
5238
+ var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join9(dirname8(rigPath), "presets.json");
4396
5239
  async function pngInfo(rigPath, file) {
4397
5240
  const abs = resolveFrom(rigPath, file);
4398
- const [width, height] = pngSize(await readFile8(abs));
5241
+ const [width, height] = pngSize(await readFile9(abs));
4399
5242
  return { abs, width, height, src: srcFor(rigPath, abs) };
4400
5243
  }
4401
5244
  async function mutate(path, op, summary = false) {
4402
5245
  try {
4403
- const raw = await readFile8(path, "utf8");
5246
+ const raw = await readFile9(path, "utf8");
4404
5247
  const prev = RigDocument.parse(JSON.parse(raw));
4405
5248
  const next = parse(await op(prev));
4406
5249
  await pushHistory(path, raw);
@@ -4414,7 +5257,7 @@ changed:${changes.length ? "\n " + changes.join("\n ") : " nothing"}`, next);
4414
5257
  }
4415
5258
  }
4416
5259
  async function create(path, doc) {
4417
- if (await exists2(path)) await pushHistory(path, await readFile8(path, "utf8"));
5260
+ if (await exists2(path)) await pushHistory(path, await readFile9(path, "utf8"));
4418
5261
  const saved = parse(doc);
4419
5262
  await save(path, saved);
4420
5263
  return text(describe(saved), saved);
@@ -4426,18 +5269,18 @@ function previewCheck(doc, animation) {
4426
5269
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
4427
5270
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
4428
5271
  }
4429
- var TrackRef = { target: z2.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z2.string(), prop: z2.string() };
4430
- var KeyIn = z2.object({
4431
- t: z2.number().describe("seconds"),
4432
- v: z2.union([z2.number(), z2.string(), z2.null(), z2.array(z2.number()), z2.array(z2.string())]),
4433
- ease: Ease.optional().describe("into the next key: linear|step|ease_in|ease_out|ease_in_out|back_out|elastic_out|{bezier:[x1,y1,x2,y2]}; default ease_in_out")
5272
+ var TrackRef = { interpolation: z3.enum(["segment", "pchip"]).optional().describe("pchip: smooth through numeric keys, preserving extrema; segment: per-key easing"), target: z3.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z3.string(), prop: z3.string() };
5273
+ var KeyIn = z3.object({
5274
+ t: z3.number().describe("seconds"),
5275
+ v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5276
+ ease: Ease.optional().describe("into the next key: linear|step|ease_in|ease_out|ease_in_out|back_out|elastic_out|{bezier:[x1,y1,x2,y2]}; default ease_in_out; minimum_jerk for a rest-to-rest segment")
4434
5277
  });
4435
5278
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
4436
5279
  var KEY_DOC = "Keys are {t, v, ease?}. Bone values are ABSOLUTE local-transform values, not offsets (rest x=4 \u2192 key v=0 moves it 4px); deform values are relative (0 = rest). bone: x|y|rotation|scaleX|scaleY|shearX|shearY; slot: alpha|attachment(asset id or null)|z; deform (3\xD73 mesh on the slot's image): bulge|bend|sway|squash (fractions of the image size, |v|\u22640.9/1) or p<row><col>x|y (px, rows/cols 0..2 top-left first). Some values are arrays: deform `vertices` keys are number arrays (2\xB7n offsets, or 2\xB7\u03A3influences for weighted meshes); drawOrder keys are slot-id arrays; slot color keys are [r,g,b,a].";
4437
5280
  tool(
4438
5281
  "rig_create",
4439
5282
  "Create a new empty rig document with a root bone at bottom-center.",
4440
- { path: Path, name: z2.string(), width: z2.number().int(), height: z2.number().int(), notes: z2.string().optional().describe("style anchor + what is intentionally missing; reused by rig_layer_prompt") },
5283
+ { path: Path, name: z3.string(), width: z3.number().int(), height: z3.number().int(), notes: z3.string().optional().describe("style anchor + what is intentionally missing; reused by rig_layer_prompt") },
4441
5284
  async ({ path, name, width, height, notes }) => {
4442
5285
  try {
4443
5286
  return await create(path, createDoc(name, width, height, notes));
@@ -4449,7 +5292,7 @@ tool(
4449
5292
  tool(
4450
5293
  "rig_create_symbol",
4451
5294
  "Create a rig from ONE image (a slot symbol): canvas = image + margin, root at the image's bottom centre, slot pivot (0.5, 1). Then apply symbol_idle / symbol_win / anticipation_shake / dim presets.",
4452
- { path: Path, image: z2.string().describe("PNG path, absolute or relative to rig.json dir"), name: z2.string().optional(), margin: z2.number().default(0.15).describe("fraction of the image size on every side, room for bounces"), notes: z2.string().optional() },
5295
+ { path: Path, image: z3.string().describe("PNG path, absolute or relative to rig.json dir"), name: z3.string().optional(), margin: z3.number().default(0.15).describe("fraction of the image size on every side, room for bounces"), notes: z3.string().optional() },
4453
5296
  async ({ path, image, name, margin, notes }) => {
4454
5297
  try {
4455
5298
  const { width, height, src } = await pngInfo(path, image);
@@ -4459,22 +5302,22 @@ tool(
4459
5302
  }
4460
5303
  }
4461
5304
  );
4462
- var ReferenceIn = z2.object({
4463
- src: z2.string().describe("PNG next to rig.json (relative path)"),
4464
- opacity: z2.number().min(0).max(1).optional(),
4465
- x: z2.number().optional(),
4466
- y: z2.number().optional(),
4467
- scale: z2.number().positive().optional()
5305
+ var ReferenceIn = z3.object({
5306
+ src: z3.string().describe("PNG next to rig.json (relative path)"),
5307
+ opacity: z3.number().min(0).max(1).optional(),
5308
+ x: z3.number().optional(),
5309
+ y: z3.number().optional(),
5310
+ scale: z3.number().positive().optional()
4468
5311
  }).strict();
4469
5312
  tool(
4470
5313
  "rig_set_meta",
4471
5314
  "Patch the document's meta: name, canvas width/height (moves nothing \u2014 only the bounds change), notes, and the editor's reference underlay `reference {src, opacity, x, y, scale}` \u2014 a PNG next to rig.json drawn under the canvas to fit parts against; runtime, preview and export ignore it; null removes it.",
4472
5315
  {
4473
5316
  path: Path,
4474
- name: z2.string().optional(),
4475
- width: z2.number().int().positive().optional(),
4476
- height: z2.number().int().positive().optional(),
4477
- notes: z2.string().optional(),
5317
+ name: z3.string().optional(),
5318
+ width: z3.number().int().positive().optional(),
5319
+ height: z3.number().int().positive().optional(),
5320
+ notes: z3.string().optional(),
4478
5321
  reference: ReferenceIn.nullable().optional()
4479
5322
  },
4480
5323
  async ({ path, ...patch }) => mutate(path, (d) => setMeta(d, patch))
@@ -4482,7 +5325,7 @@ tool(
4482
5325
  tool(
4483
5326
  "rig_get",
4484
5327
  "Read a rig. Compact summary by default; verbose=true adds slot pivot/offset and every track with key counts and value ranges (no asset prompts); full=true returns the JSON.",
4485
- { path: Path, verbose: z2.boolean().default(false), full: z2.boolean().default(false) },
5328
+ { path: Path, verbose: z3.boolean().default(false), full: z3.boolean().default(false) },
4486
5329
  async ({ path, verbose, full }) => {
4487
5330
  try {
4488
5331
  const d = await load(path);
@@ -4495,7 +5338,7 @@ tool(
4495
5338
  tool(
4496
5339
  "rig_get_attachment",
4497
5340
  "ONE attachment, in full: the exact `vertices` / `weights` / `uvs` / `triangles` arrays it stores. This is what the raw forms of rig_set_path_attachment and rig_set_mesh want \u2014 they edit a shape in place instead of rebuilding it, and to edit `weights` you have to start from the `weights` that are there. The FIRST LINE is a summary (kind, slot, vertex/anchor counts, skinning, the length a deform `vertices` key must have); everything after the first newline is the attachment object as JSON, ready to hand straight back. rig_get full=true returns the same arrays inside the whole document, which is 3.3 MB on a Spine import against a few KB here.",
4498
- { path: Path, id: z2.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
5341
+ { path: Path, id: z3.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
4499
5342
  async ({ path, id }) => {
4500
5343
  try {
4501
5344
  const d = await load(path);
@@ -4513,10 +5356,10 @@ tool(
4513
5356
  "Where every bone actually IS at one moment: world x, y, angle (degrees) and axis lengths, with the bone's own local x, y and rotation, plus each slot's shown attachment and world bounding box. Constraints are applied, so this is what the runtime draws, not what the document stores. Without `animation` it reports the rest pose, and `t` is then an error rather than a number that does nothing. A `t` past the animation's duration is clamped to the end, and the summary line says so. `bones` and `slots` narrow the report \u2014 pass them on a large rig, where the whole report runs to tens of KB. Use it to check an edit landed where you meant instead of reading it off a preview image.",
4514
5357
  {
4515
5358
  path: Path,
4516
- animation: z2.string().optional(),
4517
- t: z2.number().min(0).optional(),
4518
- bones: z2.array(z2.string()).optional(),
4519
- slots: z2.array(z2.string()).optional()
5359
+ animation: z3.string().optional(),
5360
+ t: z3.number().min(0).optional(),
5361
+ bones: z3.array(z3.string()).optional(),
5362
+ slots: z3.array(z3.string()).optional()
4520
5363
  },
4521
5364
  async ({ path, ...opts }) => {
4522
5365
  try {
@@ -4545,11 +5388,11 @@ tool(
4545
5388
  );
4546
5389
  tool(
4547
5390
  "rig_check",
4548
- "Cheap self-critique without rendering: out_of_canvas (slot leaves the canvas, worst time), static_track, loop_seam, deform_flip (lattice folds over itself \u2014 an error), mesh_fold (a skinned/deformed mesh folds over itself: error when \u2265 5 % of the mesh folds, warning below \u2014 a dense mesh bent hard at a joint pinches the odd hairline triangle), weights_sum (skinned mesh vertex weights not summing to 1), patch_offset (a slot sits away from its asset's source bbox), rest_overlap, unused_asset, never_visible, and for constraints: constraint_dead (every mix is 0 at rest and no track lifts one \u2014 the solver skips it), constraint_cycle (the target is a bone the constraint drives, or hangs off one: it reads what it writes \u2014 an error), ik_unreachable (the target sits beyond the chain's reach for over 20 % of an animation, so the limb hangs straight), path_missing (a path constraint whose target slot never shows a path attachment \u2014 an error). Findings are sorted by severity. Run it before spending vision tokens on a preview.",
4549
- { path: Path, animation: z2.string().optional().describe("limit to one animation (rest pose is always checked)") },
4550
- async ({ path, animation }) => {
5391
+ "Cheap self-critique without rendering: out_of_canvas (slot leaves the canvas, worst time), static_track, loop_seam, deform_flip (lattice folds over itself \u2014 an error), mesh_fold (a skinned/deformed mesh folds over itself: error when \u2265 5 % of the mesh folds, warning below \u2014 a dense mesh bent hard at a joint pinches the odd hairline triangle), weights_sum (skinned mesh vertex weights not summing to 1), patch_offset (a slot sits away from its asset's source bbox), rest_overlap, unused_asset, never_visible, and for constraints: constraint_dead (every mix is 0 at rest and no track lifts one \u2014 the solver skips it), constraint_cycle (the target is a bone the constraint drives, or hangs off one: it reads what it writes \u2014 an error), ik_unreachable (the target sits beyond the chain's reach for over 20 % of an animation, so the limb hangs straight), path_missing (a path constraint whose target slot never shows a path attachment \u2014 an error). Motion diagnostics also cover pass_key_stop, velocity_jump, acceleration_jump, loop_velocity and constraint_order; provide secondaryBones, frontOf slot pairs and named attachment UV landmarks for brief-specific frozen-motion, ordering and swap-registration checks. Findings are sorted by severity. Run it before spending vision tokens on a preview.",
5392
+ { path: Path, animation: z3.string().optional().describe("limit to one animation (rest pose is always checked)"), fps: z3.number().positive().optional(), deformFps: z3.number().positive().optional(), secondaryBones: z3.array(z3.string()).optional(), frontOf: z3.array(z3.tuple([z3.string(), z3.string()])).optional(), landmarks: z3.record(z3.record(z3.tuple([z3.number(), z3.number()]))).optional(), landmarkTolerance: z3.number().nonnegative().optional() },
5393
+ async ({ path, ...opts }) => {
4551
5394
  try {
4552
- return text(summarize(checkRig(await load(path), { animation }), 100));
5395
+ return text(summarize(checkRig(await load(path), opts), 100));
4553
5396
  } catch (e) {
4554
5397
  return fail(e);
4555
5398
  }
@@ -4560,12 +5403,12 @@ tool(
4560
5403
  "Import a Spine project (skeleton .json 3.8/4.x + .atlas + page image) as a new rig: bones, slots, region/mesh/path attachments with weights, IK / transform / path constraints and every animation (deform, draw order, colors), converted to this document's y-down space. The atlas may be plain or base64 text; a KTX2 page is transcoded to PNG next to rig.json. Then use rig_get / rig_render_preview / rig_check as with any rig.",
4561
5404
  {
4562
5405
  path: Path,
4563
- json: z2.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
4564
- atlas: z2.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
4565
- image: z2.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
4566
- name: z2.string().optional().describe("default: the json file name"),
4567
- margin: z2.number().optional().describe("canvas margin around the skeleton bounds, px"),
4568
- notes: z2.string().optional()
5406
+ json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5407
+ atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5408
+ image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5409
+ name: z3.string().optional().describe("default: the json file name"),
5410
+ margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5411
+ notes: z3.string().optional()
4569
5412
  },
4570
5413
  async ({ path, json, atlas, image, name, margin, notes }) => {
4571
5414
  try {
@@ -4583,13 +5426,13 @@ warnings:
4583
5426
  tool(
4584
5427
  "rig_import_layers",
4585
5428
  "Import PNG layers from a directory as assets + slots + bones. Uses layers.json sidecar if present (id, role, parent, z, bbox, joint, swap, bone, slot), else filename convention `03_arm_l.png` \u2192 z=3, id=arm_l, role from known names. Bone hierarchy is inferred from roles (head\u2192neck\u2192torso\u2192hips\u2192root, arms\u2192torso, legs\u2192hips). `swap: true` = patch (closed eyes, mouth) as a hidden slot on the parent's bone; several swap layers with the same `slot` share one slot; `bone` = attach to an existing bone without creating one.",
4586
- { path: Path, layersDir: z2.string().describe("absolute dir, or relative to rig.json dir"), relPrefix: z2.string().default("layers").describe("how asset.src refers to the dir, relative to rig.json") },
5429
+ { path: Path, layersDir: z3.string().describe("absolute dir, or relative to rig.json dir"), relPrefix: z3.string().default("layers").describe("how asset.src refers to the dir, relative to rig.json") },
4587
5430
  async ({ path, layersDir, relPrefix }) => mutate(path, (d) => importLayers(d, resolveFrom(path, layersDir), relPrefix), true)
4588
5431
  );
4589
5432
  tool(
4590
5433
  "rig_replace_asset",
4591
5434
  "Swap one layer's PNG (e.g. a regenerated part) without rebuilding the rig. Pivots are fractions, so the slot keeps working; the image should keep the same silhouette registration.",
4592
- { path: Path, id: z2.string(), file: z2.string().describe("new PNG, absolute or relative to rig.json dir"), prompt: z2.string().optional().describe("provenance: how it was generated") },
5435
+ { path: Path, id: z3.string(), file: z3.string().describe("new PNG, absolute or relative to rig.json dir"), prompt: z3.string().optional().describe("provenance: how it was generated") },
4593
5436
  async ({ path, id, file, prompt }) => mutate(path, async (d) => {
4594
5437
  const { width, height, src } = await pngInfo(path, file);
4595
5438
  return replaceAsset(d, id, { src, width, height, prompt, inpainted: true });
@@ -4598,27 +5441,27 @@ tool(
4598
5441
  tool(
4599
5442
  "rig_layer_prompt",
4600
5443
  "Build a parts-first image-generation prompt for one layer from its provenance (asset.origin), the rig's style notes and its bone context. Generate the image with your image tool, then rig_replace_asset.",
4601
- { path: Path, id: z2.string() },
4602
- async ({ path, id }) => {
5444
+ { path: Path, method: z3.enum(["part", "repair"]).default("part"), removed: z3.array(z3.string()).optional(), id: z3.string() },
5445
+ async ({ path, id, method, removed }) => {
4603
5446
  try {
4604
- return text(layerPrompt(await load(path), id));
5447
+ return text(layerPrompt(await load(path), id, { method, removed }));
4605
5448
  } catch (e) {
4606
5449
  return fail(e);
4607
5450
  }
4608
5451
  }
4609
5452
  );
4610
- var Frame = z2.tuple([z2.number().int(), z2.number().int(), z2.number().int(), z2.number().int()]).describe("[x, y, w, h] rectangle in the source image's pixels");
4611
- var OriginIn = z2.object({ sourceImage: z2.string().optional(), bbox: Frame.optional(), inpainted: z2.boolean().default(false), prompt: z2.string().optional() }).strict();
5453
+ var Frame = z3.tuple([z3.number().int(), z3.number().int(), z3.number().int(), z3.number().int()]).describe("[x, y, w, h] rectangle in the source image's pixels");
5454
+ var OriginIn = z3.object({ bboxSpace: z3.enum(["source", "document"]).optional(), sourceImage: z3.string().optional(), bbox: Frame.optional(), inpainted: z3.boolean().default(false), prompt: z3.string().optional() }).strict();
4612
5455
  tool(
4613
5456
  "rig_set_asset",
4614
5457
  "Create or patch an asset. `src` = a PNG relative to rig.json (needed to create); `frame` = one rectangle of that PNG (a part split off a sheet \u2014 the runtime, preview, export and editor all cut it at draw time, no crop file); without `frame` the asset is the whole image. Re-framing an asset that regions show keeps the source pixel under each image centre where it is drawn; a mesh keeps its uvs and is named so you can re-trace it. Place it with rig_place_asset.",
4615
5458
  {
4616
5459
  path: Path,
4617
- id: z2.string(),
4618
- src: z2.string().optional(),
5460
+ id: z3.string(),
5461
+ src: z3.string().optional(),
4619
5462
  frame: Frame.nullable().optional().describe("null = the whole image again"),
4620
- width: z2.number().int().positive().optional(),
4621
- height: z2.number().int().positive().optional(),
5463
+ width: z3.number().int().positive().optional(),
5464
+ height: z3.number().int().positive().optional(),
4622
5465
  origin: OriginIn.optional()
4623
5466
  },
4624
5467
  async ({ path, id, src, ...patch }) => mutateWith(path, async (d, say) => {
@@ -4636,19 +5479,19 @@ tool(
4636
5479
  tool(
4637
5480
  "rig_remove_asset",
4638
5481
  "Remove an asset nothing shows any more (refused, with the list, while an attachment still uses it).",
4639
- { path: Path, id: z2.string() },
5482
+ { path: Path, id: z3.string() },
4640
5483
  async ({ path, id }) => mutate(path, (d) => removeAsset(d, id))
4641
5484
  );
4642
5485
  tool(
4643
5486
  "rig_place_asset",
4644
5487
  "Put an asset on a bone in ONE call: a slot (`slot` or the asset's id; created on `bone` when missing, an existing slot keeps its bone), a region attachment named like the asset with its centre at the DOCUMENT point (x, y), and the slot showing it. z = max + 1 unless given. Then rotate/scale with rig_set_attachment.",
4645
- { path: Path, asset: z2.string(), bone: z2.string(), x: z2.number(), y: z2.number(), slot: z2.string().optional(), z: z2.number().int().optional() },
5488
+ { path: Path, asset: z3.string(), bone: z3.string(), x: z3.number(), y: z3.number(), slot: z3.string().optional(), z: z3.number().int().optional() },
4646
5489
  async ({ path, ...p }) => mutate(path, (d) => placeAsset(d, p), true)
4647
5490
  );
4648
5491
  tool(
4649
5492
  "rig_undo",
4650
5493
  "Restore the rig as it was `steps` mutations ago (every mutation snapshots the previous file into .rig-history/ next to rig.json).",
4651
- { path: Path, steps: z2.number().int().min(1).default(1) },
5494
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4652
5495
  async ({ path, steps }) => {
4653
5496
  try {
4654
5497
  const doc = parse(JSON.parse(await undo(path, steps)));
@@ -4677,13 +5520,13 @@ tool(
4677
5520
  tool(
4678
5521
  "rig_diff",
4679
5522
  "Structural difference between the current rig and the snapshot `steps` mutations ago: bones/slots/assets added, removed, changed; tracks with key counts and value ranges; preset params.",
4680
- { path: Path, steps: z2.number().int().min(1).default(1) },
5523
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4681
5524
  async ({ path, steps }) => {
4682
5525
  try {
4683
5526
  const history = await listHistory(path);
4684
5527
  if (!history.length) return text("no history for this rig");
4685
5528
  if (steps > history.length) return text(`only ${history.length} snapshot${history.length === 1 ? "" : "s"} available`);
4686
- const old = RigDocument.parse(JSON.parse(await readFile8(history[history.length - steps], "utf8")));
5529
+ const old = RigDocument.parse(JSON.parse(await readFile9(history[history.length - steps], "utf8")));
4687
5530
  const lines = diffDocs(old, await load(path));
4688
5531
  return text(lines.length ? lines.join("\n") : "no differences");
4689
5532
  } catch (e) {
@@ -4691,19 +5534,19 @@ tool(
4691
5534
  }
4692
5535
  }
4693
5536
  );
4694
- var TransformPatch = z2.object({ x: z2.number(), y: z2.number(), rotation: z2.number(), scaleX: z2.number(), scaleY: z2.number(), shearX: z2.number(), shearY: z2.number() }).partial();
5537
+ var TransformPatch = z3.object({ x: z3.number(), y: z3.number(), rotation: z3.number(), scaleX: z3.number(), scaleY: z3.number(), shearX: z3.number(), shearY: z3.number() }).partial();
4695
5538
  var BoneFields = {
4696
- parent: z2.string().optional(),
4697
- role: z2.string().optional(),
4698
- length: z2.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
4699
- inherit: z2.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
4700
- color: z2.string().optional().describe("editor colour"),
5539
+ parent: z3.string().optional(),
5540
+ role: z3.string().optional(),
5541
+ length: z3.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
5542
+ inherit: z3.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
5543
+ color: z3.string().optional().describe("editor colour"),
4701
5544
  transform: TransformPatch.optional()
4702
5545
  };
4703
5546
  tool(
4704
5547
  "rig_add_bone",
4705
5548
  "Add a bone. transform is LOCAL to parent (px, degrees CW). Omit parent \u2192 root. `bind` moves those slots onto the new bone without moving their images (the editor's B tool with a part selected).",
4706
- { path: Path, id: z2.string(), ...BoneFields, bind: z2.array(z2.string()).optional().describe("slot ids to re-bind onto the new bone, world placement kept") },
5549
+ { path: Path, id: z3.string(), ...BoneFields, bind: z3.array(z3.string()).optional().describe("slot ids to re-bind onto the new bone, world placement kept") },
4707
5550
  async ({ path, bind, ...bone }) => mutateWith(path, (d, say) => {
4708
5551
  let next = addBone(d, bone);
4709
5552
  for (const s of bind ?? []) {
@@ -4717,13 +5560,13 @@ tool(
4717
5560
  tool(
4718
5561
  "rig_update_bone",
4719
5562
  "Patch bone fields (parent, role, length, inherit, color, transform \u2014 partial, merged). parent=null detaches the bone into a root of its own.",
4720
- { path: Path, id: z2.string(), ...BoneFields, parent: z2.string().nullable().optional().describe("new parent bone; null = make it a root bone (a rig may have several)") },
5563
+ { path: Path, id: z3.string(), ...BoneFields, parent: z3.string().nullable().optional().describe("new parent bone; null = make it a root bone (a rig may have several)") },
4721
5564
  async ({ path, id, ...patch }) => mutate(path, (d) => updateBone(d, id, patch))
4722
5565
  );
4723
5566
  tool(
4724
5567
  "rig_remove_bone",
4725
5568
  "Remove a bone; children and slots re-parent to its parent.",
4726
- { path: Path, id: z2.string() },
5569
+ { path: Path, id: z3.string() },
4727
5570
  async ({ path, id }) => mutateWith(path, (d, say) => {
4728
5571
  const parent = d.bones.find((b) => b.id === id)?.parent;
4729
5572
  const slots = d.slots.filter((s) => s.bone === id).map((s) => s.id);
@@ -4734,18 +5577,19 @@ tool(
4734
5577
  );
4735
5578
  tool(
4736
5579
  "rig_set_slot",
4737
- "Create or patch a slot (a draw layer on a bone). attachment = id of one of its attachments, null hides it (swap slots). blend=add for glow/fire layers painted on black; color is #rrggbbaa tint. Changing `bone` on an existing slot KEEPS its images where they are on the document (placements are re-expressed in the new bone's rest frame; weighted meshes need nothing).",
5580
+ "Create or patch a slot (a draw layer on a bone). attachment = id of one of its attachments, null hides it (swap slots). blend=add for glow/fire layers painted on black; color is #rrggbbaa tint. clip names a closed path attachment used as a moving mask; null removes it. Changing `bone` on an existing slot KEEPS its images where they are on the document (placements are re-expressed in the new bone's rest frame; weighted meshes need nothing).",
4738
5581
  {
4739
5582
  path: Path,
4740
- id: z2.string(),
4741
- bone: z2.string().optional(),
4742
- attachment: z2.string().nullable().optional(),
4743
- z: z2.number().int().optional(),
4744
- alpha: z2.number().optional(),
4745
- blend: z2.enum(["normal", "add", "multiply", "screen"]).optional(),
4746
- color: z2.string().optional()
5583
+ id: z3.string(),
5584
+ bone: z3.string().optional(),
5585
+ clip: z3.string().nullable().optional(),
5586
+ attachment: z3.string().nullable().optional(),
5587
+ z: z3.number().int().optional(),
5588
+ alpha: z3.number().optional(),
5589
+ blend: z3.enum(["normal", "add", "multiply", "screen"]).optional(),
5590
+ color: z3.string().optional()
4747
5591
  },
4748
- async ({ path, attachment, bone, ...slot }) => mutateWith(path, (d, say) => {
5592
+ async ({ path, attachment, bone, clip, ...slot }) => mutateWith(path, (d, say) => {
4749
5593
  let next = d;
4750
5594
  const cur = d.slots.find((s) => s.id === slot.id);
4751
5595
  if (cur && bone !== void 0 && bone !== cur.bone) {
@@ -4753,7 +5597,7 @@ tool(
4753
5597
  next = r.doc;
4754
5598
  if (r.approx > 0.01) say(`slot "${slot.id}" re-bound to "${bone}" approximately: a region corner moved ${r.approx} px (shear or non-uniform scale between the bones)`);
4755
5599
  }
4756
- return setSlot(next, { ...slot, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
5600
+ return setSlot(next, { ...slot, ...clip === void 0 ? {} : { clip: clip ?? void 0 }, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
4757
5601
  })
4758
5602
  );
4759
5603
  tool(
@@ -4761,21 +5605,21 @@ tool(
4761
5605
  "Create or patch a REGION attachment: an image (asset) placed in a slot. x/y = the image CENTRE in the slot bone's space, rotation in degrees, scale; fields are merged. A slot can hold several attachments (swap states); set slot.attachment to show one. Meshes come from rig_mesh_from_image (trace this region's image) or rig_import_spine.",
4762
5606
  {
4763
5607
  path: Path,
4764
- id: z2.string(),
4765
- slot: z2.string().optional(),
4766
- asset: z2.string().optional(),
4767
- x: z2.number().optional(),
4768
- y: z2.number().optional(),
4769
- rotation: z2.number().optional(),
4770
- scaleX: z2.number().optional(),
4771
- scaleY: z2.number().optional(),
4772
- width: z2.number().optional(),
4773
- height: z2.number().optional()
5608
+ id: z3.string(),
5609
+ slot: z3.string().optional(),
5610
+ asset: z3.string().optional(),
5611
+ x: z3.number().optional(),
5612
+ y: z3.number().optional(),
5613
+ rotation: z3.number().optional(),
5614
+ scaleX: z3.number().optional(),
5615
+ scaleY: z3.number().optional(),
5616
+ width: z3.number().optional(),
5617
+ height: z3.number().optional()
4774
5618
  },
4775
5619
  async ({ path, ...att }) => mutate(path, (d) => setAttachment(d, att))
4776
5620
  );
4777
- var Influence2 = z2.object({ bone: z2.string(), x: z2.number(), y: z2.number(), w: z2.number() }).describe("one influence: the vertex in that bone's local space and its weight");
4778
- var WeightsIn = z2.array(z2.array(Influence2).min(1)).describe("one influence list per vertex, in uv order; weights of a list should sum to 1");
5621
+ var Influence2 = z3.object({ bone: z3.string(), x: z3.number(), y: z3.number(), w: z3.number() }).describe("one influence: the vertex in that bone's local space and its weight");
5622
+ var WeightsIn = z3.array(z3.array(Influence2).min(1)).describe("one influence list per vertex, in uv order; weights of a list should sum to 1");
4779
5623
  async function mutateWith(path, op, summary = false) {
4780
5624
  const lines = [];
4781
5625
  const res = await mutate(path, (d) => op(d, (l) => lines.push(l)), summary);
@@ -4790,14 +5634,15 @@ var meshOf = (doc, id) => {
4790
5634
  };
4791
5635
  tool(
4792
5636
  "rig_mesh_from_image",
4793
- "Turn a REGION attachment into a mesh traced from its PNG's alpha: the outline (pixels with alpha \u2265 threshold, simplified by tolerance px, pushed out by margin px) plus an interior grid every `spacing` px, Delaunay-triangulated. EVERY separate opaque island of the layer is meshed (a closed-eyes overlay is two strokes) \u2014 islands under 5% of the largest, and holes, are skipped; the answer says `islands: N` when there is more than one, and such a mesh reports hull 0. The mesh keeps the region's id, slot, asset and placement, so nothing moves; lattice deform tracks (bulge/bend/sway/squash/p<rc>) on it are dropped (they only apply to regions). Next: rig_auto_weights, then key deform `vertices`.",
5637
+ "Turn a REGION attachment into a mesh traced from its PNG's alpha: the outline (pixels with alpha \u2265 threshold, simplified by tolerance px, pushed out by margin px) plus an interior grid every `spacing` px, Delaunay-triangulated. EVERY separate opaque island of the layer is meshed (a closed-eyes overlay is two strokes) \u2014 islands below minIslandRatio (default 5% of the largest), and holes, are skipped; the answer says `islands: N` when there is more than one, and such a mesh reports hull 0. The mesh keeps the region's id, slot, asset and placement, so nothing moves; lattice deform tracks (bulge/bend/sway/squash/p<rc>) on it are dropped (they only apply to regions). Next: rig_auto_weights, then key deform `vertices`.",
4794
5638
  {
4795
5639
  path: Path,
4796
- id: z2.string().describe("attachment id (a region)"),
4797
- spacing: z2.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
4798
- tolerance: z2.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
4799
- margin: z2.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
4800
- threshold: z2.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
5640
+ id: z3.string().describe("attachment id (a region)"),
5641
+ spacing: z3.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
5642
+ tolerance: z3.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
5643
+ margin: z3.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
5644
+ minIslandRatio: z3.number().min(0).max(1).default(0.05).describe("minimum island area relative to largest; 0 preserves small intentional fragments"),
5645
+ threshold: z3.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
4801
5646
  },
4802
5647
  async ({ path, id, ...opts }) => mutateWith(path, async (d, say) => {
4803
5648
  const att = d.attachments.find((a) => a.id === id);
@@ -4806,15 +5651,15 @@ tool(
4806
5651
  const asset = d.assets.find((a) => a.id === att.asset);
4807
5652
  if (!asset) throw new Error(`unknown asset "${att.asset}"`);
4808
5653
  if (asset.rotate || asset.trim) throw new Error(`asset "${asset.id}" is packed into an exported atlas (rotate/trim): run rig_mesh_from_image on the unpacked rig, not an atlas export`);
4809
- const png = await readFile8(resolveFrom(path, asset.src)).catch((e) => {
5654
+ const png = await readFile9(resolveFrom(path, asset.src)).catch((e) => {
4810
5655
  throw new Error(`asset "${asset.id}": cannot read ${asset.src} (${e.code ?? e.message})`);
4811
5656
  });
4812
5657
  const { spacing, ...trace } = opts;
4813
5658
  const mask = alphaMaskFromPng(png, asset.frame);
4814
5659
  const islands = traceHulls(mask, trace);
4815
- const mesh = meshFromIslands(islands, att, asset, mask, spacing);
4816
- const { doc, droppedTracks } = convertToMesh(d, mesh);
4817
- say(`${id}=mesh(${mesh.uvs.length / 2}v, ${mesh.triangles.length / 3}t, hull ${mesh.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
5660
+ const mesh2 = meshFromIslands(islands, att, asset, mask, spacing);
5661
+ const { doc, droppedTracks } = convertToMesh(d, mesh2);
5662
+ say(`${id}=mesh(${mesh2.uvs.length / 2}v, ${mesh2.triangles.length / 3}t, hull ${mesh2.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
4818
5663
  if (droppedTracks.length) say(`dropped lattice deform tracks: ${droppedTracks.join(", ")}`);
4819
5664
  return doc;
4820
5665
  })
@@ -4824,10 +5669,10 @@ tool(
4824
5669
  "Skin a mesh to bones by distance: each vertex takes the `maxInfluences` (default 2) nearest candidate bones, inverse-distance weighted and normalized to 1. Nothing is dropped for being far away, so a vertex far from every joint still blends its two nearest bones instead of snapping to one at weight 1. `radius` scales the falloff \u2014 weight `1 / (distance/radius + 1)\xB2` \u2014 in world px, default 1: a larger radius spreads influence across more bones, a smaller one binds each vertex more tightly to its nearest bone. There is no cutoff at any radius. Default `bones`, from the REST pose: the slot's bone, its parent and its direct children. Replaces the mesh's `vertices` with `weights`, so deform `vertices` keys on it are dropped (their length changes). Check the result with rig_render_preview.",
4825
5670
  {
4826
5671
  path: Path,
4827
- id: z2.string().describe("mesh attachment id"),
4828
- bones: z2.array(z2.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
4829
- radius: z2.number().positive().optional().describe("falloff scale in world px, default 1; larger spreads influence across more bones, smaller binds tighter to the nearest one; no cutoff"),
4830
- maxInfluences: z2.number().int().min(1).default(2).describe("bones per vertex")
5672
+ id: z3.string().describe("mesh attachment id"),
5673
+ bones: z3.array(z3.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
5674
+ radius: z3.number().positive().optional().describe("falloff scale in world px, default 1; larger spreads influence across more bones, smaller binds tighter to the nearest one; no cutoff"),
5675
+ maxInfluences: z3.number().int().min(1).default(2).describe("bones per vertex")
4831
5676
  },
4832
5677
  async ({ path, id, bones, radius, maxInfluences }) => mutateWith(path, (d, say) => {
4833
5678
  const att = meshOf(d, id);
@@ -4849,12 +5694,12 @@ tool(
4849
5694
  "Replace a mesh's geometry arrays (only the given ones): `uvs` (2 per vertex, 0..1 in the asset image), `triangles` (3 indices each), `hull` (leading vertices that form the outline), and its rest shape as either `vertices` (2 per vertex, slot-bone local) or `weights` (skinned) \u2014 giving one drops the other. Deform `vertices` keys whose length no longer fits are dropped. For tracing a mesh from the image use rig_mesh_from_image.",
4850
5695
  {
4851
5696
  path: Path,
4852
- id: z2.string().describe("mesh attachment id"),
4853
- vertices: z2.array(z2.number()).optional(),
5697
+ id: z3.string().describe("mesh attachment id"),
5698
+ vertices: z3.array(z3.number()).optional(),
4854
5699
  weights: WeightsIn.optional(),
4855
- uvs: z2.array(z2.number()).optional(),
4856
- triangles: z2.array(z2.number().int().nonnegative()).optional(),
4857
- hull: z2.number().int().nonnegative().optional()
5700
+ uvs: z3.array(z3.number()).optional(),
5701
+ triangles: z3.array(z3.number().int().nonnegative()).optional(),
5702
+ hull: z3.number().int().nonnegative().optional()
4858
5703
  },
4859
5704
  async ({ path, id, ...patch }) => mutateWith(path, (d, say) => {
4860
5705
  const { doc, dropped } = setMesh(d, meshOf(d, id).id, patch);
@@ -4865,7 +5710,7 @@ tool(
4865
5710
  tool(
4866
5711
  "rig_set_weights",
4867
5712
  "Replace a mesh's skinning by hand: one influence list per vertex (uv order), each {bone, x, y, w} holding the vertex in that bone's local space; a list's weights should sum to 1. Drops the mesh's unweighted `vertices` and any deform `vertices` keys whose length no longer fits. rig_auto_weights computes these from bone distance.",
4868
- { path: Path, id: z2.string().describe("mesh attachment id"), weights: WeightsIn },
5713
+ { path: Path, id: z3.string().describe("mesh attachment id"), weights: WeightsIn },
4869
5714
  async ({ path, id, weights }) => mutateWith(path, (d, say) => {
4870
5715
  const { doc, dropped } = setWeights(d, meshOf(d, id).id, weights);
4871
5716
  if (dropped.length) say(`dropped deform tracks: ${dropped.join(", ")}`);
@@ -4875,19 +5720,19 @@ tool(
4875
5720
  tool(
4876
5721
  "rig_remove_attachment",
4877
5722
  "Remove an attachment; slots showing it become hidden, keys naming it become null.",
4878
- { path: Path, id: z2.string() },
5723
+ { path: Path, id: z3.string() },
4879
5724
  async ({ path, id }) => mutate(path, (d) => removeAttachment(d, id))
4880
5725
  );
4881
5726
  tool(
4882
5727
  "rig_remove_slot",
4883
5728
  "Remove a slot and its tracks.",
4884
- { path: Path, id: z2.string() },
5729
+ { path: Path, id: z3.string() },
4885
5730
  async ({ path, id }) => mutate(path, (d) => removeSlot(d, id), true)
4886
5731
  );
4887
5732
  tool(
4888
5733
  "rig_set_draw_order",
4889
5734
  "Setup draw order in one call: `order` lists EVERY slot back-to-front (the same order a drawOrder key uses); each slot gets z = its index. For an animated reorder key the drawOrder track with rig_set_keys instead.",
4890
- { path: Path, order: z2.array(z2.string()).min(1) },
5735
+ { path: Path, order: z3.array(z3.string()).min(1) },
4891
5736
  async ({ path, order }) => mutate(path, (d) => setDrawOrder(d, order))
4892
5737
  );
4893
5738
  function namesId(v, id) {
@@ -4899,7 +5744,7 @@ function namesId(v, id) {
4899
5744
  tool(
4900
5745
  "rig_rename",
4901
5746
  "Rename a bone, slot, attachment, asset or constraint and rewrite EVERY reference to it (parents, slot bones, chains and targets, mesh/path influences, slot attachments, key values, drawOrder keys, tracks). Roles stay. Templates in presets.json are a separate file and are not rewritten \u2014 re-save them if they name the old id. An animation's own preset.params (rig_apply_preset) is left alone too \u2014 the reply names any that still refer to the old id, since a blind substitution across differently-shaped params would be unsafe.",
4902
- { path: Path, kind: z2.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z2.string(), to: z2.string() },
5747
+ { path: Path, kind: z3.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z3.string(), to: z3.string() },
4903
5748
  async ({ path, kind, id, to }) => mutateWith(path, async (d, say) => {
4904
5749
  if (await exists2(libraryFor(path))) say(`note: ${libraryFor(path)} is not rewritten \u2014 templates that name "${id}" by id still do`);
4905
5750
  const next = rename2(d, kind, id, to);
@@ -4909,25 +5754,27 @@ tool(
4909
5754
  return next;
4910
5755
  }, true)
4911
5756
  );
4912
- var ConstraintBones = z2.array(z2.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
4913
- var ConstraintOrder = z2.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
4914
- var PathMixes = { mixRotate: z2.number().optional(), mixX: z2.number().optional(), mixY: z2.number().optional() };
4915
- var Mixes = { ...PathMixes, mixScaleX: z2.number().optional(), mixScaleY: z2.number().optional(), mixShearY: z2.number().optional() };
5757
+ var ConstraintBones = z3.array(z3.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
5758
+ var ConstraintOrder = z3.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
5759
+ var PathMixes = { mixRotate: z3.number().optional(), mixX: z3.number().optional(), mixY: z3.number().optional() };
5760
+ var Mixes = { ...PathMixes, mixScaleX: z3.number().optional(), mixScaleY: z3.number().optional(), mixShearY: z3.number().optional() };
4916
5761
  var CONSTRAINT_DOC = "Constraints are solved after the bone tree, in `order`, and OVERWRITE the bones they drive: keying such a bone directly does nothing visible \u2014 key the target bone (or this constraint's mix, an ordinary number track) instead. Ids are shared by the three kinds and a constraint's kind is fixed once created; rig_remove_constraint drops it together with its tracks.";
4917
5762
  tool(
4918
5763
  "rig_set_ik",
4919
- "Create or patch an IK constraint: bend a chain of 1-2 bones so its tip reaches a TARGET BONE \u2014 move the target, the limb follows. `bones` is the chain parent\u2192child (1 or 2 bones; the format has no longer chains), `target` is a BONE, normally a marker parented to the root so the limb reaches a place in the world. `bendPositive` picks which way a two-bone chain buckles (knee forward or backward), `softness` eases the last px before full extension, `compress`/`stretch` let the chain shorten/lengthen to reach, `mix` 0..1 fades the whole constraint. Fields are merged, so {id, mix} patches only the mix. To rig a limb from scratch call rig_add_ik_chain instead: it creates the target bone at the current tip and derives `bendPositive` from the pose. " + CONSTRAINT_DOC,
5764
+ "Create or patch an IK constraint: bend a chain of bones so its tip reaches a TARGET BONE \u2014 move the target, the limb follows. `bones` is the chain parent\u2192child (1\u20132 bones: analytic solve; 3+: iterative CCD), `target` is a BONE, normally a marker parented to the root so the limb reaches a place in the world. `bendPositive` picks which way a two-bone chain buckles (knee forward or backward), `softness` eases the last px before full extension, `compress`/`stretch` let the chain shorten/lengthen to reach, `mix` 0..1 fades the whole constraint. Fields are merged, so {id, mix} patches only the mix. To rig a limb from scratch call rig_add_ik_chain instead: it creates the target bone at the current tip and derives `bendPositive` from the pose. " + CONSTRAINT_DOC,
4920
5765
  {
4921
5766
  path: Path,
4922
- id: z2.string(),
5767
+ id: z3.string(),
4923
5768
  bones: ConstraintBones,
4924
- target: z2.string().optional().describe("BONE the chain reaches for"),
4925
- mix: z2.number().optional(),
4926
- softness: z2.number().optional(),
4927
- bendPositive: z2.boolean().optional(),
4928
- compress: z2.boolean().optional(),
4929
- stretch: z2.boolean().optional(),
4930
- uniform: z2.boolean().optional(),
5769
+ target: z3.string().optional().describe("BONE the chain reaches for"),
5770
+ mix: z3.number().optional(),
5771
+ softness: z3.number().optional(),
5772
+ bendPositive: z3.boolean().optional(),
5773
+ iterations: z3.number().int().min(1).max(1e3).optional(),
5774
+ tolerance: z3.number().positive().optional(),
5775
+ compress: z3.boolean().optional(),
5776
+ stretch: z3.boolean().optional(),
5777
+ uniform: z3.boolean().optional(),
4931
5778
  order: ConstraintOrder
4932
5779
  },
4933
5780
  async ({ path, id, ...patch }) => mutate(path, (d) => setConstraint(d, "ik", id, patch))
@@ -4937,17 +5784,17 @@ tool(
4937
5784
  "Create or patch a transform constraint: copy a TARGET BONE's transform onto the driven bones, channel by channel (mixRotate/mixX/mixY/mixScaleX/mixScaleY/mixShearY, 0..1, each keyable), with constant offsets (rotation/x/y/scaleX/scaleY/shearY) added on top. `local` copies in the bones' local space instead of world; `relative` ADDS the target's transform to the bone's own instead of replacing it. Use it to make parts follow one control: a cape following the torso, a mirrored limb, a bone that inherits half the rotation. Fields are merged; `bones` and `target` (a BONE) are required when it is created. " + CONSTRAINT_DOC,
4938
5785
  {
4939
5786
  path: Path,
4940
- id: z2.string(),
5787
+ id: z3.string(),
4941
5788
  bones: ConstraintBones,
4942
- target: z2.string().optional().describe("BONE whose transform is copied"),
4943
- local: z2.boolean().optional(),
4944
- relative: z2.boolean().optional(),
4945
- rotation: z2.number().optional(),
4946
- x: z2.number().optional(),
4947
- y: z2.number().optional(),
4948
- scaleX: z2.number().optional(),
4949
- scaleY: z2.number().optional(),
4950
- shearY: z2.number().optional(),
5789
+ target: z3.string().optional().describe("BONE whose transform is copied"),
5790
+ local: z3.boolean().optional(),
5791
+ relative: z3.boolean().optional(),
5792
+ rotation: z3.number().optional(),
5793
+ x: z3.number().optional(),
5794
+ y: z3.number().optional(),
5795
+ scaleX: z3.number().optional(),
5796
+ scaleY: z3.number().optional(),
5797
+ shearY: z3.number().optional(),
4951
5798
  ...Mixes,
4952
5799
  order: ConstraintOrder
4953
5800
  },
@@ -4958,15 +5805,15 @@ tool(
4958
5805
  "Create or patch a path constraint: place and orient bones along the curve of a path attachment (a tail following a spline, a chain crawling along a road). `target` is a SLOT whose visible attachment is a path \u2014 build the curve with rig_set_path_attachment, then show it with rig_set_slot {attachment}. positionMode fixed|percent (position in px along the curve, or 0..1 of its length), spacingMode length|fixed|percent|proportional (how the bones are spread along it), rotateMode tangent|chain|chainScale. `position` and `spacing` slide the bones along the curve and are the usual things to key. Fields are merged; `bones` and `target` are required when it is created. " + CONSTRAINT_DOC,
4959
5806
  {
4960
5807
  path: Path,
4961
- id: z2.string(),
5808
+ id: z3.string(),
4962
5809
  bones: ConstraintBones,
4963
- target: z2.string().optional().describe("SLOT showing a path attachment"),
4964
- positionMode: z2.enum(["fixed", "percent"]).optional(),
4965
- spacingMode: z2.enum(["length", "fixed", "percent", "proportional"]).optional(),
4966
- rotateMode: z2.enum(["tangent", "chain", "chainScale"]).optional(),
4967
- rotation: z2.number().optional(),
4968
- position: z2.number().optional(),
4969
- spacing: z2.number().optional(),
5810
+ target: z3.string().optional().describe("SLOT showing a path attachment"),
5811
+ positionMode: z3.enum(["fixed", "percent"]).optional(),
5812
+ spacingMode: z3.enum(["length", "fixed", "percent", "proportional"]).optional(),
5813
+ rotateMode: z3.enum(["tangent", "chain", "chainScale"]).optional(),
5814
+ rotation: z3.number().optional(),
5815
+ position: z3.number().optional(),
5816
+ spacing: z3.number().optional(),
4970
5817
  ...PathMixes,
4971
5818
  order: ConstraintOrder
4972
5819
  },
@@ -4975,7 +5822,7 @@ tool(
4975
5822
  tool(
4976
5823
  "rig_remove_constraint",
4977
5824
  "Remove a constraint of any kind (ik / transform / path) by id, together with its animation tracks; the bones it drove fall back to their keyed or rest transforms.",
4978
- { path: Path, id: z2.string() },
5825
+ { path: Path, id: z3.string() },
4979
5826
  async ({ path, id }) => mutate(path, (d) => removeConstraint(d, id))
4980
5827
  );
4981
5828
  tool(
@@ -4983,13 +5830,14 @@ tool(
4983
5830
  "Rig a limb in ONE call \u2014 the easy path to IK. Give the chain's LAST bone (shin, forearm): the chain is [parent, bone] when that parent has exactly this one child, otherwise just the bone. The tool creates a target bone (role ik_target) at the chain's current tip AS A CHILD OF THE ROOT BONE \u2014 never inside the chain, where the IK would drag its own target \u2014 and an IK constraint `<bone>_ik` whose `bendPositive` is derived from the CURRENT pose, so adding it does not move the rig. It is solved last (highest order). When the two-bone chain would reach up to the root itself, it falls back to the single bone (which just aims at the target) and says so. Afterwards pose the limb by moving or keying the TARGET bone (or fade the constraint with its mix), not the chain's rotations.",
4984
5831
  {
4985
5832
  path: Path,
4986
- bone: z2.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
4987
- target: z2.string().optional().describe("id for the new target bone; default <bone>_ik"),
4988
- mix: z2.number().default(1),
4989
- softness: z2.number().default(0)
5833
+ bone: z3.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
5834
+ bones: z3.array(z3.string()).min(1).optional().describe("explicit parent-to-tip chain for long IK"),
5835
+ target: z3.string().optional().describe("id for the new target bone; default <bone>_ik"),
5836
+ mix: z3.number().default(1),
5837
+ softness: z3.number().default(0)
4990
5838
  },
4991
- async ({ path, bone, target, mix, softness }) => mutateWith(path, (d, say) => {
4992
- const r = addIkChain(d, bone, { target, mix, softness });
5839
+ async ({ path, bone, target, mix, softness, bones }) => mutateWith(path, (d, say) => {
5840
+ const r = addIkChain(d, bone, { target, mix, softness, bones });
4993
5841
  say(`ik "${bone}_ik": chain ${r.chain.join(",")}${r.note ? ` (${r.note})` : ""} \u2192 new target bone "${r.target}" (bend ${r.bendPositive ? "+" : "-"}); move or key "${r.target}" to pose the limb`);
4994
5842
  return r.doc;
4995
5843
  }, true)
@@ -4999,12 +5847,12 @@ tool(
4999
5847
  "Create or replace a PATH attachment. Two forms, exactly one per call (both need at least 2 anchors, 3 when closed): `points` \u2014 anchors in DOCUMENT coordinates \u2014 rebuilds the curve with fresh Catmull-Rom tangents, so it DESTROYS a skinned path's weights and any authored tangents; the form for drawing a path from scratch. `vertices` / `weights` \u2014 raw control points [cPrev, p, cNext] per anchor, in the slot bone's space (or skinned: `weights` gives one influence list per control point, {bone,x,y,w} like rig_set_weights) \u2014 the ONLY form that preserves weights and authored tangents; use it to edit an existing path, and rig_path_anchor to add or remove an anchor without a full replace. Show it with rig_set_slot {id: <slot>, attachment: <id>}, then drive bones along it with rig_set_path. `closed` keeps its current value when omitted on an existing path (false on creation). Deform `vertices` keys whose length no longer fits the new geometry are dropped \u2014 and a `points` rebuild that happens to keep the same count keeps them too, now deforming a DIFFERENT curve, which is the other reason to edit through the raw form. `lengths` are always re-measured, so an imported path's stored values move by up to ~0.5 % even when the shape is byte-identical; that is nothing to act on, because every path here has `constantSpeed: true` (all of ross's do, and no tool can turn it off) and the runtime then measures the posed curve itself and never reads them.",
5000
5848
  {
5001
5849
  path: Path,
5002
- id: z2.string().describe("path attachment id"),
5003
- slot: z2.string(),
5004
- points: z2.array(z2.tuple([z2.number(), z2.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
5005
- vertices: z2.array(z2.number()).optional(),
5850
+ id: z3.string().describe("path attachment id"),
5851
+ slot: z3.string(),
5852
+ points: z3.array(z3.tuple([z3.number(), z3.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
5853
+ vertices: z3.array(z3.number()).optional(),
5006
5854
  weights: WeightsIn.optional(),
5007
- closed: z2.boolean().optional()
5855
+ closed: z3.boolean().optional()
5008
5856
  },
5009
5857
  async ({ path, id, slot, points, vertices, weights, closed }) => mutateWith(path, (d, say) => {
5010
5858
  const { doc: next, dropped } = setPathAttachment(d, id, slot, { points, vertices, weights, closed });
@@ -5019,10 +5867,10 @@ tool(
5019
5867
  "Insert or remove one anchor of a path attachment. `insert` splits the curve AFTER anchor `index` at `t` (strictly between 0 and 1, default 0.5) using de Casteljau, so the curve keeps its exact shape in EVERY pose, not just at rest, and a weighted path keeps its skinning (each new control point is the c*w-weighted blend of the split segment's influences, which is an identity in the bone matrices). It also keeps the attachment's DEFORM tracks: the same coefficients blend the four source control points' offsets, so the ANIMATED curve is unchanged too \u2014 an insert loses nothing. `remove` drops the anchor's three control points, which does change the shape, and with it any deform key whose length no longer fits (reported in the reply).",
5020
5868
  {
5021
5869
  path: Path,
5022
- id: z2.string().describe("path attachment id"),
5023
- op: z2.enum(["insert", "remove"]),
5024
- index: z2.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
5025
- t: z2.number().gt(0).lt(1).optional().describe("insert only: where in the segment, strictly between 0 and 1 (an endpoint is the anchor that is already there), default 0.5")
5870
+ id: z3.string().describe("path attachment id"),
5871
+ op: z3.enum(["insert", "remove"]),
5872
+ index: z3.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
5873
+ t: z3.number().gt(0).lt(1).optional().describe("insert only: where in the segment, strictly between 0 and 1 (an endpoint is the anchor that is already there), default 0.5")
5026
5874
  },
5027
5875
  async ({ path, id, op, index, t }) => mutateWith(path, (d, say) => {
5028
5876
  const { doc, dropped } = pathAnchorOp(d, id, op, index, t);
@@ -5036,9 +5884,9 @@ tool(
5036
5884
  "Generate an animation from a parametric preset (replaces the animation with the same id). Built-in presets bind to bones by `role`; template presets (rig_save_as_preset) come from the library and match bones by role, then id. Re-tune by re-applying with different params.\nCharacter: idle_breathing (bulges the torso image, neck/waist pinned), weight_shift (feet stay planted), win_bounce, anticipation_shake, blink, mouth_talk. Symbol: symbol_idle, symbol_win, dim (state pose, use as base).\n" + presetHelp,
5037
5885
  {
5038
5886
  path: Path,
5039
- preset: z2.string().describe("built-in name or a template name from the library"),
5040
- params: z2.record(z2.unknown()).default({}).describe("partial override of defaults"),
5041
- id: z2.string().optional().describe("animation id for templates (default: template name)"),
5887
+ preset: z3.string().describe("built-in name or a template name from the library"),
5888
+ params: z3.record(z3.unknown()).default({}).describe("partial override of defaults"),
5889
+ id: z3.string().optional().describe("animation id for templates (default: template name)"),
5042
5890
  library: Library
5043
5891
  },
5044
5892
  async ({ path, preset, params, id, library }) => mutate(path, async (d) => {
@@ -5051,7 +5899,7 @@ tool(
5051
5899
  tool(
5052
5900
  "rig_save_as_preset",
5053
5901
  'Turn a finished animation into a reusable template: tracks stored relative to rest and keyed by bone role, params amplitude (all relative values), speed (time) and optional named groups of source tracks (`{"turn": ["bone.root.rotation"]}`) that become their own multipliers. Apply on any rig with rig_apply_preset.',
5054
- { path: Path, animation: z2.string(), name: z2.string().describe("snake_case template name"), groups: z2.record(z2.array(z2.string())).optional(), library: Library },
5902
+ { path: Path, animation: z3.string(), name: z3.string().describe("snake_case template name"), groups: z3.record(z3.array(z3.string())).optional(), library: Library },
5055
5903
  async ({ path, animation, name, groups, library }) => {
5056
5904
  try {
5057
5905
  const tpl = extractTemplate(await load(path), animation, name, groups);
@@ -5083,7 +5931,7 @@ ${tpls.length ? tpls.map(describeTemplate).join("\n") : " none"}`);
5083
5931
  tool(
5084
5932
  "rig_get_preset",
5085
5933
  "One preset as JSON: {builtin: true, name, defaults} for a built-in, or the library template (tracks, params). For editors that preview parameter changes locally.",
5086
- { path: Path, preset: z2.string(), library: Library },
5934
+ { path: Path, preset: z3.string(), library: Library },
5087
5935
  async ({ path, preset, library }) => {
5088
5936
  try {
5089
5937
  if (preset in presets) {
@@ -5101,9 +5949,9 @@ tool(
5101
5949
  tool(
5102
5950
  "rig_set_keys",
5103
5951
  "Set the keys of ONE track (creates the animation if missing; manual keys drop the animation's preset link). Replaces all keys unless merge=true, which replaces only keys at the given times and keeps the rest. duration/loop are optional and persist once set. " + KEY_DOC,
5104
- { path: Path, animation: z2.string(), ...TrackRef, keys: z2.array(KeyIn), merge: z2.boolean().default(false), duration: z2.number().optional(), loop: z2.boolean().optional() },
5105
- async ({ path, animation, target, id, prop, keys, merge, duration, loop }) => mutate(path, (d) => {
5106
- let n = setKeys(d, animation, { target, id, prop }, keys.map(roundKey), duration, { merge });
5952
+ { path: Path, animation: z3.string(), ...TrackRef, keys: z3.array(KeyIn), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
5953
+ async ({ path, animation, target, id, prop, interpolation, keys, merge, duration, loop }) => mutate(path, (d) => {
5954
+ let n = setKeys(d, animation, { target, id, prop, interpolation }, keys.map(roundKey), duration, { merge });
5107
5955
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5108
5956
  return n;
5109
5957
  })
@@ -5111,10 +5959,10 @@ tool(
5111
5959
  tool(
5112
5960
  "rig_set_tracks",
5113
5961
  "Set several tracks of one animation in a single call (same semantics as rig_set_keys for each). " + KEY_DOC,
5114
- { path: Path, animation: z2.string(), tracks: z2.array(z2.object({ ...TrackRef, keys: z2.array(KeyIn) })).min(1), merge: z2.boolean().default(false), duration: z2.number().optional(), loop: z2.boolean().optional() },
5962
+ { path: Path, animation: z3.string(), tracks: z3.array(z3.object({ ...TrackRef, keys: z3.array(KeyIn) })).min(1), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
5115
5963
  async ({ path, animation, tracks, merge, duration, loop }) => mutate(path, (d) => {
5116
5964
  let n = d;
5117
- for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop }, t.keys.map(roundKey), duration, { merge });
5965
+ for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop, interpolation: t.interpolation }, t.keys.map(roundKey), duration, { merge });
5118
5966
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5119
5967
  return n;
5120
5968
  })
@@ -5124,11 +5972,11 @@ tool(
5124
5972
  "Put a bone at a point in DOCUMENT coordinates \u2014 the same coordinates rig_get_pose reports and rig_render_preview draws. Without `animation` it moves the setup pose; with one it keys x and y at `t`, creating the animation if it does not exist yet (`t` and `ease` without an `animation` are an error, not a no-op). If the bone is driven by a LIVE ik constraint the edit is redirected to that constraint's TARGET BONE, whose origin is then what lands on the point (the answer says so), because writing the bone itself would be overwritten by the solver. For a rig_add_ik_chain limb that target sits at the chain's tip, so AT FULL MIX the tip reaches the point while the named bone only turns \u2014 a goal on the chain's FIRST bone does not move that bone's origin at all, and a partly mixed chain lands the target but leaves the tip short. A live transform or path constraint refuses instead: nothing can be written there, so key its mix to 0 at that time or edit the constraint. The point is converted using the pose AT THAT TIME, so a turned parent is accounted for. The result is re-evaluated before it is saved: if what was written does not actually land on the point (a target a second solver owns, or one parented inside the chain that aims it) the call fails and writes nothing. On success the answer gives the NAMED bone's world origin and tip after the edit.",
5125
5973
  {
5126
5974
  path: Path,
5127
- bone: z2.string(),
5128
- x: z2.number(),
5129
- y: z2.number(),
5130
- animation: z2.string().optional(),
5131
- t: z2.number().min(0).optional(),
5975
+ bone: z3.string(),
5976
+ x: z3.number(),
5977
+ y: z3.number(),
5978
+ animation: z3.string().optional(),
5979
+ t: z3.number().min(0).optional(),
5132
5980
  ease: Ease.optional()
5133
5981
  },
5134
5982
  async ({ path, bone, x, y, ...opts }) => mutateWith(path, (d, say) => {
@@ -5143,11 +5991,11 @@ tool(
5143
5991
  "Copy the bone tracks of one set of bones onto another, pair by pair, optionally shifted in time and mirrored. `phase` is a fraction of the animation's duration (0.5 = half a cycle later, 1 wraps back to 0). `mirrorX` negates x, rotation, shearX and shearY, which mirrors the motion across the PARENT's y axis \u2014 that is a left/right mirror only when the destination's parent frame is itself the mirror image of the source's, which is not automatic: on a rig whose shoulders are not conjugate frames a mirrored arm copy points the SAME way in the document. Destination tracks are replaced per prop, not merged. Data can be lost: a track keyed at both 0 and `duration` with DIFFERENT values loses the segment into that last key when `phase` shifts it, and a non-looping animation has the segment between its last and first key split \u2014 both are reported in the answer. This is how the second leg of a cycle is made from the first \u2014 it is an operation, not a preset: nothing is stored or reused.",
5144
5992
  {
5145
5993
  path: Path,
5146
- animation: z2.string(),
5147
- from: z2.array(z2.string()).min(1),
5148
- to: z2.array(z2.string()).min(1),
5149
- phase: z2.number().min(0).max(1).default(0),
5150
- mirrorX: z2.boolean().default(false)
5994
+ animation: z3.string(),
5995
+ from: z3.array(z3.string()).min(1),
5996
+ to: z3.array(z3.string()).min(1),
5997
+ phase: z3.number().min(0).max(1).default(0),
5998
+ mirrorX: z3.boolean().default(false)
5151
5999
  },
5152
6000
  async ({ path, animation, from, to, phase, mirrorX }) => mutateWith(path, (d, say) => {
5153
6001
  const { doc, copied, empty, lossy } = copyTracks(d, animation, from, to, { phase, mirrorX });
@@ -5161,7 +6009,7 @@ tool(
5161
6009
  tool(
5162
6010
  "rig_adjust_track",
5163
6011
  "Re-tune an existing track without retyping keys: amplitude scales its deviation from the rest value (0.5 = half as strong, 2 = twice; scale props as ratios), offset adds a constant, timeScale stretches its key times (duration grows to fit). Drops the preset link.",
5164
- { path: Path, animation: z2.string(), ...TrackRef, amplitude: z2.number().optional(), offset: z2.number().optional(), timeScale: z2.number().positive().optional() },
6012
+ { path: Path, animation: z3.string(), ...TrackRef, amplitude: z3.number().optional(), offset: z3.number().optional(), timeScale: z3.number().positive().optional() },
5165
6013
  async ({ path, animation, target, id, prop, ...adj }) => mutate(path, (d) => adjustTrack(d, animation, { target, id, prop }, adj))
5166
6014
  );
5167
6015
  tool(
@@ -5169,47 +6017,108 @@ tool(
5169
6017
  "Animation properties without touching keys: duration (must cover the last key/event), loop, events (full list), rename. Creates the animation if missing.",
5170
6018
  {
5171
6019
  path: Path,
5172
- id: z2.string(),
5173
- duration: z2.number().positive().optional(),
5174
- loop: z2.boolean().optional(),
5175
- events: z2.array(z2.object({ t: z2.number().nonnegative(), name: z2.string(), payload: z2.record(z2.unknown()).optional() })).optional(),
5176
- rename: z2.string().optional()
6020
+ id: z3.string(),
6021
+ duration: z3.number().positive().optional(),
6022
+ loop: z3.boolean().optional(),
6023
+ events: z3.array(z3.object({ t: z3.number().nonnegative(), name: z3.string(), payload: z3.record(z3.unknown()).optional() })).optional(),
6024
+ rename: z3.string().optional()
5177
6025
  },
5178
6026
  async ({ path, id, ...patch }) => mutate(path, (d) => setAnimation(d, id, patch))
5179
6027
  );
5180
6028
  tool(
5181
6029
  "rig_remove_keys",
5182
6030
  "Remove the keys of one track at the given times; an emptied track is dropped. Drops the preset link.",
5183
- { path: Path, animation: z2.string(), ...TrackRef, times: z2.array(z2.number()).min(1) },
6031
+ { path: Path, animation: z3.string(), ...TrackRef, times: z3.array(z3.number()).min(1) },
5184
6032
  async ({ path, animation, target, id, prop, times: times2 }) => mutate(path, (d) => removeKeys(d, animation, { target, id, prop }, times2))
5185
6033
  );
5186
6034
  tool(
5187
6035
  "rig_remove_animation",
5188
6036
  "Delete an animation.",
5189
- { path: Path, id: z2.string() },
6037
+ { path: Path, id: z3.string() },
5190
6038
  async ({ path, id }) => mutate(path, (d) => removeAnimation(d, id), true)
5191
6039
  );
6040
+ tool(
6041
+ "rig_order_constraints",
6042
+ "Sort constraints by what they read and write, including target ancestors and skinned paths. Cycles are refused.",
6043
+ { path: Path },
6044
+ async ({ path }) => mutate(path, orderConstraints)
6045
+ );
6046
+ tool(
6047
+ "rig_gradient_weights",
6048
+ "Skin a mesh with a gradient along ordered chain joints, preserving its setup geometry.",
6049
+ { path: Path, id: z3.string(), bones: z3.array(z3.string()).min(2) },
6050
+ async ({ path, id, bones }) => mutate(path, (d) => gradientWeights(d, id, bones))
6051
+ );
6052
+ tool(
6053
+ "rig_smooth_weights",
6054
+ "Smooth weights over triangle neighbours. Locked vertex indices keep their weights. Deform tracks are dropped if the influence layout changes.",
6055
+ { path: Path, id: z3.string(), iterations: z3.number().int().min(1).max(100).default(3), strength: z3.number().min(0).max(1).default(0.5), locked: z3.array(z3.number().int().nonnegative()).default([]) },
6056
+ async ({ path, id, iterations, strength, locked }) => mutate(path, (d) => smoothWeights(d, id, iterations, strength, locked))
6057
+ );
6058
+ tool(
6059
+ "rig_pin_weights",
6060
+ "Bind selected mesh vertices rigidly to one bone. Select by indices or document-space rectangle, e.g. the solid body of a prop while leaving its strap flexible.",
6061
+ { path: Path, id: z3.string(), bone: z3.string(), vertices: z3.array(z3.number().int().nonnegative()).optional(), region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional() },
6062
+ async ({ path, id, bone, vertices, region }) => mutate(path, (d) => pinWeights(d, id, bone, vertices, region))
6063
+ );
6064
+ tool(
6065
+ "rig_attach_to_bone",
6066
+ "At an animation time, switch a prop bone to follow a target bone without changing its evaluated world pose. Creates keyed transform constraints and computes offsets after IK. Author successive contacts in time order; key hand attachment swaps at the same time.",
6067
+ { path: Path, animation: z3.string(), bone: z3.string(), target: z3.string(), t: z3.number().nonnegative() },
6068
+ async ({ path, animation, bone, target, t }) => mutate(path, (d) => attachToBone(d, animation, bone, target, t))
6069
+ );
6070
+ tool(
6071
+ "rig_bake_secondary",
6072
+ "Bake velocity-driven damped spring rotations into editable PCHIP tracks. Uses evaluated world rotation or x/y motion (including IK). A looping driver must close; loop state is settled before baking and starts at setup within maxAngle. No runtime physics is required.",
6073
+ { path: Path, animation: z3.string(), driver: z3.string(), driverProp: z3.enum(["rotation", "x", "y"]).default("rotation"), bones: z3.array(z3.string()).min(1), frequency: z3.number().positive().default(2.5), damping: z3.number().positive().default(0.65), gain: z3.number().default(0.2), fps: z3.number().int().min(1).max(240).default(60), maxAngle: z3.number().positive().default(30) },
6074
+ async ({ path, animation, ...opts }) => mutate(path, (d) => bakeSecondary(d, animation, opts))
6075
+ );
5192
6076
  tool(
5193
6077
  "rig_render_preview",
5194
6078
  "Render frames of an animation (or the rest pose) as a contact-sheet PNG, plus the rig_check findings for that animation (or a positive one-liner). Use it after every change to SEE the result. overlayBones=true draws the skeleton with ids; onionSkin=true ghosts the previous frame of the sheet under each frame (the first frame has no ghost); region=[x,y,w,h] in document px crops to a detail (a face) so small motion is legible at low token cost.",
5195
6079
  {
5196
6080
  path: Path,
5197
- animation: z2.string().optional(),
5198
- fps: z2.number().default(8),
5199
- count: z2.number().int().optional(),
5200
- times: z2.array(z2.number()).optional(),
5201
- overlayBones: z2.boolean().default(false),
5202
- onionSkin: z2.boolean().default(false),
5203
- region: z2.tuple([z2.number(), z2.number(), z2.number(), z2.number()]).optional(),
5204
- scale: z2.number().default(0.5).describe("downscale to save tokens"),
5205
- sheetColumns: z2.number().int().default(4),
5206
- saveTo: z2.string().optional().describe("also write sheet PNG here")
6081
+ animation: z3.string().optional(),
6082
+ fps: z3.number().default(8),
6083
+ count: z3.number().int().optional(),
6084
+ times: z3.array(z3.number()).optional(),
6085
+ transparent: z3.boolean().default(false),
6086
+ background: z3.string().optional(),
6087
+ weightBone: z3.string().optional(),
6088
+ debugFillMasks: z3.record(z3.string()).optional(),
6089
+ framesDir: z3.string().optional(),
6090
+ overlayBones: z3.boolean().default(false),
6091
+ onionSkin: z3.boolean().default(false),
6092
+ region: z3.tuple([z3.number(), z3.number(), z3.number(), z3.number()]).optional(),
6093
+ scale: z3.number().default(0.5).describe("downscale to save tokens"),
6094
+ sheetColumns: z3.number().int().default(4),
6095
+ saveTo: z3.string().optional().describe("also write sheet PNG here")
5207
6096
  },
5208
- async ({ path, saveTo, ...opts }) => {
6097
+ async ({ path, saveTo, framesDir, ...opts }) => {
5209
6098
  try {
5210
- const doc = await load(path);
5211
- const r = await renderPreview(doc, { assetsDir: dirname6(path), ...opts });
5212
- if (saveTo) await writeFile6(saveTo, r.sheet);
6099
+ const raw = await readFile9(path, "utf8");
6100
+ const doc = parse(JSON.parse(raw));
6101
+ const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6102
+ if (saveTo) await writeFile7(saveTo, r.sheet);
6103
+ if (framesDir) {
6104
+ const { mkdir: mkdir7 } = await import("node:fs/promises");
6105
+ await mkdir7(framesDir, { recursive: true });
6106
+ const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6107
+ for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6108
+ const manifest = {
6109
+ version: 1,
6110
+ rigFile: sha256(raw),
6111
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
6112
+ identity: r.identity,
6113
+ animation: opts.animation ?? null,
6114
+ transparent: opts.transparent,
6115
+ scale: opts.scale,
6116
+ times: r.times,
6117
+ frames,
6118
+ render: { background: opts.background, overlayBones: opts.overlayBones, onionSkin: opts.onionSkin, weightBone: opts.weightBone, debugFillMasks: opts.debugFillMasks ?? {}, region: opts.region }
6119
+ };
6120
+ await writeFile7(join9(framesDir, "manifest.json"), JSON.stringify(manifest, null, 2));
6121
+ }
5213
6122
  return { text: `${r.times.length} frames @ ${r.width}x${r.height}, t=[${r.times.join(", ")}]
5214
6123
  ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5215
6124
  } catch (e) {
@@ -5220,14 +6129,55 @@ ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5220
6129
  tool(
5221
6130
  "rig_export",
5222
6131
  "Export for a game: pack all PNG layers into <outDir>/atlas.png and write <outDir>/rig.json whose assets point into it (asset.frame). One texture per rig instead of one per layer.",
5223
- { path: Path, outDir: z2.string().describe("absolute, or relative to rig.json dir"), padding: z2.number().int().default(2), maxSize: z2.number().int().default(4096) },
6132
+ { path: Path, outDir: z3.string().describe("absolute, or relative to rig.json dir"), padding: z3.number().int().default(2), maxSize: z3.number().int().default(4096) },
5224
6133
  async ({ path, outDir, padding, maxSize }) => {
5225
6134
  try {
5226
6135
  const out = resolveFrom(path, outDir);
5227
- const doc = await buildAtlas(await load(path), dirname6(path), out, { padding, maxSize });
5228
- const [w, h] = pngSize(await readFile8(join7(out, "atlas.png")));
5229
- return text(`\u2192 ${join7(out, "rig.json")}
5230
- \u2192 ${join7(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6136
+ const doc = await buildAtlas(await load(path), dirname8(path), out, { padding, maxSize });
6137
+ const [w, h] = pngSize(await readFile9(join9(out, "atlas.png")));
6138
+ return text(`\u2192 ${join9(out, "rig.json")}
6139
+ \u2192 ${join9(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6140
+ } catch (e) {
6141
+ return fail(e);
6142
+ }
6143
+ }
6144
+ );
6145
+ tool(
6146
+ "rig_export_video",
6147
+ "Export runtime playback to MP4 (60 fps by default). Requires ffmpeg. Duration is rounded to a whole frame and reported. Output has an opaque presentation background; use transparent preview frames for alpha QA.",
6148
+ { path: Path, animation: z3.string(), outFile: z3.string(), fps: z3.number().int().min(1).max(240).default(60), scale: z3.number().positive().default(1), background: z3.string().optional(), weightBone: z3.string().optional(), debugFillMasks: z3.record(z3.string()).optional() },
6149
+ async ({ path, outFile, ...opts }) => {
6150
+ try {
6151
+ const result = await exportVideo(await load(path), { assetsDir: dirname8(path), outFile: resolveFrom(path, outFile), ...opts });
6152
+ return text(`${resolveFrom(path, outFile)}: ${result.frames} frames, ${result.fps} fps, ${result.duration}s`);
6153
+ } catch (e) {
6154
+ return fail(e);
6155
+ }
6156
+ }
6157
+ );
6158
+ tool(
6159
+ "rig_verify_render",
6160
+ "Fail if render inputs or outputs changed, frames are absent/extra, or the capture does not cover the explicit expected times. Run before image gates; a manifest alone is not an art-quality pass.",
6161
+ { path: Path, framesDir: z3.string(), animation: z3.string().optional(), times: z3.array(z3.number().nonnegative()).min(1), transparent: z3.boolean().optional() },
6162
+ async ({ path, framesDir, ...expected }) => {
6163
+ try {
6164
+ const here = dirname8(fileURLToPath2(import.meta.url));
6165
+ const source = join9(here, "preview/harness.js"), bundled = join9(here, "harness.js");
6166
+ const manifest = await verifyRenderManifest(path, framesDir, { ...expected, renderer: await exists2(source) ? source : bundled });
6167
+ return text(`verified ${manifest.frames.length}/${expected.times.length} frames; inputs, renderer and frame hashes match`);
6168
+ } catch (e) {
6169
+ return fail(e);
6170
+ }
6171
+ }
6172
+ );
6173
+ tool(
6174
+ "rig_run_gates",
6175
+ "Run explicit motion, proportion/contact, flash/pose, animation-ID and real AnimationState transition gates. Report sampling, amplitudes, speed/acceleration/jerk and failures; thresholds are supplied by the brief.",
6176
+ { path: Path, config: GateConfig },
6177
+ async ({ path, config }) => {
6178
+ try {
6179
+ const report = runGates(await load(path), config);
6180
+ return { text: JSON.stringify(report, null, 2), isError: !report.pass };
5231
6181
  } catch (e) {
5232
6182
  return fail(e);
5233
6183
  }
@@ -5238,7 +6188,7 @@ tool(
5238
6188
  var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".map": "application/json", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp" };
5239
6189
  async function startEditorServer(opts) {
5240
6190
  const root2 = resolve2(opts.root);
5241
- const assets = resolve2(opts.assets ?? fileURLToPath2(new URL("../..", import.meta.url)));
6191
+ const assets = resolve2(opts.assets ?? fileURLToPath3(new URL("../..", import.meta.url)));
5242
6192
  const inside = (rel) => {
5243
6193
  const abs = resolve2(root2, rel);
5244
6194
  return abs === root2 || abs.startsWith(root2 + sep) ? abs : void 0;
@@ -5251,7 +6201,7 @@ async function startEditorServer(opts) {
5251
6201
  return "missing";
5252
6202
  }
5253
6203
  };
5254
- const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile9(abs, "utf8")));
6204
+ const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile10(abs, "utf8")));
5255
6205
  const readDocAt = async (abs) => {
5256
6206
  let v = await version(abs);
5257
6207
  for (let i = 0; i < 3; i++) {
@@ -5272,7 +6222,7 @@ async function startEditorServer(opts) {
5272
6222
  const watchers = /* @__PURE__ */ new Map();
5273
6223
  function ensureWatch(abs) {
5274
6224
  if (watchers.has(abs)) return;
5275
- const entry = { w: fsWatch(dirname7(abs), { persistent: false }, (_e, file) => {
6225
+ const entry = { w: fsWatch(dirname9(abs), { persistent: false }, (_e, file) => {
5276
6226
  if (file && file !== basename5(abs)) return;
5277
6227
  schedule();
5278
6228
  }), last: "" };
@@ -5302,16 +6252,16 @@ data: ${JSON.stringify({ version: v })}
5302
6252
  async function listRigs() {
5303
6253
  const out = [];
5304
6254
  const walk = async (dir) => {
5305
- for (const e of await readdir3(dir, { withFileTypes: true })) {
6255
+ for (const e of await readdir4(dir, { withFileTypes: true })) {
5306
6256
  if (e.isDirectory()) {
5307
- if (!SKIP.has(e.name)) await walk(join8(dir, e.name));
6257
+ if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
5308
6258
  continue;
5309
6259
  }
5310
6260
  if (e.name !== "rig.json") continue;
5311
- const abs = join8(dir, e.name);
6261
+ const abs = join10(dir, e.name);
5312
6262
  let name = "?";
5313
6263
  try {
5314
- name = String(JSON.parse(await readFile9(abs, "utf8")).meta?.name ?? "?");
6264
+ name = String(JSON.parse(await readFile10(abs, "utf8")).meta?.name ?? "?");
5315
6265
  } catch {
5316
6266
  }
5317
6267
  out.push({ rig: relative3(root2, abs).split(sep).join("/"), name, mtimeMs: (await stat3(abs)).mtimeMs });
@@ -5321,9 +6271,9 @@ data: ${JSON.stringify({ version: v })}
5321
6271
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
5322
6272
  }
5323
6273
  async function listFiles(rigAbs) {
5324
- const dir = dirname7(rigAbs), out = [];
5325
- for (const e of (await readdir3(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
5326
- const fh = await open(join8(dir, e.name));
6274
+ const dir = dirname9(rigAbs), out = [];
6275
+ for (const e of (await readdir4(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6276
+ const fh = await open(join10(dir, e.name));
5327
6277
  try {
5328
6278
  const head = Buffer.alloc(24);
5329
6279
  const { bytesRead } = await fh.read(head, 0, 24, 0);
@@ -5358,13 +6308,13 @@ data: ${JSON.stringify({ version: v })}
5358
6308
  }
5359
6309
  if (url.pathname.startsWith("/api/")) return await api(url, req, res);
5360
6310
  const STATIC = /* @__PURE__ */ new Set(["/editor.html", "/dist/editor.js", "/dist/editor.js.map", "/dist/editor.css", "/dist/editor.css.map"]);
5361
- const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join8(assets, url.pathname) : void 0;
6311
+ const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join10(assets, url.pathname) : void 0;
5362
6312
  if (!abs) {
5363
6313
  res.writeHead(403);
5364
6314
  return res.end("forbidden");
5365
6315
  }
5366
6316
  try {
5367
- const data = await readFile9(abs);
6317
+ const data = await readFile10(abs);
5368
6318
  res.writeHead(200, { "content-type": TYPES[extname2(abs)] ?? "application/octet-stream" });
5369
6319
  res.end(data);
5370
6320
  } catch {
@@ -5440,8 +6390,8 @@ data: ${JSON.stringify({ version: v })}
5440
6390
  const abs = inside(rig);
5441
6391
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
5442
6392
  if (await stat3(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
5443
- await mkdir5(dirname7(abs), { recursive: true });
5444
- const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname7(abs)), width: width ?? 640, height: height ?? 700 });
6393
+ await mkdir6(dirname9(abs), { recursive: true });
6394
+ const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
5445
6395
  ensureWatch(abs);
5446
6396
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });
5447
6397
  }
@@ -5456,7 +6406,7 @@ data: ${JSON.stringify({ version: v })}
5456
6406
  const name = url.searchParams.get("name") ?? "";
5457
6407
  if (!/^[A-Za-z0-9._-]+\.png$/i.test(name) || name.startsWith(".")) return json(res, 400, { isError: true, text: `ERROR: bad file name "${name}" \u2014 letters, digits, . _ - and a .png extension` });
5458
6408
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("image/png")) return json(res, 415, { isError: true, text: "ERROR: upload needs content-type: image/png" });
5459
- const target = join8(dirname7(abs), name);
6409
+ const target = join10(dirname9(abs), name);
5460
6410
  if (!inside(relative3(root2, target))) return json(res, 403, { isError: true, text: "file outside root" });
5461
6411
  if (await stat3(target).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${name} already exists next to the rig \u2014 rename the file` });
5462
6412
  const data = await bytes(req, UPLOAD_MAX);