@energy8platform/golem 0.2.1 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +28 -2
  2. package/dist/editor.css +50 -0
  3. package/dist/editor.js +1772 -1037
  4. package/dist/lib/cli.js +1575 -366
  5. package/dist/lib/cli.js.map +4 -4
  6. package/dist/lib/editor/controls-panel.d.ts +2 -0
  7. package/dist/lib/editor/stage.d.ts +1 -0
  8. package/dist/lib/editor/store.d.ts +212 -33
  9. package/dist/lib/editor-entry.js +1575 -366
  10. package/dist/lib/editor-entry.js.map +4 -4
  11. package/dist/lib/harness.js +328 -31
  12. package/dist/lib/interactive-bones.d.ts +37 -0
  13. package/dist/lib/interactive-editor.d.ts +80 -0
  14. package/dist/lib/interactive-editor.js +744 -0
  15. package/dist/lib/interactive-editor.js.map +7 -0
  16. package/dist/lib/preview/export-video.d.ts +12 -0
  17. package/dist/lib/preview/harness.d.ts +2 -0
  18. package/dist/lib/preview/render-preview.d.ts +6 -0
  19. package/dist/lib/rig-anim.d.ts +6 -3
  20. package/dist/lib/rig-check.d.ts +2 -1
  21. package/dist/lib/rig-constraints.d.ts +63 -0
  22. package/dist/lib/rig-control-qa.d.ts +319 -0
  23. package/dist/lib/rig-controls.d.ts +13 -0
  24. package/dist/lib/rig-format.d.ts +1469 -331
  25. package/dist/lib/rig-gates.d.ts +209 -0
  26. package/dist/lib/rig-mesh.d.ts +1 -0
  27. package/dist/lib/rig-motion.d.ts +22 -0
  28. package/dist/lib/rig-qa.d.ts +35 -0
  29. package/dist/lib/rig-quality.d.ts +12 -0
  30. package/dist/lib/rig-runtime.d.ts +43 -3
  31. package/dist/lib/rig-state.d.ts +3 -0
  32. package/dist/lib/rig-tools.d.ts +5 -1
  33. package/dist/lib/runtime.d.ts +3 -1
  34. package/dist/lib/runtime.js +306 -28
  35. package/dist/lib/runtime.js.map +4 -4
  36. package/dist/lib/tools.js +1557 -348
  37. package/dist/lib/tools.js.map +4 -4
  38. package/package.json +8 -2
package/dist/lib/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/rig-api.ts
2
- import { z as z2 } from "zod";
3
- import { readFile as readFile8, writeFile as writeFile6, stat as stat2 } from "node:fs/promises";
4
- import { dirname as dirname6, join as join7, relative as relative2, isAbsolute } from "node:path";
2
+ import { z as z3 } from "zod";
3
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
4
+ import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
5
5
 
6
6
  // src/rig-io.ts
7
7
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
@@ -105,7 +105,7 @@ function minJacobian(offsets, w, h, samples = 17) {
105
105
  var ID_RE = /^[a-z][a-z0-9_]*$/;
106
106
  var Id = z.string().regex(ID_RE, "snake_case id");
107
107
  var Ease = z.union([
108
- z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "back_out", "elastic_out"]),
108
+ z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "minimum_jerk", "back_out", "elastic_out"]),
109
109
  // cubic-bezier control points, same semantics as CSS
110
110
  z.object({ bezier: z.tuple([z.number(), z.number(), z.number(), z.number()]) })
111
111
  ]);
@@ -137,6 +137,7 @@ var Asset = z.object({
137
137
  /** where this layer came from — provenance for agents */
138
138
  origin: z.object({
139
139
  sourceImage: z.string().optional(),
140
+ bboxSpace: z.enum(["source", "document"]).optional(),
140
141
  /** bbox in source image: [x, y, w, h] */
141
142
  bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
142
143
  inpainted: z.boolean().default(false),
@@ -159,6 +160,8 @@ var Bone = z.object({
159
160
  var Slot = z.object({
160
161
  id: Id,
161
162
  bone: Id,
163
+ /** Closed path attachment used as a moving clipping mask. */
164
+ clip: Id.optional(),
162
165
  /** active attachment id; undefined = hidden */
163
166
  attachment: Id.optional(),
164
167
  alpha: z.number().min(0).max(1).default(1),
@@ -221,6 +224,8 @@ var IkConstraint = z.object({
221
224
  order: z.number().int().default(0),
222
225
  bones: z.array(Id).min(1),
223
226
  target: Id,
227
+ iterations: z.number().int().min(1).max(1e3).optional(),
228
+ tolerance: z.number().positive().optional(),
224
229
  mix: z.number().default(1),
225
230
  softness: z.number().default(0),
226
231
  bendPositive: z.boolean().default(true),
@@ -273,7 +278,7 @@ var BoneProp = z.enum(["x", "y", "rotation", "scaleX", "scaleY", "shearX", "shea
273
278
  var SlotProp = z.enum(["alpha", "attachment", "z", "color"]);
274
279
  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");
275
280
  var IkProp = z.enum(["mix", "softness", "bendPositive", "compress", "stretch"]);
276
- var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY"]);
281
+ var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY", "x", "y", "rotation", "scaleX", "scaleY", "shearY"]);
277
282
  var PathProp = z.enum(["position", "spacing", "mixRotate", "mixX", "mixY"]);
278
283
  var KeyValue = z.union([z.number(), z.string(), z.null(), z.array(z.number()), z.array(z.string())]);
279
284
  var Key = z.object({
@@ -282,14 +287,16 @@ var Key = z.object({
282
287
  /** easing INTO the next key */
283
288
  ease: Ease.default("ease_in_out")
284
289
  });
290
+ var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
285
291
  var Track = z.discriminatedUnion("target", [
286
- z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, keys: z.array(Key).min(1) }),
287
- z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, keys: z.array(Key).min(1) }),
288
- z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, keys: z.array(Key).min(1) }),
289
- z.object({ target: z.literal("ik"), id: Id, prop: IkProp, keys: z.array(Key).min(1) }),
290
- z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, keys: z.array(Key).min(1) }),
291
- z.object({ target: z.literal("path"), id: Id, prop: PathProp, keys: z.array(Key).min(1) }),
292
- z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), keys: z.array(Key).min(1) })
292
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
293
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
294
+ z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
295
+ z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
296
+ z.object({ target: z.literal("ik"), id: Id, prop: IkProp, ...Interpolation, keys: z.array(Key).min(1) }),
297
+ z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, ...Interpolation, keys: z.array(Key).min(1) }),
298
+ z.object({ target: z.literal("path"), id: Id, prop: PathProp, ...Interpolation, keys: z.array(Key).min(1) }),
299
+ z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), ...Interpolation, keys: z.array(Key).min(1) })
293
300
  ]);
294
301
  var Event = z.object({
295
302
  t: z.number().nonnegative(),
@@ -305,6 +312,25 @@ var Animation = z.object({
305
312
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
306
313
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
307
314
  });
315
+ var RigControl = z.object({
316
+ id: Id,
317
+ label: z.string().optional(),
318
+ min: z.number().finite(),
319
+ max: z.number().finite(),
320
+ default: z.number().finite()
321
+ }).strict();
322
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
323
+ var ControlChannel = z.discriminatedUnion("target", [
324
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
325
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
326
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
327
+ ]);
328
+ var ControlBinding = z.object({
329
+ id: Id,
330
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
331
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
332
+ channels: z.array(ControlChannel).min(1)
333
+ }).strict();
308
334
  var Reference = z.object({
309
335
  src: z.string(),
310
336
  opacity: z.number().min(0).max(1).default(0.5),
@@ -330,7 +356,9 @@ var RigSchema = z.object({
330
356
  ik: z.array(IkConstraint).default([]),
331
357
  transform: z.array(TransformConstraint).default([]),
332
358
  path: z.array(PathConstraint).default([]),
333
- animations: z.array(Animation)
359
+ animations: z.array(Animation),
360
+ controls: z.array(RigControl).optional(),
361
+ controlBindings: z.array(ControlBinding).optional()
334
362
  });
335
363
  var RigDocument = z.preprocess((input) => {
336
364
  const v = input?.version;
@@ -405,6 +433,8 @@ function validate(doc) {
405
433
  dup(doc.ik, "ik");
406
434
  dup(doc.transform, "transform");
407
435
  dup(doc.path, "path");
436
+ dup(doc.controls ?? [], "control");
437
+ dup(doc.controlBindings ?? [], "control binding");
408
438
  const bones = new Set(doc.bones.map((b) => b.id));
409
439
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
410
440
  const slots = new Set(doc.slots.map((s) => s.id));
@@ -431,6 +461,10 @@ function validate(doc) {
431
461
  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)`);
432
462
  }
433
463
  for (const s of doc.slots) {
464
+ if (s.clip) {
465
+ const mask = attachments.get(s.clip);
466
+ if (mask?.type !== "path" || !mask.closed) errs.push(`slot "${s.id}": clip must name a closed path attachment`);
467
+ }
434
468
  if (!bones.has(s.bone)) errs.push(`slot "${s.id}": unknown bone "${s.bone}"`);
435
469
  if (s.attachment) {
436
470
  const a = attachments.get(s.attachment);
@@ -462,7 +496,8 @@ function validate(doc) {
462
496
  }
463
497
  }
464
498
  for (const c of doc.ik) {
465
- if (c.bones.length > 2) errs.push(`ik "${c.id}": must have 1 or 2 bones`);
499
+ for (let i = 1; i < c.bones.length; i++) if (doc.bones.find((b) => b.id === c.bones[i])?.parent !== c.bones[i - 1])
500
+ errs.push(`ik "${c.id}": bones must form a contiguous parent-to-child chain`);
466
501
  for (const b of c.bones) if (!bones.has(b)) errs.push(`ik "${c.id}": unknown bone "${b}"`);
467
502
  if (!bones.has(c.target)) errs.push(`ik "${c.id}": unknown target bone "${c.target}"`);
468
503
  }
@@ -474,13 +509,47 @@ function validate(doc) {
474
509
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
475
510
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
476
511
  }
512
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
513
+ const driven = /* @__PURE__ */ new Set();
514
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
515
+ errs.push(`control "${c.id}": require min < max and default within range`);
516
+ for (const b of doc.controlBindings ?? []) {
517
+ const prefix = `control binding "${b.id}"`;
518
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
519
+ for (const axis of b.axes) {
520
+ const c = controls.get(axis.control);
521
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
522
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
523
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
524
+ }
525
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
526
+ for (const ch of b.channels) {
527
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
528
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
529
+ driven.add(key);
530
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
531
+ if (ch.target === "deform") {
532
+ const att = attachments.get(ch.id);
533
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
534
+ else {
535
+ const n = att.weights ? att.weights.reduce((s, a) => s + a.length * 2, 0) : att.vertices.length;
536
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
537
+ }
538
+ } else {
539
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
540
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
541
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
542
+ }
543
+ }
544
+ }
477
545
  for (const a of doc.animations) {
478
546
  const tracks = /* @__PURE__ */ new Set();
479
547
  for (const t of a.tracks) {
480
548
  const tid = trackId(t);
481
549
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
482
550
  tracks.add(tid);
483
- const known = t.target === "bone" ? bones.has(t.id) : t.target === "slot" ? slots.has(t.id) : t.target === "deform" ? attachments.has(t.id) : t.target === "ik" ? iks.has(t.id) : t.target === "transform" ? transforms.has(t.id) : t.target === "path" ? paths.has(t.id) : true;
551
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
552
+ const known = t.target === "bone" ? bones.has(t.id) : t.target === "slot" ? slots.has(t.id) : t.target === "deform" ? attachments.has(t.id) : t.target === "ik" ? iks.has(t.id) : t.target === "transform" ? transforms.has(t.id) : t.target === "path" ? paths.has(t.id) : t.target === "control" ? controls.has(t.id) : true;
484
553
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
485
554
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
486
555
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
@@ -488,6 +557,10 @@ function validate(doc) {
488
557
  for (const k of t.keys) {
489
558
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
490
559
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
560
+ if (t.target === "control") {
561
+ const c = controls.get(t.id);
562
+ if (typeof k.v !== "number" || !Number.isFinite(k.v) || c && (k.v < c.min || k.v > c.max)) errs.push(`animation "${a.id}" ${tid}: control key outside its finite range`);
563
+ }
491
564
  if (t.target === "slot" && t.prop === "attachment") {
492
565
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
493
566
  else if (typeof k.v === "string") {
@@ -526,6 +599,65 @@ function parse(input) {
526
599
  return doc;
527
600
  }
528
601
 
602
+ // src/rig-controls.ts
603
+ function controlPose(doc, values2) {
604
+ const out = /* @__PURE__ */ new Map();
605
+ for (const [id, value] of Object.entries(values2)) {
606
+ const c = doc.controls?.find((c2) => c2.id === id);
607
+ if (!c) throw new Error(`unknown control "${id}"`);
608
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
609
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
610
+ }
611
+ return out;
612
+ }
613
+ function bracket(keys, value) {
614
+ let i = 0;
615
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
616
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
617
+ }
618
+ function sampleBinding(binding, values2) {
619
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
620
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
621
+ const width = binding.axes[0].keys.length, a = y * width + x;
622
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
623
+ return binding.channels.map((ch) => {
624
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
625
+ const at = (j2) => {
626
+ const top = mix(read(a, j2), read(a + 1, j2), u);
627
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
628
+ };
629
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
630
+ });
631
+ }
632
+ function resolveControls(doc, input) {
633
+ if (!doc.controlBindings?.length) return input;
634
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
635
+ for (const b of doc.controlBindings) {
636
+ const values2 = b.axes.map((axis) => {
637
+ const c = controls.get(axis.control);
638
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
639
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
640
+ return Math.max(c.min, Math.min(c.max, value));
641
+ });
642
+ const sampled2 = sampleBinding(b, values2);
643
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
644
+ }
645
+ return out;
646
+ }
647
+ function controlCases(doc, probes) {
648
+ let cases = [{ label: "default", values: {} }];
649
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
650
+ for (const [id, values2] of Object.entries(probes)) {
651
+ const c = doc.controls?.find((c2) => c2.id === id);
652
+ if (!c) throw new Error(`unknown control "${id}"`);
653
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
654
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
655
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
656
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
657
+ }
658
+ return cases;
659
+ }
660
+
529
661
  // src/rig-anim.ts
530
662
  var DEG2 = Math.PI / 180;
531
663
  var named = {
@@ -534,6 +666,7 @@ var named = {
534
666
  ease_in: (t) => t * t,
535
667
  ease_out: (t) => 1 - (1 - t) * (1 - t),
536
668
  ease_in_out: (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
669
+ minimum_jerk: (t) => t * t * t * (10 + t * (-15 + 6 * t)),
537
670
  back_out: (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2,
538
671
  elastic_out: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1
539
672
  };
@@ -565,7 +698,25 @@ function ease(spec) {
565
698
  return f;
566
699
  }
567
700
  var pk = (target, id, prop) => `${target}:${id}:${prop}`;
568
- function sample(track, t) {
701
+ function smoothValue(track, i, t, component, loop) {
702
+ const k = track.keys, n = k.length;
703
+ const v = (j2) => component === void 0 ? k[j2].v : k[j2].v[component];
704
+ const h = (j2) => k[j2 + 1].t - k[j2].t;
705
+ const d = (j2) => (v(j2 + 1) - v(j2)) / h(j2);
706
+ const harmonic = (a, b, h0, h1) => a * b <= 0 ? 0 : 3 * (h0 + h1) / ((2 * h1 + h0) / a + (h1 + 2 * h0) / b);
707
+ const tangent = (j2) => {
708
+ if (j2 === 0 || j2 === n - 1) {
709
+ if (loop && n > 2 && v(0) === v(n - 1) && k[0].ease !== "step" && k[n - 2].ease !== "step")
710
+ return harmonic(d(n - 2), d(0), h(n - 2), h(0));
711
+ return 0;
712
+ }
713
+ if (k[j2 - 1].ease === "step" || k[j2].ease === "step") return 0;
714
+ return harmonic(d(j2 - 1), d(j2), h(j2 - 1), h(j2));
715
+ };
716
+ const u = (t - k[i].t) / h(i), u2 = u * u, u3 = u2 * u;
717
+ 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);
718
+ }
719
+ function sample(track, t, loop = false) {
569
720
  const keys = track.keys;
570
721
  if (t <= keys[0].t) return keys[0].v;
571
722
  const last = keys[keys.length - 1];
@@ -573,22 +724,44 @@ function sample(track, t) {
573
724
  let i = 0;
574
725
  while (keys[i + 1].t <= t) i++;
575
726
  const a = keys[i], b = keys[i + 1];
727
+ if (a.ease === "step") return a.v;
576
728
  if (typeof a.v === "number" && typeof b.v === "number") {
729
+ if (track.interpolation === "pchip") return smoothValue(track, i, t, void 0, loop);
577
730
  const u = ease(a.ease)((t - a.t) / (b.t - a.t));
578
731
  return a.v + (b.v - a.v) * u;
579
732
  }
580
733
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
734
+ if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
581
735
  const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
582
736
  return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
583
737
  }
584
738
  return a.v;
585
739
  }
586
740
  function poseOf(anim, t, out = /* @__PURE__ */ new Map()) {
587
- for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t));
741
+ 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));
742
+ return out;
743
+ }
744
+ function blendPoses(from, to, w, restOf) {
745
+ if (w >= 1) return new Map(to);
746
+ const out = /* @__PURE__ */ new Map();
747
+ for (const k of /* @__PURE__ */ new Set([...from.keys(), ...to.keys()])) {
748
+ const f = from.has(k) ? from.get(k) : restOf(k);
749
+ const cur = to.has(k) ? to.get(k) : restOf(k);
750
+ if (typeof f === "number" && !Array.isArray(cur)) {
751
+ const target = typeof cur === "number" ? cur : restOf(k);
752
+ out.set(k, f + (target - f) * w);
753
+ } else if (Array.isArray(f) && typeof f[0] === "number") {
754
+ const fv = f, tv = Array.isArray(cur) && typeof cur[0] === "number" ? cur : fv.map(() => 0);
755
+ out.set(k, fv.map((v, i) => v + ((tv[i] ?? 0) - v) * w));
756
+ } else if (Array.isArray(cur) && typeof cur[0] === "number" && typeof f !== "string") {
757
+ out.set(k, cur.map((v) => v * w));
758
+ } else out.set(k, f ?? null);
759
+ }
588
760
  return out;
589
761
  }
590
762
  function restValue(doc, key) {
591
763
  const [target, id, prop] = key.split(":");
764
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
592
765
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
593
766
  if (target === "slot") {
594
767
  const s = doc.slots.find((s2) => s2.id === id);
@@ -695,7 +868,9 @@ function updateSubtree(skel, root2) {
695
868
  for (const k of kids.get(id) ?? []) stack.push(k.id);
696
869
  }
697
870
  }
698
- function evaluate(doc, pose) {
871
+ function evaluate(doc, pose, modifiers) {
872
+ pose = resolveControls(doc, pose);
873
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
699
874
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
700
875
  for (const b of boneOrder(doc)) {
701
876
  const s = localOf(doc, pose, b);
@@ -756,10 +931,43 @@ function aimAt(skel, boneId, tx, ty, mix, compress = false, stretch = false, uni
756
931
  updateSubtree(skel, boneId);
757
932
  }
758
933
  var shortest = (deg) => ((deg + 180) % 360 + 360) % 360 - 180;
934
+ function applyLongIk(skel, c, tx, ty, mix) {
935
+ const initial = c.bones.map((id) => ({ ...skel.bones.get(id) }));
936
+ const last = c.bones.at(-1), len = boneOf(skel.doc, last).length;
937
+ const end = () => apply(skel.bones.get(last).world, len, 0);
938
+ const tolerance = c.tolerance ?? 0.05;
939
+ const root2 = skel.bones.get(c.bones[0]).world, tip = end();
940
+ 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) {
941
+ skel.bones.get(last).rotation += cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 5 : -5;
942
+ updateSubtree(skel, last);
943
+ }
944
+ for (let iter = 0; iter < (c.iterations ?? 100); iter++) {
945
+ const e = end();
946
+ if (Math.hypot(e[0] - tx, e[1] - ty) <= tolerance) break;
947
+ for (let i = c.bones.length - 1; i >= 0; i--) {
948
+ const id = c.bones[i], b = boneOf(skel.doc, id), st = skel.bones.get(id), w = st.world, e2 = end();
949
+ const angle = wrapRad(Math.atan2(ty - w.ty, tx - w.tx) - Math.atan2(e2[1] - w.ty, e2[0] - w.tx));
950
+ const cos = Math.cos(angle), sin = Math.sin(angle);
951
+ 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 });
952
+ }
953
+ }
954
+ if (mix < 1) {
955
+ c.bones.forEach((id, i) => {
956
+ const st = skel.bones.get(id), from = initial[i];
957
+ for (const p of ["rotation", "shearX", "shearY"]) st[p] = from[p] + shortest(st[p] - from[p]) * mix;
958
+ for (const p of ["x", "y", "scaleX", "scaleY"]) st[p] = from[p] + (st[p] - from[p]) * mix;
959
+ });
960
+ updateSubtree(skel, c.bones[0]);
961
+ }
962
+ }
759
963
  function applyIk(skel, c) {
760
964
  const mix = cnum(skel, "ik", c.id, "mix", c.mix);
761
965
  if (mix <= 0) return;
762
966
  const target = skel.bones.get(c.target).world;
967
+ if (c.bones.length > 2) {
968
+ applyLongIk(skel, c, target.tx, target.ty, mix);
969
+ return;
970
+ }
763
971
  const bendDir = cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 1 : -1;
764
972
  const compress = cnum(skel, "ik", c.id, "compress", c.compress) > 0.5, stretch = cnum(skel, "ik", c.id, "stretch", c.stretch) > 0.5;
765
973
  let softness = cnum(skel, "ik", c.id, "softness", c.softness);
@@ -894,6 +1102,7 @@ function applyTransformConstraint(skel, c) {
894
1102
  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);
895
1103
  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);
896
1104
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
1105
+ c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
897
1106
  const target = skel.bones.get(c.target), tw = target.world;
898
1107
  const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
899
1108
  for (const id of c.bones) {
@@ -1284,10 +1493,10 @@ function traceLoops(mask, threshold) {
1284
1493
  return loops;
1285
1494
  }
1286
1495
  function traceHulls(mask, opts = {}) {
1287
- const { threshold = 8, tolerance = 2, margin = 1 } = opts;
1496
+ const { threshold = 8, tolerance = 2, margin = 1, minIslandRatio = MIN_ISLAND_AREA } = opts;
1288
1497
  const loops = traceLoops(mask, threshold).map((l) => ({ l, a: signedArea(l) }));
1289
1498
  const biggest = loops.reduce((m, x) => Math.abs(x.a) > Math.abs(m.a) ? x : m, loops[0]);
1290
- 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));
1499
+ 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));
1291
1500
  return islands.map(({ l }) => {
1292
1501
  const s = simplify(dropCollinear(l), tolerance);
1293
1502
  return margin ? offsetPolygon(s, margin) : s;
@@ -1690,6 +1899,54 @@ function pathFromPoints(skel, slotId, points, closed) {
1690
1899
  }
1691
1900
  return { vertices, closed, lengths: curveLengths(flatten(ctrl.flat()), closed) };
1692
1901
  }
1902
+ function constraintDependencies(doc) {
1903
+ const parent = new Map(doc.bones.map((b) => [b.id, b.parent]));
1904
+ const ancestors = (id) => {
1905
+ const out = /* @__PURE__ */ new Set();
1906
+ for (let b = id; b && !out.has(b); b = parent.get(b)) out.add(b);
1907
+ return out;
1908
+ };
1909
+ const all = [
1910
+ ...doc.ik.map((c) => ({ kind: "ik", ...c })),
1911
+ ...doc.transform.map((c) => ({ kind: "transform", ...c })),
1912
+ ...doc.path.map((c) => ({ kind: "path", ...c }))
1913
+ ].sort((a, b) => a.order - b.order);
1914
+ 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))));
1915
+ const edges = [];
1916
+ all.forEach((reader, ri) => {
1917
+ if (!active2(reader)) return;
1918
+ const reads = /* @__PURE__ */ new Set();
1919
+ const add = (b) => ancestors(b).forEach((x) => reads.add(x));
1920
+ if (reader.kind === "path") {
1921
+ const slot = doc.slots.find((s) => s.id === reader.target);
1922
+ if (slot) add(slot.bone);
1923
+ for (const a of doc.attachments) if (a.slot === reader.target && a.type === "path") {
1924
+ for (const list of a.weights ?? []) for (const inf of list) if (inf.w !== 0) add(inf.bone);
1925
+ }
1926
+ } else add(reader.target);
1927
+ for (const b of reader.bones) {
1928
+ const p = parent.get(b);
1929
+ if (p && !reader.bones.includes(p)) add(p);
1930
+ }
1931
+ all.forEach((writer, wi) => {
1932
+ if (ri === wi || !active2(writer)) return;
1933
+ const bone = writer.bones.find((b) => reads.has(b));
1934
+ if (bone) edges.push({ reader: reader.id, readerKind: reader.kind, writer: writer.id, writerKind: writer.kind, bone, late: wi > ri });
1935
+ });
1936
+ });
1937
+ return { all, edges };
1938
+ }
1939
+ function orderConstraints(doc) {
1940
+ const { all, edges } = constraintDependencies(doc);
1941
+ const pending3 = [...all], ids = [];
1942
+ while (pending3.length) {
1943
+ const i = pending3.findIndex((c2) => edges.filter((e) => e.reader === c2.id && e.readerKind === c2.kind).every((e) => ids.includes(`${e.writerKind}:${e.writer}`)));
1944
+ if (i < 0) throw new Error(`constraint dependency cycle: ${pending3.map((c2) => c2.id).join(", ")}`);
1945
+ const c = pending3.splice(i, 1)[0];
1946
+ ids.push(`${c.kind}:${c.id}`);
1947
+ }
1948
+ 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}`) })) };
1949
+ }
1693
1950
 
1694
1951
  // src/rig-tools.ts
1695
1952
  function createDoc(name, width, height, notes) {
@@ -1791,13 +2048,13 @@ function placeAsset(doc, p) {
1791
2048
  const boneId = cur?.bone ?? p.bone;
1792
2049
  const world = evaluate(doc, /* @__PURE__ */ new Map()).bones.get(boneId)?.world;
1793
2050
  if (!world) throw new Error(`unknown bone "${boneId}"`);
1794
- const z3 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s) => s.z)) + 1 : 0);
1795
- let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z3 });
2051
+ const z4 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s) => s.z)) + 1 : 0);
2052
+ let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z4 });
1796
2053
  const [lx, ly] = apply(invert(world), p.x, p.y);
1797
2054
  next = setAttachment(next, { id: p.asset, slot: slotId, asset: p.asset, x: round4(lx), y: round4(ly) });
1798
2055
  return setSlot(next, { id: slotId, attachment: p.asset, ...p.z !== void 0 ? { z: p.z } : {} });
1799
2056
  }
1800
- function layerPrompt(doc, assetId) {
2057
+ function layerPrompt(doc, assetId, opts = {}) {
1801
2058
  const a = doc.assets.find((a2) => a2.id === assetId);
1802
2059
  if (!a) throw new Error(`unknown asset "${assetId}"`);
1803
2060
  const att = doc.attachments.find((a2) => a2.type !== "path" && a2.asset === assetId);
@@ -1810,10 +2067,15 @@ function layerPrompt(doc, assetId) {
1810
2067
  `Part: ${a.origin?.prompt ?? a.id}.`,
1811
2068
  `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.` : "."),
1812
2069
  bone ? `Rig context: attached to bone "${bone.id}"${bone.parent ? `, parent "${bone.parent}"` : ""}${children.length ? `; child parts: ${children.join(", ")}` : ""}.` : "Rig context: not attached yet.",
1813
- "Add about 20 px (source scale) of overlap beyond every joint end so rotation cannot expose a cut.",
2070
+ "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.",
1814
2071
  "Composition: orthographic front view, one connected opaque part only, same orientation as the current layer, centred with generous padding, no foreshortening.",
1815
- "Backdrop: perfectly flat uniform #00FF00 chroma-key background with no gradient, floor, shadow, text or watermark; do not use #00FF00 inside the part."
2072
+ "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."
1816
2073
  ];
2074
+ if (opts.method === "repair") lines.push(
2075
+ "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.",
2076
+ `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.`,
2077
+ "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."
2078
+ );
1817
2079
  return lines.join("\n");
1818
2080
  }
1819
2081
  function setMeta(doc, patch) {
@@ -1834,6 +2096,9 @@ function updateBone(doc, id, patch) {
1834
2096
  const i = doc.bones.findIndex((b) => b.id === id);
1835
2097
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1836
2098
  const cur = doc.bones[i];
2099
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2100
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2101
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1837
2102
  const { parent, ...rest } = patch;
1838
2103
  const next = RigSchema.shape.bones.element.parse({
1839
2104
  ...cur,
@@ -1866,6 +2131,11 @@ function patchDrawOrder(doc, fn) {
1866
2131
  return { ...doc, animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.map((t) => t.target !== "drawOrder" ? t : { ...t, keys: t.keys.map((k) => ({ ...k, v: fn(k.v) })) }) })) };
1867
2132
  }
1868
2133
  function setSlot(doc, slot) {
2134
+ if (slot.alpha !== void 0) {
2135
+ for (const b of doc.controlBindings ?? [])
2136
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2137
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2138
+ }
1869
2139
  const cur = doc.slots.find((s) => s.id === slot.id);
1870
2140
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
1871
2141
  const base = doc.slots.find((s) => s.id === slot.id);
@@ -1877,7 +2147,7 @@ function removeSlot(doc, id) {
1877
2147
  const gone = new Set(doc.attachments.filter((a) => a.slot === id).map((a) => a.id));
1878
2148
  return patchDrawOrder({
1879
2149
  ...doc,
1880
- slots: doc.slots.filter((s) => s.id !== id),
2150
+ slots: doc.slots.filter((s) => s.id !== id).map((s) => gone.has(s.clip ?? "") ? { ...s, clip: void 0 } : s),
1881
2151
  attachments: doc.attachments.filter((a) => a.slot !== id),
1882
2152
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
1883
2153
  }, (o) => o.filter((s) => s !== id));
@@ -1925,8 +2195,8 @@ function rebindSlot(doc, slotId, bone) {
1925
2195
  function setDrawOrder(doc, order) {
1926
2196
  const ids = new Set(doc.slots.map((s) => s.id));
1927
2197
  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");
1928
- const z3 = new Map(order.map((id, i) => [id, i]));
1929
- return { ...doc, slots: doc.slots.map((s) => ({ ...s, z: z3.get(s.id) })) };
2198
+ const z4 = new Map(order.map((id, i) => [id, i]));
2199
+ return { ...doc, slots: doc.slots.map((s) => ({ ...s, z: z4.get(s.id) })) };
1930
2200
  }
1931
2201
  function setAttachment(doc, att) {
1932
2202
  const cur = doc.attachments.find((a) => a.id === att.id);
@@ -1964,31 +2234,33 @@ function setMesh(doc, id, patch) {
1964
2234
  const len = deformLength(next);
1965
2235
  return dropVertexKeys(withAtt, id, (l) => l === len);
1966
2236
  }
1967
- var setWeights = (doc, id, weights) => setMesh(doc, id, { weights });
1968
- function convertToMesh(doc, mesh) {
1969
- const cur = doc.attachments.find((a) => a.id === mesh.id);
1970
- if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh.id}" is not a region`);
2237
+ var setWeights = (doc, id, weights) => {
2238
+ const old = doc.attachments.find((a) => a.id === id);
2239
+ 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));
2240
+ const result = setMesh(doc, id, { weights });
2241
+ if (sameLayout) return result;
2242
+ const cleared = dropVertexKeys(result.doc, id, () => false);
2243
+ return { doc: cleared.doc, dropped: [...result.dropped, ...cleared.dropped] };
2244
+ };
2245
+ function convertToMesh(doc, mesh2) {
2246
+ const cur = doc.attachments.find((a) => a.id === mesh2.id);
2247
+ if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh2.id}" is not a region`);
1971
2248
  const droppedTracks = [];
1972
2249
  const animations = doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => {
1973
- const drop = t.target === "deform" && t.id === mesh.id;
2250
+ const drop = t.target === "deform" && t.id === mesh2.id;
1974
2251
  if (drop) droppedTracks.push(`${trackId2(t)} in ${a.id}`);
1975
2252
  return !drop;
1976
2253
  }) }));
1977
- return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh.id ? Attachment.parse(mesh) : a), animations }, droppedTracks };
2254
+ return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh2.id ? Attachment.parse(mesh2) : a), animations }, droppedTracks };
1978
2255
  }
1979
2256
  function removeAttachment(doc, id) {
1980
2257
  return {
1981
2258
  ...doc,
1982
2259
  attachments: doc.attachments.filter((a) => a.id !== id),
1983
- slots: doc.slots.map((s) => s.attachment === id ? { ...s, attachment: void 0 } : s),
2260
+ slots: doc.slots.map((s) => ({ ...s, attachment: s.attachment === id ? void 0 : s.attachment, clip: s.clip === id ? void 0 : s.clip })),
1984
2261
  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) }))
1985
2262
  };
1986
2263
  }
1987
- function placementFromPivot(pivot, offset, w, h) {
1988
- const o = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, ...offset };
1989
- 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);
1990
- return { x: +x.toFixed(4) + 0, y: +y.toFixed(4) + 0, rotation: o.rotation, scaleX: o.scaleX, scaleY: o.scaleY };
1991
- }
1992
2264
  var CONSTRAINT_SCHEMA = { ik: IkConstraint, transform: TransformConstraint, path: PathConstraint };
1993
2265
  var article = (kind) => kind === "ik" ? "an" : "a";
1994
2266
  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;
@@ -2000,8 +2272,10 @@ function setConstraint(doc, kind, id, patch) {
2000
2272
  const given = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0));
2001
2273
  if (!cur && !(given.bones && given.target)) throw new Error(`no ${kind} constraint "${id}" yet: creating one needs at least bones and target`);
2002
2274
  const next = CONSTRAINT_SCHEMA[kind].parse({ ...cur, ...given, id });
2003
- if (kind === "ik" && next.bones.length > 2) throw new Error(`ik "${id}": 1 or 2 bones, got ${next.bones.length} (${next.bones.join(",")})`);
2004
2275
  const list = cur ? doc[kind].map((c) => c.id === id ? next : c) : [...doc[kind], next];
2276
+ if (kind === "ik") {
2277
+ 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`);
2278
+ }
2005
2279
  return { ...doc, [kind]: list };
2006
2280
  }
2007
2281
  function removeConstraint(doc, id) {
@@ -2016,8 +2290,10 @@ function removeConstraint(doc, id) {
2016
2290
  function addIkChain(doc, bone, opts = {}) {
2017
2291
  const id = `${bone}_ik`;
2018
2292
  if (constraintKind(doc, id)) throw new Error(`constraint "${id}" exists; remove it first (rig_remove_constraint) or patch it`);
2019
- const full = ikChainFor(doc, bone);
2293
+ const full = opts.bones ?? ikChainFor(doc, bone);
2294
+ if (!full.length || full.at(-1) !== bone) throw new Error("explicit chain must end at the named tip bone");
2020
2295
  const rootId = doc.bones.find((b) => !b.parent).id;
2296
+ if (opts.bones && full.includes(rootId)) throw new Error("explicit chain cannot include the root carrying its target");
2021
2297
  const note = full.includes(rootId) ? "single bone \u2014 the two-bone chain would include the root, which carries the target" : void 0;
2022
2298
  const chain = note ? [bone] : full;
2023
2299
  if (chain.length === 1 && !doc.bones.find((b) => b.id === bone).length)
@@ -2131,10 +2407,11 @@ function pathAnchorOp(doc, id, op, index, t = 0.5) {
2131
2407
  function setKeys(doc, animId, track, keys, duration, opts = {}) {
2132
2408
  const anim = doc.animations.find((a) => a.id === animId) ?? { id: animId, duration: duration ?? 1, loop: false, tracks: [], events: [] };
2133
2409
  const same = (t) => t.target === track.target && t.id === track.id && t.prop === track.prop;
2410
+ const old = anim.tracks.find(same);
2134
2411
  const given = keys.map((k) => ({ ...k, ease: k.ease ?? "ease_in_out" }));
2135
2412
  const kept = opts.merge ? (anim.tracks.find(same)?.keys ?? []).filter((k) => !given.some((g) => Math.abs(g.t - k.t) < 1e-9)) : [];
2136
2413
  const sorted = [...kept, ...given].sort((a, b) => a.t - b.t);
2137
- const edited = { ...track, keys: sorted };
2414
+ const edited = { ...old, ...track, interpolation: track.interpolation ?? old?.interpolation, keys: sorted };
2138
2415
  const tracks = anim.tracks.some(same) ? anim.tracks.map((t) => same(t) ? edited : t) : [...anim.tracks, edited];
2139
2416
  const dur = duration ?? Math.max(anim.duration, ...sorted.map((k) => k.t));
2140
2417
  const next = { ...anim, tracks, duration: dur, preset: void 0 };
@@ -2217,7 +2494,7 @@ function copyTracks(doc, animId, from, to, opts) {
2217
2494
  const shifted = round6(k.t + shift);
2218
2495
  return { t: shifted >= anim.duration ? shifted - anim.duration : shifted, v: mirror ? -k.v : k.v, ease: k.ease };
2219
2496
  });
2220
- next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop }, keys);
2497
+ next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop, interpolation: t.interpolation }, keys);
2221
2498
  copied++;
2222
2499
  }
2223
2500
  if (!any) empty.push(srcId);
@@ -2233,6 +2510,7 @@ function trackSummary(t) {
2233
2510
  function rename2(doc, kind, id, to) {
2234
2511
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2235
2512
  if (to === id) return doc;
2513
+ if (doc.controlBindings) doc = { ...doc, controlBindings: doc.controlBindings.map((b) => ({ ...b, channels: b.channels.map((ch) => ch.id === id && (kind === ch.target || kind === "attachment" && ch.target === "deform") ? { ...ch, id: to } : ch) })) };
2236
2514
  const sub = (v) => v === id ? to : v;
2237
2515
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2238
2516
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2277,7 +2555,7 @@ function rename2(doc, kind, id, to) {
2277
2555
  return {
2278
2556
  ...doc,
2279
2557
  attachments: doc.attachments.map((a) => ({ ...a, id: sub(a.id) })),
2280
- slots: doc.slots.map((s) => s.attachment ? { ...s, attachment: sub(s.attachment) } : s)
2558
+ slots: doc.slots.map((s) => ({ ...s, attachment: s.attachment ? sub(s.attachment) : void 0, clip: s.clip ? sub(s.clip) : void 0 }))
2281
2559
  };
2282
2560
  }
2283
2561
  case "asset": {
@@ -2307,13 +2585,15 @@ function describeAttachment(doc, att) {
2307
2585
  }
2308
2586
  const n = vertexCount(att);
2309
2587
  const bones = att.weights ? [...new Set(att.weights.flat().map((i) => i.bone))] : [];
2310
- 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)`;
2588
+ 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)`;
2311
2589
  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"}`;
2312
2590
  const keyed = doc.animations.filter((a) => a.tracks.some((t) => t.target === "deform" && t.id === att.id && t.prop === "vertices")).map((a) => a.id);
2313
- return `${head}: ${shape}; ${skin}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2591
+ return `${head}: ${shape}; ${skin2}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2314
2592
  }
2315
2593
  function describe(doc, opts = {}) {
2316
2594
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2595
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2596
+ for (const b of doc.controlBindings ?? []) lines.push(`keyforms ${b.id}: ${b.axes.map((a) => `${a.control}[${a.keys.join(",")}]`).join(" \xD7 ")} \u2192 ${b.channels.map((c) => `${c.target}.${c.id}.${c.prop}`).join(", ")}`);
2317
2597
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2318
2598
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2319
2599
  const place = (s) => {
@@ -2471,9 +2751,11 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2471
2751
  let specs;
2472
2752
  try {
2473
2753
  specs = JSON.parse(await readFile(join2(dir, "layers.json"), "utf8"));
2474
- } catch {
2754
+ } catch (e) {
2755
+ if (e.code !== "ENOENT") throw e;
2475
2756
  specs = (await readdir(dir)).filter((f) => f.endsWith(".png")).sort().map((file) => ({ file }));
2476
2757
  }
2758
+ if (!Array.isArray(specs)) throw new Error("layers.json must be an array");
2477
2759
  let next = doc;
2478
2760
  const parsed = [];
2479
2761
  for (const s of specs) {
@@ -2481,21 +2763,32 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2481
2763
  const [w, h] = pngSize(buf);
2482
2764
  const m = basename2(s.file, ".png").match(/^(?:(\d+)_)?(.+)$/);
2483
2765
  const id = s.id ?? m[2].toLowerCase().replace(/[^a-z0-9_]/g, "_");
2484
- const z3 = s.z ?? (m[1] ? +m[1] : parsed.length);
2766
+ const z4 = s.z ?? (m[1] ? +m[1] : parsed.length);
2485
2767
  const role = s.role ?? (DEFAULT_PARENT[id] || id === "root" ? id : void 0);
2486
2768
  const bbox = s.bbox ?? [0, 0, w, h];
2487
- parsed.push({ ...s, id, z: z3, role, w, h, bbox });
2769
+ if (bbox.length !== 4 || bbox.some((v) => !Number.isFinite(v)) || bbox[2] <= 0 || bbox[3] <= 0)
2770
+ throw new Error(`layer "${id}": bbox must have positive width and height`);
2771
+ parsed.push({ ...s, id, z: z4, role, w, h, bbox });
2488
2772
  }
2489
2773
  const ids = new Set(parsed.map((p) => p.id));
2490
2774
  const rootId = next.bones.find((b) => !b.parent).id;
2491
2775
  const exists3 = (id) => ids.has(id) || next.bones.some((b) => b.id === id);
2492
2776
  const resolveParent = (p) => {
2493
- if (p.parent && exists3(p.parent)) return p.parent;
2777
+ if (p.parent) {
2778
+ if (!exists3(p.parent)) throw new Error(`layer "${p.id}": unknown parent "${p.parent}"`);
2779
+ return p.parent;
2780
+ }
2494
2781
  let cur = p.role && DEFAULT_PARENT[p.role];
2495
2782
  while (cur && !exists3(cur)) cur = DEFAULT_PARENT[cur];
2496
2783
  return cur ?? rootId;
2497
2784
  };
2498
- const dependency = (p) => p.bone && exists3(p.bone) ? p.bone : resolveParent(p);
2785
+ const dependency = (p) => {
2786
+ if (p.bone) {
2787
+ if (!exists3(p.bone)) throw new Error(`layer "${p.id}": unknown bone "${p.bone}"`);
2788
+ return p.bone;
2789
+ }
2790
+ return resolveParent(p);
2791
+ };
2499
2792
  const pending3 = [...parsed];
2500
2793
  while (pending3.length) {
2501
2794
  const i = pending3.findIndex((p2) => next.bones.some((b) => b.id === dependency(p2)));
@@ -2504,36 +2797,45 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2504
2797
  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`);
2505
2798
  const [bx, by, bw, bh] = p.bbox;
2506
2799
  const joint = p.joint ?? [bx + bw / 2, by + bh / 2];
2507
- const host = worldOf(next, target);
2800
+ const host = evaluate(next, /* @__PURE__ */ new Map()).bones.get(target).world;
2801
+ if (Math.abs(host.a * host.d - host.b * host.c) < 1e-10) throw new Error(`layer "${p.id}": singular host transform`);
2508
2802
  next = { ...next, assets: [...next.assets, RigSchema.shape.assets.element.parse({
2509
2803
  id: p.id,
2510
2804
  src: `${relPrefix}/${p.file}`,
2511
2805
  width: p.w,
2512
2806
  height: p.h,
2513
- origin: { sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2807
+ origin: { bboxSpace: "document", sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2514
2808
  })] };
2515
- const pivot = { x: (joint[0] - bx) / bw, y: (joint[1] - by) / bh };
2809
+ const place = (slotId) => {
2810
+ const slot = next.slots.find((s) => s.id === slotId);
2811
+ const inv = invert(evaluate(next, /* @__PURE__ */ new Map()).bones.get(slot.bone).world);
2812
+ const [x, y] = apply(inv, bx + bw / 2, by + bh / 2);
2813
+ 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 };
2814
+ const sx = Math.hypot(m.a, m.b), sy = Math.hypot(m.c, m.d);
2815
+ if (Math.abs(m.a * m.c + m.b * m.d) > 1e-8 * sx * sy) {
2816
+ const vertices = [[bx, by], [bx + bw, by], [bx + bw, by + bh], [bx, by + bh]].flatMap(([x2, y2]) => apply(inv, x2, y2));
2817
+ 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 });
2818
+ next = { ...next, attachments: [...next.attachments.filter((a) => a.id !== p.id), att] };
2819
+ } 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 });
2820
+ };
2516
2821
  if (p.swap || p.bone) {
2517
2822
  const slotId = p.slot ?? p.id;
2518
2823
  if (!next.slots.some((s) => s.id === slotId)) next = setSlot(next, { id: slotId, bone: target, z: p.z, blend: p.blend });
2519
- 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) });
2824
+ place(slotId);
2520
2825
  if (!p.swap) next = setSlot(next, { id: slotId, attachment: p.id });
2521
2826
  continue;
2522
2827
  }
2523
- if (!next.bones.some((b) => b.id === p.id))
2524
- 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 } });
2828
+ if (!next.bones.some((b) => b.id === p.id)) {
2829
+ const [x, y] = apply(invert(host), ...joint);
2830
+ next = addBone(next, { id: p.id, parent: target, role: p.role, transform: { x, y } });
2831
+ }
2525
2832
  next = setSlot(next, { id: p.id, bone: p.id, z: p.z, blend: p.blend });
2526
- next = setAttachment(next, { id: p.id, slot: p.id, asset: p.id, ...placementFromPivot(pivot, {}, p.w, p.h) });
2833
+ place(p.id);
2527
2834
  next = setSlot(next, { id: p.id, attachment: p.id });
2528
2835
  }
2529
2836
  return next;
2530
2837
  }
2531
2838
  var s_bbox = (p) => p.bbox;
2532
- function worldOf(doc, id) {
2533
- const b = doc.bones.find((b2) => b2.id === id);
2534
- const p = b.parent ? worldOf(doc, b.parent) : { x: 0, y: 0 };
2535
- return { x: p.x + b.transform.x, y: p.y + b.transform.y };
2536
- }
2537
2839
 
2538
2840
  // src/rig-presets.ts
2539
2841
  var root = (doc) => doc.bones.find((b) => !b.parent);
@@ -2741,18 +3043,57 @@ function applyPreset(doc, name, params = {}) {
2741
3043
  return { ...doc, animations };
2742
3044
  }
2743
3045
 
3046
+ // src/preview/export-video.ts
3047
+ import { spawn } from "node:child_process";
3048
+ import { once } from "node:events";
3049
+ import { mkdtemp, writeFile as writeFile2, rm as rm2, rename as rename3, mkdir as mkdir2 } from "node:fs/promises";
3050
+ import { dirname as dirname4, join as join5 } from "node:path";
3051
+
2744
3052
  // src/preview/render-preview.ts
2745
3053
  import { chromium } from "playwright";
2746
- import { readFile as readFile2 } from "node:fs/promises";
2747
- import { join as join3, dirname as dirname2 } from "node:path";
3054
+ import { readFile as readFile3 } from "node:fs/promises";
3055
+ import { join as join4, dirname as dirname3 } from "node:path";
2748
3056
  import { fileURLToPath } from "node:url";
2749
- var HERE = dirname2(fileURLToPath(import.meta.url));
3057
+
3058
+ // src/rig-qa.ts
3059
+ import { createHash } from "node:crypto";
3060
+ import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises";
3061
+ import { dirname as dirname2, join as join3 } from "node:path";
3062
+ var sha256 = (data) => createHash("sha256").update(data).digest("hex");
3063
+ async function verifyRenderManifest(rigPath, framesDir, expected) {
3064
+ const m = JSON.parse(await readFile2(join3(framesDir, "manifest.json"), "utf8"));
3065
+ if (m.version !== 1 || !m.frames?.length || !m.times?.length) throw new Error("missing or empty capture manifest");
3066
+ const raw = await readFile2(rigPath, "utf8");
3067
+ if (sha256(raw) !== m.rigFile) throw new Error("stale render: rig file changed");
3068
+ const doc = parse(JSON.parse(raw));
3069
+ if (sha256(JSON.stringify(doc)) !== m.identity.document) throw new Error("stale render: rig changed");
3070
+ if (sha256(await readFile2(expected.renderer)) !== m.identity.renderer) throw new Error("stale render: renderer changed");
3071
+ 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`);
3072
+ 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`);
3073
+ 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");
3074
+ if (expected.transparent !== void 0 && m.transparent !== expected.transparent) throw new Error("capture has the wrong alpha mode");
3075
+ 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");
3076
+ const files2 = (await readdir2(framesDir)).filter((f) => f.endsWith(".png")).sort();
3077
+ if (JSON.stringify(files2) !== JSON.stringify(m.frames.map((f) => f.file).sort())) throw new Error("capture directory has missing or unmanifested frames");
3078
+ for (const f of m.frames) {
3079
+ if (!/^\d{6}\.png$/.test(f.file)) throw new Error("invalid capture filename");
3080
+ if (sha256(await readFile2(join3(framesDir, f.file))) !== f.sha256) throw new Error(`frame ${f.file} changed since capture`);
3081
+ }
3082
+ return m;
3083
+ }
3084
+
3085
+ // src/preview/render-preview.ts
3086
+ import { PNG } from "pngjs";
3087
+ var HERE = dirname3(fileURLToPath(import.meta.url));
2750
3088
  var browser;
2751
3089
  var page;
2752
3090
  var harnessJs;
2753
3091
  var pending2 = Promise.resolve();
2754
3092
  async function getPage() {
2755
- if (page && !page.isClosed()) return page;
3093
+ const latestHarness = await readFile3(join4(HERE, "harness.js"), "utf8");
3094
+ if (page && !page.isClosed() && latestHarness === harnessJs) return page;
3095
+ if (page && !page.isClosed()) await page.close();
3096
+ harnessJs = latestHarness;
2756
3097
  if (browser && !browser.isConnected()) browser = void 0;
2757
3098
  browser ??= await chromium.launch({
2758
3099
  headless: true,
@@ -2762,7 +3103,7 @@ async function getPage() {
2762
3103
  try {
2763
3104
  next.on("pageerror", (e) => console.error("[harness]", e.message));
2764
3105
  await next.setContent(`<!doctype html><html><body style="margin:0;background:transparent"></body></html>`);
2765
- harnessJs ??= await readFile2(join3(HERE, "harness.js"), "utf8");
3106
+ harnessJs ??= await readFile3(join4(HERE, "harness.js"), "utf8");
2766
3107
  await next.addScriptTag({ content: harnessJs });
2767
3108
  page = next;
2768
3109
  return next;
@@ -2800,6 +3141,8 @@ async function render(doc, opts) {
2800
3141
  if (opts.count !== void 0) positive(opts.count, "count", true);
2801
3142
  const anim = opts.animation === void 0 ? void 0 : doc.animations.find((a) => a.id === opts.animation);
2802
3143
  if (opts.animation !== void 0 && !anim) throw new Error(`unknown animation "${opts.animation}"`);
3144
+ if (opts.weightBone && !doc.bones.some((b) => b.id === opts.weightBone)) throw new Error(`unknown weight bone "${opts.weightBone}"`);
3145
+ 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
3146
  if (opts.times && (!opts.times.length || opts.times.some((t) => !Number.isFinite(t) || t < 0)))
2804
3147
  throw new Error("times must contain finite, nonnegative sample times");
2805
3148
  if (opts.region && (opts.region.length !== 4 || opts.region.some((v) => !Number.isFinite(v)) || opts.region[2] <= 0 || opts.region[3] <= 0))
@@ -2816,12 +3159,27 @@ async function render(doc, opts) {
2816
3159
  }
2817
3160
  }
2818
3161
  const assets = {};
3162
+ const identity = { document: sha256(JSON.stringify(doc)), assets: {}, renderer: "", masks: {} };
2819
3163
  for (const a of doc.assets) {
2820
3164
  if (assets[a.src]) continue;
2821
- const buf = await readFile2(join3(opts.assetsDir, a.src));
3165
+ let buf = await readFile3(join4(opts.assetsDir, a.src));
3166
+ identity.assets[a.src] = sha256(buf);
3167
+ if (opts.debugFillMasks?.[a.src]) {
3168
+ const maskData = await readFile3(join4(opts.assetsDir, opts.debugFillMasks[a.src]));
3169
+ identity.masks[opts.debugFillMasks[a.src]] = sha256(maskData);
3170
+ const png = PNG.sync.read(buf), mask = PNG.sync.read(maskData);
3171
+ if (png.width !== mask.width || png.height !== mask.height) throw new Error(`fill mask dimensions differ for ${a.src}`);
3172
+ for (let i = 0; i < png.data.length; i += 4) if (mask.data[i] > 127 && mask.data[i + 3] > 0) {
3173
+ png.data[i] = 255;
3174
+ png.data[i + 1] = 0;
3175
+ png.data[i + 2] = 255;
3176
+ }
3177
+ buf = PNG.sync.write(png);
3178
+ }
2822
3179
  assets[a.src] = `data:${mime(a.src)};base64,${buf.toString("base64")}`;
2823
3180
  }
2824
3181
  const p = await getPage();
3182
+ identity.renderer = sha256(harnessJs);
2825
3183
  const res = await p.evaluate(
2826
3184
  async ({ doc: doc2, assets: assets2, scale, ro }) => {
2827
3185
  const h = window.rigHarness;
@@ -2832,10 +3190,11 @@ async function render(doc, opts) {
2832
3190
  doc,
2833
3191
  assets,
2834
3192
  scale: opts.scale ?? 1,
2835
- ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
3193
+ 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
3194
  }
2837
3195
  );
2838
3196
  return {
3197
+ identity,
2839
3198
  frames: res.frames.map(fromDataUrl),
2840
3199
  sheet: fromDataUrl(res.sheet),
2841
3200
  times: times2,
@@ -2844,6 +3203,146 @@ async function render(doc, opts) {
2844
3203
  };
2845
3204
  }
2846
3205
 
3206
+ // src/preview/export-video.ts
3207
+ async function exportVideo(doc, opts) {
3208
+ const anim = doc.animations.find((a) => a.id === opts.animation), fps = opts.fps ?? 60;
3209
+ if (!anim) throw new Error(`unknown animation "${opts.animation}"`);
3210
+ if (!Number.isInteger(fps) || fps < 1 || fps > 240) throw new Error("video fps must be an integer between 1 and 240");
3211
+ const count = Math.max(1, Math.round(anim.duration * fps));
3212
+ if (count > 1e4) throw new Error("video exceeds 10000 frames; export shorter clips");
3213
+ await mkdir2(dirname4(opts.outFile), { recursive: true });
3214
+ const temp = await mkdtemp(join5(dirname4(opts.outFile), ".golem-video-"));
3215
+ try {
3216
+ for (let start = 0; start < count; start += 24) {
3217
+ const times2 = Array.from({ length: Math.min(24, count - start) }, (_, i) => (start + i) / fps);
3218
+ const rendered = await renderPreview(doc, { ...opts, transparent: false, times: times2 });
3219
+ await Promise.all(rendered.frames.map((frame, i) => writeFile2(join5(temp, `${String(start + i).padStart(6, "0")}.png`), frame)));
3220
+ }
3221
+ const output = join5(temp, "preview.mp4");
3222
+ 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"] });
3223
+ let diagnostic = "";
3224
+ proc.stderr.on("data", (chunk) => {
3225
+ diagnostic = (diagnostic + chunk).slice(-8e3);
3226
+ });
3227
+ const [code] = await once(proc, "close");
3228
+ if (code !== 0) throw new Error(`MP4 encoder failed: ${diagnostic}`);
3229
+ await rename3(output, opts.outFile);
3230
+ return { frames: count, fps, duration: count / fps };
3231
+ } finally {
3232
+ await rm2(temp, { recursive: true, force: true });
3233
+ }
3234
+ }
3235
+
3236
+ // src/rig-quality.ts
3237
+ var values = (v) => typeof v === "number" ? [v] : Array.isArray(v) && v.every((n) => typeof n === "number") ? v : void 0;
3238
+ var delta = (a, b) => a.map((v, i) => v - b[i]);
3239
+ var magnitude = (a) => Math.max(0, ...a.map(Math.abs));
3240
+ function motionQuality(doc, anim, opts = {}) {
3241
+ const out = [], D = anim.duration;
3242
+ const report = (code, subject, message, time, level = "warn") => out.push({ level, code, subject, animation: anim.id, time, message });
3243
+ for (const tr of anim.tracks) {
3244
+ const id = `${tr.target}.${tr.id}.${tr.prop}`, looping = anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === D;
3245
+ const at = (t) => sample(tr, t, looping), numeric = tr.keys.every((k) => values(k.v) !== void 0);
3246
+ if (anim.loop) {
3247
+ const first = at(0), last = at(D), av = values(first), bv = values(last);
3248
+ const diff = av && bv && av.length === bv.length ? magnitude(delta(av, bv)) : first === last || JSON.stringify(first) === JSON.stringify(last) ? 0 : Infinity;
3249
+ 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`);
3250
+ if (numeric && diff <= 1e-3 && tr.keys.length > 1) {
3251
+ 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);
3252
+ 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);
3253
+ const dv = magnitude(delta(va, vb));
3254
+ 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`);
3255
+ }
3256
+ }
3257
+ if (!numeric) continue;
3258
+ for (let i = 1; i < tr.keys.length - 1; i++) {
3259
+ const p = tr.keys[i - 1], k = tr.keys[i], n = tr.keys[i + 1];
3260
+ if (p.ease === "step" || k.ease === "step") continue;
3261
+ const h = Math.min(k.t - p.t, n.t - k.t) / 1e3;
3262
+ 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);
3263
+ const before = delta(values(k.v), values(p.v)), after = delta(values(n.v), values(k.v));
3264
+ const pass = before.some((v, j2) => v * after[j2] > 1e-6);
3265
+ const speed = Math.max(magnitude(before) / (k.t - p.t), magnitude(after) / (n.t - k.t));
3266
+ 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);
3267
+ const jump = magnitude(delta(left, right));
3268
+ 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);
3269
+ const acceleration = (a, b, c) => values(at(b)).map((v, j2) => (values(at(c))[j2] - 2 * v + values(at(a))[j2]) / (h * h));
3270
+ const al = acceleration(k.t - 2 * h, k.t - h, k.t), ar = acceleration(k.t, k.t + h, k.t + 2 * h);
3271
+ const aj = magnitude(delta(al, ar));
3272
+ 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");
3273
+ }
3274
+ }
3275
+ const steps = Math.max(2, Math.ceil(D * 60));
3276
+ const times2 = new Set(Array.from({ length: steps + 1 }, (_, i) => i * D / steps));
3277
+ for (const tr of anim.tracks) for (const k of tr.keys) {
3278
+ times2.add(k.t);
3279
+ if (k.t > 0) times2.add(Math.max(0, k.t - 1e-5));
3280
+ }
3281
+ const samples = [...times2].sort((a, b) => a - b).map((t) => ({ t, pose: poseOf(anim, t) }));
3282
+ for (const bone of opts.secondaryBones ?? []) {
3283
+ if (!doc.bones.some((b) => b.id === bone)) throw new Error(`unknown secondary bone "${bone}"`);
3284
+ const local = samples.map((s) => evaluate(doc, s.pose).bones.get(bone));
3285
+ const curves = ["rotation", "x", "y"].map((prop) => local.map((b) => b[prop]));
3286
+ 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`);
3287
+ }
3288
+ const reported = /* @__PURE__ */ new Set();
3289
+ for (const [front, back] of opts.frontOf ?? []) {
3290
+ if (![front, back].every((id) => doc.slots.some((s) => s.id === id))) throw new Error(`unknown frontOf slots: ${front}, ${back}`);
3291
+ for (const { t, pose } of samples) {
3292
+ const skel = evaluate(doc, pose);
3293
+ if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3294
+ const visible = (id) => {
3295
+ const slot = doc.slots.find((s) => s.id === id);
3296
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3297
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3298
+ };
3299
+ if (!visible(front) || !visible(back)) continue;
3300
+ const order = pose.get(pk("drawOrder", "order", "order"));
3301
+ const z4 = (id) => order ? order.indexOf(id) : Number(pose.get(pk("slot", id, "z")) ?? doc.slots.find((s) => s.id === id).z);
3302
+ const pair = JSON.stringify([front, back]);
3303
+ if (z4(front) <= z4(back) && !reported.has(pair)) {
3304
+ reported.add(pair);
3305
+ report("draw_order_rule", front, `"${front}" must stay explicitly in front of "${back}" according to the brief (equal z is ambiguous)`, t);
3306
+ }
3307
+ }
3308
+ }
3309
+ 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)) {
3310
+ const a = evaluate(doc, poseOf(anim, Math.max(0, key.t - 1e-6))), b = evaluate(doc, poseOf(anim, key.t));
3311
+ const before = a.attachment.get(tr.id), after = b.attachment.get(tr.id);
3312
+ if (!before || !after || before.id === after.id) continue;
3313
+ const landmarksA = opts.landmarks?.[before.id], landmarksB = opts.landmarks?.[after.id];
3314
+ if (!landmarksA && !landmarksB) continue;
3315
+ if (!landmarksA || !landmarksB || !Object.keys(landmarksA).some((name) => landmarksB[name])) {
3316
+ report("landmark_unmeasured", tr.id, `swap ${before.id} \u2192 ${after.id} has no shared declared landmarks`, key.t);
3317
+ continue;
3318
+ }
3319
+ const point = (skel, att, uv) => {
3320
+ if (att.type === "region") {
3321
+ const [w, h] = regionSize(doc, att);
3322
+ return apply(regionMatrix(skel, att.slot, att), (uv[0] - 0.5) * w, (uv[1] - 0.5) * h);
3323
+ }
3324
+ if (att.type !== "mesh") return;
3325
+ const v = attachmentWorldVertices(skel, att.slot, att);
3326
+ for (let i = 0; i < att.triangles.length; i += 3) {
3327
+ 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;
3328
+ if (Math.abs(det) < 1e-12) continue;
3329
+ const s = ((uv[0] - ux) * cy - (uv[1] - uy) * cx) / det, r = (bx * (uv[1] - uy) - by * (uv[0] - ux)) / det;
3330
+ 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];
3331
+ }
3332
+ };
3333
+ for (const [name, uv] of Object.entries(landmarksA)) if (landmarksB[name]) {
3334
+ const p = point(a, before, uv), q = point(b, after, landmarksB[name]);
3335
+ if (!p || !q) {
3336
+ report("landmark_unmeasured", tr.id, `landmark "${name}" is outside attachment geometry`, key.t);
3337
+ continue;
3338
+ }
3339
+ const distance = Math.hypot(p[0] - q[0], p[1] - q[1]);
3340
+ 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);
3341
+ }
3342
+ }
3343
+ return out;
3344
+ }
3345
+
2847
3346
  // src/rig-check.ts
2848
3347
  var FOLD_ERROR_RATIO = 0.05;
2849
3348
  var IK_SLACK = 1;
@@ -2852,17 +3351,25 @@ var isNumeric = (t) => t.target !== "slot" || t.prop !== "attachment";
2852
3351
  var trackId3 = (t) => `${t.target}.${t.id}.${t.prop}`;
2853
3352
  var fmt = (n) => String(Math.round(n * 1e3) / 1e3);
2854
3353
  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));
3354
+ const n = Math.max(1, Math.ceil(a.duration * fps)), out = [];
3355
+ for (let i = 0; i <= n; i++) out.push(a.duration * i / n);
2857
3356
  return out;
2858
3357
  };
2859
3358
  function checkRig(doc, opts = {}) {
2860
3359
  const fps = opts.fps ?? 30, dfps = opts.deformFps ?? 60, overlapMin = opts.overlap ?? 0.6, margin = opts.margin ?? 1;
3360
+ if (![fps, dfps].every((v) => Number.isFinite(v) && v > 0)) throw new Error("check sampling rates must be positive");
2861
3361
  const anims = opts.animation === void 0 ? doc.animations : doc.animations.filter((a) => a.id === opts.animation);
2862
3362
  if (opts.animation !== void 0 && !anims.length) throw new Error(`unknown animation "${opts.animation}"`);
2863
3363
  const out = [];
3364
+ for (const e of constraintDependencies(doc).edges.filter((e2) => e2.late)) out.push({
3365
+ level: "warn",
3366
+ code: "constraint_order",
3367
+ subject: e.reader,
3368
+ message: `constraint "${e.reader}" reads bone "${e.bone}" before later constraint "${e.writer}" moves it; use rig_order_constraints or set explicit order`
3369
+ });
2864
3370
  const W = doc.meta.width, H = doc.meta.height;
2865
3371
  const overflowOf = (pose, slotId, skel) => {
3372
+ pose = skel.pose;
2866
3373
  const pts = slotOutline(doc, pose, slotId, 4, skel);
2867
3374
  if (!pts) return void 0;
2868
3375
  const slot = doc.slots.find((s) => s.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3032,6 +3539,7 @@ function checkRig(doc, opts = {}) {
3032
3539
  return rest;
3033
3540
  };
3034
3541
  for (const a of anims) {
3542
+ out.push(...motionQuality(doc, a, opts));
3035
3543
  for (const t of a.tracks) {
3036
3544
  if (!isNumeric(t)) continue;
3037
3545
  const vals = t.keys.map((k) => k.v);
@@ -3060,6 +3568,7 @@ function checkRig(doc, opts = {}) {
3060
3568
  }
3061
3569
  }
3062
3570
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3571
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3063
3572
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3064
3573
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3065
3574
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3139,7 +3648,9 @@ function checkRig(doc, opts = {}) {
3139
3648
  if (!sorted.length) continue;
3140
3649
  const typical = sorted[Math.floor(sorted.length / 2)];
3141
3650
  const worst2 = ds.reduce((m, x) => x.d > m.d ? x : m, ds[0]);
3142
- if (worst2.d > Math.max(6, 5 * typical)) {
3651
+ const index = ds.indexOf(worst2);
3652
+ const neighbours = Math.max(ds[index - 1]?.d ?? 0, ds[index + 1]?.d ?? 0);
3653
+ if (worst2.d > Math.max(6, 5 * typical, 3 * neighbours)) {
3143
3654
  const stepped = stepTimes.some((st) => st > worst2.t - 1 / dfps - 1e-9 && st <= worst2.t + 1e-9);
3144
3655
  out.push({
3145
3656
  level: stepped ? "info" : "warn",
@@ -3190,7 +3701,7 @@ function checkRig(doc, opts = {}) {
3190
3701
  for (const att of doc.attachments) {
3191
3702
  if (att.type !== "region") continue;
3192
3703
  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;
3704
+ if (!asset || !bbox || asset.origin?.bboxSpace === "source" || !doc.slots.some((s) => s.id === att.slot)) continue;
3194
3705
  const [cx, cy] = apply(regionMatrix(restWorld, att.slot, att), 0, 0);
3195
3706
  const ex = bbox[0] + bbox[2] / 2, ey = bbox[1] + bbox[3] / 2, dx = cx - ex, dy = cy - ey;
3196
3707
  const dist = Math.hypot(dx, dy);
@@ -3235,7 +3746,8 @@ function checkRig(doc, opts = {}) {
3235
3746
  for (const att of doc.attachments) if (att.type !== "path" && shownAttachments.has(att.id)) used.add(att.asset);
3236
3747
  for (const asset of doc.assets) if (!used.has(asset.id))
3237
3748
  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))
3749
+ const maskSlots = new Set(doc.slots.flatMap((s) => s.clip ? [doc.attachments.find((a) => a.id === s.clip)?.slot] : []));
3750
+ for (const s of doc.slots) if (!s.attachment && !swappedIn.has(s.id) && !maskSlots.has(s.id))
3239
3751
  out.push({ level: "warn", code: "never_visible", subject: s.id, message: `slot "${s.id}" has no attachment and no animation ever shows one` });
3240
3752
  return out;
3241
3753
  }
@@ -3263,14 +3775,14 @@ function summarize(findings, max = 12) {
3263
3775
  }
3264
3776
 
3265
3777
  // 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";
3778
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile4, writeFile as writeFile3, unlink } from "node:fs/promises";
3779
+ import { basename as basename3, dirname as dirname5, join as join6 } from "node:path";
3268
3780
  function historyDir(rigPath) {
3269
- return join4(dirname3(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3781
+ return join6(dirname5(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3270
3782
  }
3271
3783
  async function files(dir) {
3272
3784
  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)));
3785
+ 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
3786
  } catch {
3275
3787
  return [];
3276
3788
  }
@@ -3279,22 +3791,22 @@ async function snapshots(rigPath) {
3279
3791
  return files(historyDir(rigPath));
3280
3792
  }
3281
3793
  async function listHistory(rigPath) {
3282
- return (await snapshots(rigPath)).map((f) => join4(historyDir(rigPath), f));
3794
+ return (await snapshots(rigPath)).map((f) => join6(historyDir(rigPath), f));
3283
3795
  }
3284
- var redoDir = (rigPath) => join4(historyDir(rigPath), "redo");
3796
+ var redoDir = (rigPath) => join6(historyDir(rigPath), "redo");
3285
3797
  async function listRedo(rigPath) {
3286
- return (await files(redoDir(rigPath))).map((f) => join4(redoDir(rigPath), f));
3798
+ return (await files(redoDir(rigPath))).map((f) => join6(redoDir(rigPath), f));
3287
3799
  }
3288
3800
  async function clearRedo(rigPath) {
3289
3801
  for (const f of await listRedo(rigPath)) await unlink(f);
3290
3802
  }
3291
3803
  async function pushInto(dir, contents, keep) {
3292
- await mkdir2(dir, { recursive: true });
3804
+ await mkdir3(dir, { recursive: true });
3293
3805
  const fs = await files(dir);
3294
3806
  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));
3807
+ const file = join6(dir, `${String(next).padStart(4, "0")}.json`);
3808
+ await writeFile3(file, contents);
3809
+ for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join6(dir, f));
3298
3810
  return file;
3299
3811
  }
3300
3812
  async function pushHistory(rigPath, contents, keep = 50, opts = {}) {
@@ -3307,12 +3819,12 @@ async function popHistory(rigPath, steps = 1) {
3307
3819
  if (!Number.isInteger(steps) || steps < 1 || steps > files2.length)
3308
3820
  throw new Error(`only ${files2.length} snapshot${files2.length === 1 ? "" : "s"} available, cannot undo ${steps}`);
3309
3821
  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));
3822
+ const contents = await readFile4(join6(dir, files2[files2.length - steps]), "utf8");
3823
+ for (const f of files2.slice(files2.length - steps)) await unlink(join6(dir, f));
3312
3824
  return contents;
3313
3825
  }
3314
3826
  async function undo(rigPath, steps = 1) {
3315
- const current = await readFile3(rigPath, "utf8");
3827
+ const current = await readFile4(rigPath, "utf8");
3316
3828
  const restored = await popHistory(rigPath, steps);
3317
3829
  await pushInto(redoDir(rigPath), current, 50);
3318
3830
  return restored;
@@ -3321,8 +3833,8 @@ async function redo(rigPath) {
3321
3833
  const entries2 = await listRedo(rigPath);
3322
3834
  if (!entries2.length) throw new Error("nothing to redo");
3323
3835
  const file = entries2[entries2.length - 1];
3324
- const contents = await readFile3(file, "utf8");
3325
- await pushHistory(rigPath, await readFile3(rigPath, "utf8"), 50, { keepRedo: true });
3836
+ const contents = await readFile4(file, "utf8");
3837
+ await pushHistory(rigPath, await readFile4(rigPath, "utf8"), 50, { keepRedo: true });
3326
3838
  await unlink(file);
3327
3839
  return contents;
3328
3840
  }
@@ -3393,11 +3905,13 @@ function diffDocs(a, b) {
3393
3905
  ["attachments", "attachment"],
3394
3906
  ["ik", "ik"],
3395
3907
  ["transform", "transform"],
3396
- ["path", "path"]
3908
+ ["path", "path"],
3909
+ ["controls", "control"],
3910
+ ["controlBindings", "control binding"]
3397
3911
  ];
3398
3912
  for (const [kind, name] of kinds) {
3399
- const ma = new Map(a[kind].map((x) => [x.id, x]));
3400
- const mb = new Map(b[kind].map((x) => [x.id, x]));
3913
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3914
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3401
3915
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3402
3916
  for (const [id, x] of ma) {
3403
3917
  const y = mb.get(id);
@@ -3416,9 +3930,9 @@ function diffDocs(a, b) {
3416
3930
  }
3417
3931
 
3418
3932
  // 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";
3933
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
3934
+ import { join as join7 } from "node:path";
3935
+ import { PNG as PNG2 } from "pngjs";
3422
3936
  function packRects(rects, opts = {}) {
3423
3937
  const extrude = opts.extrude ?? 1, pad = Math.max(opts.padding ?? 2, 2 * extrude), max = opts.maxSize ?? 4096;
3424
3938
  const sorted = [...rects].sort((a, b) => b.height - a.height || b.width - a.width || a.id.localeCompare(b.id));
@@ -3476,10 +3990,10 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3476
3990
  for (const a of doc.assets) {
3477
3991
  if (!/\.png$/i.test(a.src)) throw new Error(`atlas requires PNG assets: "${a.src}" (asset "${a.id}") is not a PNG`);
3478
3992
  let png = files2.get(a.src);
3479
- if (!png) files2.set(a.src, png = PNG.sync.read(await readFile4(join5(assetsDir, a.src))));
3993
+ if (!png) files2.set(a.src, png = PNG2.sync.read(await readFile5(join7(assetsDir, a.src))));
3480
3994
  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);
3995
+ const [fx, fy, fw, fh] = a.frame, sub = new PNG2({ width: fw, height: fh });
3996
+ PNG2.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
3483
3997
  png = sub;
3484
3998
  }
3485
3999
  if (png.width !== a.width || png.height !== a.height)
@@ -3487,26 +4001,26 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3487
4001
  images.set(a.id, png);
3488
4002
  }
3489
4003
  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 });
4004
+ const sheet = new PNG2({ width: packed.width, height: packed.height });
3491
4005
  for (const [id, [x, y, w, h]] of packed.frames) {
3492
- PNG.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
4006
+ PNG2.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
3493
4007
  extrudeFrame(sheet, x, y, w, h, opts.extrude ?? 1);
3494
4008
  }
3495
- await mkdir3(outDir, { recursive: true });
3496
- await writeFile3(join5(outDir, "atlas.png"), PNG.sync.write(sheet));
4009
+ await mkdir4(outDir, { recursive: true });
4010
+ await writeFile4(join7(outDir, "atlas.png"), PNG2.sync.write(sheet));
3497
4011
  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);
4012
+ await saveJson(join7(outDir, "rig.json"), out);
3499
4013
  return out;
3500
4014
  }
3501
4015
 
3502
4016
  // 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";
4017
+ import { readFile as readFile7, stat, writeFile as writeFile5 } from "node:fs/promises";
4018
+ import { basename as basename4, dirname as dirname6, extname, join as join8, relative } from "node:path";
3505
4019
 
3506
4020
  // src/ktx2.ts
3507
- import { readFile as readFile5 } from "node:fs/promises";
4021
+ import { readFile as readFile6 } from "node:fs/promises";
3508
4022
  import { createRequire } from "node:module";
3509
- import { PNG as PNG2 } from "pngjs";
4023
+ import { PNG as PNG3 } from "pngjs";
3510
4024
  var require2 = createRequire(import.meta.url);
3511
4025
  var basisPromise;
3512
4026
  async function basis2() {
@@ -3518,8 +4032,8 @@ async function basis2() {
3518
4032
  } catch {
3519
4033
  throw new Error("KTX2 pages need the basis transcoder from three.js: `npm i -D three`, or convert the page to PNG yourself");
3520
4034
  }
3521
- const wasm = await readFile5(jsPath.replace(/\.js$/, ".wasm"));
3522
- const src = await readFile5(jsPath, "utf8");
4035
+ const wasm = await readFile6(jsPath.replace(/\.js$/, ".wasm"));
4036
+ const src = await readFile6(jsPath, "utf8");
3523
4037
  const mod = { exports: {} };
3524
4038
  new Function("require", "module", "exports", "__dirname", "__filename", src)(require2, mod, mod.exports, jsPath.replace(/\/[^/]+$/, ""), jsPath);
3525
4039
  const Basis = await mod.exports({ wasmBinary: wasm });
@@ -3546,9 +4060,9 @@ async function ktx2ToRgba(data) {
3546
4060
  }
3547
4061
  async function ktx2ToPng(data) {
3548
4062
  const { rgba, ...info } = await ktx2ToRgba(data);
3549
- const png = new PNG2({ width: info.width, height: info.height });
4063
+ const png = new PNG3({ width: info.width, height: info.height });
3550
4064
  png.data = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
3551
- return { ...info, png: PNG2.sync.write(png) };
4065
+ return { ...info, png: PNG3.sync.write(png) };
3552
4066
  }
3553
4067
 
3554
4068
  // src/spine-import.ts
@@ -3688,9 +4202,9 @@ function importSpine(json, atlasText, opts = {}) {
3688
4202
  };
3689
4203
  const sequences = /* @__PURE__ */ new Map();
3690
4204
  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 ?? {}))
4205
+ for (const skin2 of skins) {
4206
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4207
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
3694
4208
  for (const [name, a] of Object.entries(atts)) {
3695
4209
  const key = `${slotName} ${name}`;
3696
4210
  const seq = a.sequence;
@@ -3755,11 +4269,11 @@ function importSpine(json, atlasText, opts = {}) {
3755
4269
  }
3756
4270
  return { weights };
3757
4271
  };
3758
- for (const skin of skins) {
3759
- const prefix = skin.name === "default" ? "" : `${skin.name}_`;
3760
- for (const [slotName, atts] of Object.entries(skin.attachments ?? {})) {
4272
+ for (const skin2 of skins) {
4273
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4274
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
3761
4275
  if (!slotIndexByName.has(slotName)) {
3762
- warnings.push(`skin "${skin.name}": unknown slot "${slotName}"`);
4276
+ warnings.push(`skin "${skin2.name}": unknown slot "${slotName}"`);
3763
4277
  continue;
3764
4278
  }
3765
4279
  const slot = slotIds.get(slotName);
@@ -4090,9 +4604,9 @@ function importSpine(json, atlasText, opts = {}) {
4090
4604
  const attFrames = attachmentFrames.get(slotName);
4091
4605
  const setupName = spineSlots.find((x) => x.name === slotName)?.attachment ?? null;
4092
4606
  const at = (xs, t) => {
4093
- let hit;
4094
- for (const x of xs) if (x.t <= t + 1e-9) hit = x;
4095
- return hit;
4607
+ let hit2;
4608
+ for (const x of xs) if (x.t <= t + 1e-9) hit2 = x;
4609
+ return hit2;
4096
4610
  };
4097
4611
  const shownAt = (t) => {
4098
4612
  let name = setupName;
@@ -4198,21 +4712,21 @@ async function firstExisting(candidates, what) {
4198
4712
  throw new Error(`${what} not found, tried: ${candidates.join(", ")}`);
4199
4713
  }
4200
4714
  async function importSpineProject(opts) {
4201
- const json = JSON.parse(await readFile6(opts.json, "utf8"));
4715
+ const json = JSON.parse(await readFile7(opts.json, "utf8"));
4202
4716
  const stem = opts.json.replace(/\.json$/i, "");
4203
4717
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4204
- const atlasText = await readFile6(atlasPath, "utf8");
4718
+ const atlasText = await readFile7(atlasPath, "utf8");
4205
4719
  const { pages } = parseAtlas(atlasText);
4206
4720
  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);
4721
+ const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4722
+ const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4723
+ const rigDir = dirname6(opts.rigPath);
4210
4724
  let pngPath = imagePath;
4211
4725
  if (extname(imagePath).toLowerCase() === ".ktx2") {
4212
- pngPath = join6(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4726
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4213
4727
  if (!await exists(pngPath)) {
4214
- const { png } = await ktx2ToPng(new Uint8Array(await readFile6(imagePath)));
4215
- await writeFile4(pngPath, png);
4728
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4729
+ await writeFile5(pngPath, png);
4216
4730
  }
4217
4731
  }
4218
4732
  const pageImage = relative(rigDir, pngPath).split("\\").join("/");
@@ -4234,7 +4748,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4234
4748
  const tracks = [];
4235
4749
  const skipped = [];
4236
4750
  for (const t of anim.tracks) {
4237
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4751
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4238
4752
  skipped.push(trackId5(t));
4239
4753
  continue;
4240
4754
  }
@@ -4332,11 +4846,11 @@ function describeTemplate(t) {
4332
4846
  }
4333
4847
 
4334
4848
  // 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";
4849
+ import { readFile as readFile8, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
4850
+ import { dirname as dirname7 } from "node:path";
4337
4851
  async function loadLibrary(path) {
4338
4852
  try {
4339
- return JSON.parse(await readFile7(path, "utf8"));
4853
+ return JSON.parse(await readFile8(path, "utf8"));
4340
4854
  } catch (e) {
4341
4855
  if (e.code === "ENOENT") return [];
4342
4856
  throw e;
@@ -4347,14 +4861,14 @@ async function saveTemplate(path, tpl) {
4347
4861
  const i = all.findIndex((t) => t.name === tpl.name);
4348
4862
  if (i >= 0) all[i] = tpl;
4349
4863
  else all.push(tpl);
4350
- await mkdir4(dirname5(path), { recursive: true });
4351
- await writeFile5(path, JSON.stringify(all, null, 2));
4864
+ await mkdir5(dirname7(path), { recursive: true });
4865
+ await writeFile6(path, JSON.stringify(all, null, 2));
4352
4866
  }
4353
4867
 
4354
4868
  // src/rig-mesh-image.ts
4355
- import { PNG as PNG3 } from "pngjs";
4869
+ import { PNG as PNG4 } from "pngjs";
4356
4870
  function alphaMaskFromPng(buf, frame) {
4357
- const png = PNG3.sync.read(buf);
4871
+ const png = PNG4.sync.read(buf);
4358
4872
  const [fx, fy, w, h] = frame ?? [0, 0, png.width, png.height];
4359
4873
  if (fx < 0 || fy < 0 || w < 1 || h < 1 || fx + w > png.width || fy + h > png.height) {
4360
4874
  throw new Error(`frame [${fx}, ${fy}, ${w}, ${h}] is outside the ${png.width}x${png.height} image`);
@@ -4364,11 +4878,526 @@ function alphaMaskFromPng(buf, frame) {
4364
4878
  return { width: w, height: h, alpha };
4365
4879
  }
4366
4880
 
4881
+ // src/rig-gates.ts
4882
+ import { z as z2 } from "zod";
4883
+
4884
+ // src/rig-state.ts
4885
+ var AnimationState = class {
4886
+ constructor(sink, opts = {}) {
4887
+ this.sink = sink;
4888
+ this.opts = opts;
4889
+ this.defaultMix = opts.mix ?? 0;
4890
+ }
4891
+ onEvent;
4892
+ base;
4893
+ main;
4894
+ queue = [];
4895
+ overlays = /* @__PURE__ */ new Map();
4896
+ /** last composed pose without overlays — mix snapshots start here */
4897
+ pose = /* @__PURE__ */ new Map();
4898
+ from;
4899
+ mixLeft = 0;
4900
+ mixTotal = 0;
4901
+ /** a mix begun in this update shows weight 0 for the frame it starts on */
4902
+ mixFresh = false;
4903
+ defaultMix;
4904
+ /** id of the animation currently shown (main, else base) */
4905
+ get current() {
4906
+ return (this.main ?? this.base)?.anim.id;
4907
+ }
4908
+ get baseId() {
4909
+ return this.base?.anim.id;
4910
+ }
4911
+ channel(id, loop, priority = 0) {
4912
+ const anim = this.sink.doc.animations.find((a) => a.id === id);
4913
+ if (!anim) {
4914
+ const message = `unknown animation "${id}"; available: ${this.sink.doc.animations.map((a) => a.id).join(", ")}`;
4915
+ if (this.opts.missingAnimation === "throw") throw new Error(message);
4916
+ (this.opts.onWarning ?? console.warn)(message);
4917
+ return void 0;
4918
+ }
4919
+ return { anim, time: 0, loop: loop ?? anim.loop, done: false, priority, fresh: true };
4920
+ }
4921
+ setBase(id, opts = {}) {
4922
+ const next = id === void 0 ? void 0 : this.channel(id);
4923
+ if (id !== void 0 && !next) return;
4924
+ this.base = next;
4925
+ if (!this.main || this.main.done) {
4926
+ this.main = void 0;
4927
+ this.beginMix(opts.mix);
4928
+ this.update(0);
4929
+ }
4930
+ }
4931
+ play(id, opts = {}) {
4932
+ const ch = this.channel(id, opts.loop, opts.priority ?? 0);
4933
+ if (!ch) {
4934
+ this.main?.settle?.("interrupted");
4935
+ for (const queued of this.queue) queued.settle?.("dropped");
4936
+ this.main = void 0;
4937
+ this.queue = [];
4938
+ this.beginMix(opts.mix);
4939
+ this.update(0);
4940
+ opts.onComplete?.("dropped");
4941
+ return Promise.resolve("dropped");
4942
+ }
4943
+ const promise = new Promise((resolve2) => {
4944
+ ch.settle = (r) => {
4945
+ ch.settle = void 0;
4946
+ opts.onComplete?.(r);
4947
+ resolve2(r);
4948
+ };
4949
+ });
4950
+ const cur = this.main, busy = cur !== void 0 && !cur.done, mode = opts.ifBusy ?? "queue";
4951
+ if (!busy || mode === "interrupt" || ch.priority >= cur.priority) {
4952
+ if (busy) cur.settle?.("interrupted");
4953
+ this.main = ch;
4954
+ this.beginMix(opts.mix);
4955
+ this.update(0);
4956
+ } else if (mode === "drop") ch.settle("dropped");
4957
+ else this.queue.push(ch);
4958
+ return promise;
4959
+ }
4960
+ /** Independent loop on top of the shown pose; `undefined` removes it. */
4961
+ setOverlay(name, id) {
4962
+ if (id === void 0) this.overlays.delete(name);
4963
+ else {
4964
+ const ch = this.channel(id);
4965
+ if (ch) this.overlays.set(name, ch);
4966
+ }
4967
+ }
4968
+ /** Clear everything and show the setup pose. Pending plays resolve as "stopped". */
4969
+ stop() {
4970
+ this.main?.settle?.("stopped");
4971
+ for (const ch of this.queue) ch.settle?.("stopped");
4972
+ this.main = this.base = void 0;
4973
+ this.queue = [];
4974
+ this.overlays.clear();
4975
+ this.from = void 0;
4976
+ this.mixLeft = this.mixTotal = 0;
4977
+ this.mixFresh = false;
4978
+ this.pose = /* @__PURE__ */ new Map();
4979
+ this.sink.setPose(/* @__PURE__ */ new Map());
4980
+ }
4981
+ update(dt) {
4982
+ if (this.base) this.advance(this.base, dt, this.main === void 0);
4983
+ if (this.main) {
4984
+ const wasDone = this.main.done;
4985
+ this.advance(this.main, dt, true);
4986
+ if (this.main.done && !wasDone) {
4987
+ this.main.settle?.("completed");
4988
+ const next = this.queue.shift();
4989
+ if (next || this.base) {
4990
+ this.main = next;
4991
+ this.beginMix();
4992
+ }
4993
+ }
4994
+ }
4995
+ const shown = this.main ?? this.base;
4996
+ let pose = shown ? poseOf(shown.anim, shown.time) : /* @__PURE__ */ new Map();
4997
+ if (this.from) {
4998
+ if (!this.mixFresh) this.mixLeft = Math.max(0, this.mixLeft - dt);
4999
+ this.mixFresh = false;
5000
+ const w = this.mixTotal > 0 ? 1 - this.mixLeft / this.mixTotal : 1;
5001
+ pose = blendPoses(this.from, pose, w, (k) => restValue(this.sink.doc, k));
5002
+ if (this.mixLeft === 0) this.from = void 0;
5003
+ }
5004
+ this.pose = pose;
5005
+ if (this.overlays.size) {
5006
+ pose = new Map(pose);
5007
+ for (const [name, ov] of this.overlays) {
5008
+ this.advance(ov, dt, false);
5009
+ poseOf(ov.anim, ov.time, pose);
5010
+ if (ov.done) this.overlays.delete(name);
5011
+ }
5012
+ }
5013
+ this.sink.setPose(pose);
5014
+ }
5015
+ beginMix(mix = this.defaultMix) {
5016
+ if (mix > 0) {
5017
+ this.from = new Map(this.pose);
5018
+ this.mixLeft = this.mixTotal = mix;
5019
+ this.mixFresh = true;
5020
+ } else {
5021
+ this.from = void 0;
5022
+ this.mixLeft = this.mixTotal = 0;
5023
+ this.mixFresh = false;
5024
+ }
5025
+ }
5026
+ advance(ch, dt, emit) {
5027
+ if (ch.done) return;
5028
+ const prev = ch.fresh ? -1 : ch.time, d = ch.anim.duration;
5029
+ ch.fresh = false;
5030
+ ch.time += dt;
5031
+ if (emit) {
5032
+ for (const e of ch.anim.events) if (hit(e.t, prev, ch.time, d, ch.loop)) this.onEvent?.(e.name, e.payload);
5033
+ }
5034
+ if (ch.time >= d) {
5035
+ if (ch.loop) ch.time %= d;
5036
+ else {
5037
+ ch.time = d;
5038
+ ch.done = true;
5039
+ }
5040
+ }
5041
+ }
5042
+ };
5043
+ function hit(t, prev, cur, d, loop) {
5044
+ if (cur < d || !loop) return t > prev && t <= cur;
5045
+ return t > prev && t <= d || t <= cur % d;
5046
+ }
5047
+
5048
+ // src/rig-gates.ts
5049
+ var Limit = z2.number().nonnegative().optional();
5050
+ var Window = z2.tuple([z2.number().nonnegative(), z2.number().nonnegative()]).optional();
5051
+ var GateConfig = z2.object({
5052
+ animation: z2.string(),
5053
+ fps: z2.number().int().min(1).max(240).default(60),
5054
+ requiredAnimations: z2.array(z2.string()).default([]),
5055
+ 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([]),
5056
+ distances: z2.array(z2.object({ a: z2.string(), b: z2.string(), min: Limit, max: Limit, minRatio: Limit, maxRatio: Limit, during: Window })).default([]),
5057
+ 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([]),
5058
+ 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([])
5059
+ }).strict();
5060
+ var norm = (v) => Math.hypot(...v);
5061
+ var subtract = (a, b) => a.map((v, i) => v - b[i]);
5062
+ var wrap = (v) => ((v + 180) % 360 + 360) % 360 - 180;
5063
+ function runGates(doc, config) {
5064
+ const cfg = GateConfig.parse(config), animation = doc.animations.find((a) => a.id === cfg.animation);
5065
+ if (!animation) throw new Error(`unknown animation "${cfg.animation}"`);
5066
+ 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");
5067
+ const failures = [];
5068
+ const metrics = {};
5069
+ for (const id of cfg.requiredAnimations) if (!doc.animations.some((a) => a.id === id)) failures.push(`required animation missing: ${id}`);
5070
+ const n = Math.max(1, Math.ceil(animation.duration * cfg.fps)), dt = animation.duration / n;
5071
+ const frames = Array.from({ length: n + 1 }, (_, i) => {
5072
+ const t = i * dt, pose = poseOf(animation, t);
5073
+ return { t, pose, skel: evaluate(doc, pose) };
5074
+ });
5075
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5076
+ const bone = (s, id, channel, space) => {
5077
+ const b = s.bones.get(id);
5078
+ if (!b) throw new Error(`unknown gate bone "${id}"`);
5079
+ 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];
5080
+ };
5081
+ const inside = (t, w) => !w || t >= w[0] - 1e-9 && t <= w[1] + 1e-9;
5082
+ const windowCheck = (w) => {
5083
+ if (w && (w[0] > w[1] || w[1] > animation.duration)) throw new Error("invalid gate time window");
5084
+ };
5085
+ for (const rule of cfg.motion) {
5086
+ 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");
5087
+ windowCheck(rule.during);
5088
+ const fs = frames.filter((f) => inside(f.t, rule.during));
5089
+ if (fs.length < 4) throw new Error(`motion gate ${rule.bone} needs at least four samples`);
5090
+ const vs = fs.map((f) => bone(f.skel, rule.bone, rule.channel, rule.space));
5091
+ 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]);
5092
+ const derivative = (xs) => xs.slice(1).map((v, i) => subtract(v, xs[i]).map((v2) => v2 / dt));
5093
+ const velocity = derivative(vs), acceleration = derivative(velocity), jerk = derivative(acceleration);
5094
+ const range = vs[0].map((_, i) => Math.max(...vs.map((v) => v[i])) - Math.min(...vs.map((v) => v[i])));
5095
+ const setup = bone(rest, rule.bone, rule.channel, rule.space);
5096
+ 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)))) };
5097
+ const id = `${rule.bone}.${rule.channel}.${rule.space}`;
5098
+ metrics[id] = m;
5099
+ if (rule.minAmplitude !== void 0 && m.amplitude < rule.minAmplitude) failures.push(`${id}: amplitude ${m.amplitude} < ${rule.minAmplitude}`);
5100
+ for (const [limit, key] of [["maxSpeed", "speed"], ["maxAcceleration", "acceleration"], ["maxJerk", "jerk"], ["maxStep", "step"], ["maxSetupDrift", "setupDrift"]])
5101
+ if (rule[limit] !== void 0 && m[key] > rule[limit]) failures.push(`${id}: ${key} ${m[key]} > ${rule[limit]}`);
5102
+ }
5103
+ for (const rule of cfg.distances) {
5104
+ if ([rule.min, rule.max, rule.minRatio, rule.maxRatio].every((v) => v === void 0)) throw new Error("distance gate needs an explicit threshold");
5105
+ windowCheck(rule.during);
5106
+ const fs = frames.filter((f) => inside(f.t, rule.during));
5107
+ if (!fs.length) throw new Error("distance gate has no samples");
5108
+ const distance = (s) => norm(subtract(bone(s, rule.a, "position", "world"), bone(s, rule.b, "position", "world")));
5109
+ const setup = distance(rest), values2 = fs.map((f) => distance(f.skel)), lo = Math.min(...values2), hi = Math.max(...values2);
5110
+ metrics[`${rule.a}-${rule.b}`] = { samples: fs.length, min: lo, max: hi, setup };
5111
+ if ((rule.minRatio !== void 0 || rule.maxRatio !== void 0) && setup < 1e-9) throw new Error("distance ratio needs noncoincident setup landmarks");
5112
+ 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`);
5113
+ }
5114
+ for (const rule of cfg.conditions) {
5115
+ if (rule.min === void 0 && rule.max === void 0) throw new Error("condition gate needs an explicit threshold");
5116
+ const slot = doc.slots.find((s) => s.id === rule.whenSlotVisible);
5117
+ if (!slot) throw new Error(`unknown gate slot "${rule.whenSlotVisible}"`);
5118
+ const ts = new Set(frames.map((f) => f.t));
5119
+ for (const tr of animation.tracks) for (const k of tr.keys) {
5120
+ ts.add(k.t);
5121
+ if (k.t > 0) ts.add(Math.max(0, k.t - 1e-6));
5122
+ }
5123
+ let active2 = 0, bad = 0;
5124
+ for (const t of ts) {
5125
+ const pose = poseOf(animation, t), s = evaluate(doc, pose);
5126
+ const color = s.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5127
+ const alpha = Number(s.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
5128
+ if (!s.attachment.get(slot.id) || alpha <= 0.01) continue;
5129
+ active2++;
5130
+ const v = bone(s, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
5131
+ if (rule.min !== void 0 && v < rule.min || rule.max !== void 0 && v > rule.max) bad++;
5132
+ }
5133
+ metrics[`condition:${slot.id}:${rule.bone}.${rule.prop}`] = { active: active2, bad };
5134
+ if (bad || !active2 && rule.required) failures.push(`${slot.id} condition: ${bad} invalid visible samples, ${active2} active samples`);
5135
+ }
5136
+ for (const tr of cfg.transitions) {
5137
+ for (const id of [tr.from, tr.to]) if (!doc.animations.some((a) => a.id === id)) throw new Error(`missing transition animation ${id}`);
5138
+ const sink = { doc, pose: /* @__PURE__ */ new Map(), setPose(p) {
5139
+ this.pose = p;
5140
+ } }, state = new AnimationState(sink, { mix: tr.mix, missingAnimation: "throw" });
5141
+ state.setBase(tr.from, { mix: 0 });
5142
+ state.update(tr.fromTime);
5143
+ let previous = evaluate(doc, sink.pose), previousPose = sink.pose, maxStep = 0;
5144
+ state.play(tr.to);
5145
+ const measure = () => {
5146
+ const next = evaluate(doc, sink.pose);
5147
+ for (const b of doc.bones) {
5148
+ const a = previous.bones.get(b.id).world, c = next.bones.get(b.id).world;
5149
+ const p = apply(a, b.length, 0), q = apply(c, b.length, 0);
5150
+ maxStep = Math.max(maxStep, Math.hypot(c.tx - a.tx, c.ty - a.ty), Math.hypot(q[0] - p[0], q[1] - p[1]));
5151
+ }
5152
+ for (const s of doc.slots) {
5153
+ if (previous.attachment.get(s.id)?.id !== next.attachment.get(s.id)?.id) continue;
5154
+ const before = slotOutline(doc, previousPose, s.id, 1, previous), after = slotOutline(doc, sink.pose, s.id, 1, next);
5155
+ 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]));
5156
+ }
5157
+ previous = next;
5158
+ previousPose = sink.pose;
5159
+ };
5160
+ measure();
5161
+ const count = Math.ceil(tr.duration * cfg.fps);
5162
+ for (let i = 0; i < count; i++) {
5163
+ state.update(1 / cfg.fps);
5164
+ measure();
5165
+ }
5166
+ state.stop();
5167
+ metrics[`transition:${tr.from}->${tr.to}`] = { samples: count + 1, maxStep, mix: tr.mix };
5168
+ if (maxStep > tr.maxStep) failures.push(`transition ${tr.from}->${tr.to}: step ${maxStep} > ${tr.maxStep}`);
5169
+ }
5170
+ return { pass: failures.length === 0, animation: animation.id, fps: cfg.fps, interval: dt, expected: n + 1, inspected: frames.length, metrics, failures };
5171
+ }
5172
+
5173
+ // src/rig-api.ts
5174
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5175
+
5176
+ // src/rig-motion.ts
5177
+ var wrap2 = (x) => ((x + 180) % 360 + 360) % 360 - 180;
5178
+ var mesh = (doc, id) => {
5179
+ const a = doc.attachments.find((a2) => a2.id === id);
5180
+ if (a?.type !== "mesh") throw new Error(`expected mesh "${id}"`);
5181
+ return a;
5182
+ };
5183
+ function skin(doc, a, weights) {
5184
+ const skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5185
+ return weights.map((list, i) => {
5186
+ const sum = [...list.values()].reduce((a2, b) => a2 + b, 0);
5187
+ if (!(sum > 0)) throw new Error(`vertex ${i}: weights sum to zero`);
5188
+ return [...list].filter(([, w]) => w > 1e-9).map(([bone, w]) => {
5189
+ const b = skel.bones.get(bone);
5190
+ if (!b) throw new Error(`unknown weight bone "${bone}"`);
5191
+ const [x, y] = apply(invert(b.world), world[i * 2], world[i * 2 + 1]);
5192
+ return { bone, x, y, w: w / sum };
5193
+ });
5194
+ });
5195
+ }
5196
+ function gradientWeights(doc, id, bones) {
5197
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map());
5198
+ if (bones.length < 2 || new Set(bones).size !== bones.length) throw new Error("gradient needs at least two distinct bones");
5199
+ const points = bones.map((id2) => {
5200
+ const b = skel.bones.get(id2);
5201
+ if (!b) throw new Error(`unknown bone "${id2}"`);
5202
+ return [b.world.tx, b.world.ty];
5203
+ });
5204
+ const world = attachmentWorldVertices(skel, a.slot, a), weights = [];
5205
+ for (let v = 0; v < world.length; v += 2) {
5206
+ let best = Infinity, segment = 0, u = 0;
5207
+ for (let i = 0; i < points.length - 1; i++) {
5208
+ const [x, y] = points[i], dx = points[i + 1][0] - x, dy = points[i + 1][1] - y, l2 = dx * dx + dy * dy;
5209
+ if (l2 < 1e-12) throw new Error("gradient chain contains coincident joints");
5210
+ const t = Math.max(0, Math.min(1, ((world[v] - x) * dx + (world[v + 1] - y) * dy) / l2));
5211
+ const d = (world[v] - x - t * dx) ** 2 + (world[v + 1] - y - t * dy) ** 2;
5212
+ if (d < best) {
5213
+ best = d;
5214
+ segment = i;
5215
+ u = t;
5216
+ }
5217
+ }
5218
+ weights.push(/* @__PURE__ */ new Map([[bones[segment], 1 - u], [bones[segment + 1], u]]));
5219
+ }
5220
+ return setWeights(doc, id, skin(doc, a, weights)).doc;
5221
+ }
5222
+ function smoothWeights(doc, id, iterations = 3, strength = 0.5, locked = []) {
5223
+ const a = mesh(doc, id);
5224
+ if (!a.weights) throw new Error("skin the mesh before smoothing weights");
5225
+ if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100 || strength < 0 || strength > 1) throw new Error("invalid smoothing options");
5226
+ const fixed = new Set(locked), neighbours = a.weights.map(() => /* @__PURE__ */ new Set());
5227
+ for (const v of fixed) if (!Number.isInteger(v) || v < 0 || v >= neighbours.length) throw new Error(`invalid locked vertex ${v}`);
5228
+ 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);
5229
+ let w = a.weights.map((list) => new Map(list.map((i) => [i.bone, i.w])));
5230
+ for (let iter = 0; iter < iterations; iter++) w = w.map((own, i) => {
5231
+ if (fixed.has(i) || !neighbours[i].size) return own;
5232
+ const next = new Map([...own].map(([b, v]) => [b, v * (1 - strength)]));
5233
+ 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);
5234
+ return next;
5235
+ });
5236
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5237
+ }
5238
+ function pinWeights(doc, id, bone, vertices, region) {
5239
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5240
+ if (!skel.bones.has(bone)) throw new Error(`unknown bone "${bone}"`);
5241
+ if (!vertices?.length && !region) throw new Error("select vertices or a document-space region");
5242
+ const selected = new Set(vertices);
5243
+ for (const v of selected) if (!Number.isInteger(v) || v < 0 || v >= world.length / 2) throw new Error(`invalid vertex ${v}`);
5244
+ const host = doc.slots.find((s) => s.id === a.slot).bone;
5245
+ let count = 0;
5246
+ const w = Array.from({ length: world.length / 2 }, (_, i) => {
5247
+ const x = world[i * 2], y = world[i * 2 + 1];
5248
+ const inside = region && x >= region[0] && y >= region[1] && x <= region[0] + region[2] && y <= region[1] + region[3];
5249
+ if (selected.has(i) || inside) {
5250
+ count++;
5251
+ return /* @__PURE__ */ new Map([[bone, 1]]);
5252
+ }
5253
+ return new Map(a.weights?.[i].map((inf) => [inf.bone, inf.w]) ?? [[host, 1]]);
5254
+ });
5255
+ if (!count) throw new Error("selection contains no mesh vertices");
5256
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5257
+ }
5258
+ function attachToBone(doc, animation, bone, target, t) {
5259
+ const anim = doc.animations.find((a) => a.id === animation);
5260
+ if (!anim || t < 0 || t > anim.duration) throw new Error("attach requires an existing animation and time within its duration");
5261
+ const skel = evaluate(doc, poseOf(anim, t)), from = skel.bones.get(bone)?.world, to = skel.bones.get(target)?.world;
5262
+ if (!from || !to || bone === target) throw new Error("attach needs distinct existing bones");
5263
+ 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");
5264
+ if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5265
+ const [x, y] = apply(invert(to), from.tx, from.ty);
5266
+ const id = `attach_${bone}_${target}`;
5267
+ const offset = {
5268
+ x,
5269
+ y,
5270
+ rotation: wrap2(rotationOf(from) - rotationOf(to)),
5271
+ scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5272
+ scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5273
+ 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)
5274
+ };
5275
+ const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5276
+ let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
5277
+ for (const c of next.transform.filter((c2) => c2.id.startsWith(`attach_${bone}_`) && c2.bones.length === 1 && c2.bones[0] === bone)) {
5278
+ for (const prop of ["mixX", "mixY", "mixRotate", "mixScaleX", "mixScaleY", "mixShearY"]) {
5279
+ const has = anim.tracks.some((tr) => tr.target === "transform" && tr.id === c.id && tr.prop === prop);
5280
+ const keys = [...!has && t > 0 ? [{ t: 0, v: 0, ease: "step" }] : [], { t, v: c.id === id ? 1 : 0, ease: "step" }];
5281
+ next = setKeys(next, animation, { target: "transform", id: c.id, prop }, keys, void 0, { merge: true });
5282
+ }
5283
+ }
5284
+ for (const [prop, v] of Object.entries(offset)) next = setKeys(next, animation, { target: "transform", id, prop }, [{ t, v, ease: "step" }], void 0, { merge: true });
5285
+ return next;
5286
+ }
5287
+ function bakeSecondary(doc, animation, opts) {
5288
+ const anim = doc.animations.find((a) => a.id === animation);
5289
+ if (!anim) throw new Error(`unknown animation "${animation}"`);
5290
+ const fps = opts.fps ?? 60, hz = opts.frequency ?? 2.5, damping = opts.damping ?? 0.65, gain = opts.gain ?? 0.2, maxAngle = opts.maxAngle ?? 30;
5291
+ if (fps <= 0 || hz <= 0 || damping <= 0 || maxAngle <= 0 || !opts.bones.length) throw new Error("invalid spring parameters");
5292
+ 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");
5293
+ if (new Set(opts.bones).size !== opts.bones.length) throw new Error("secondary bones must be distinct");
5294
+ for (let b = doc.bones.find((b2) => b2.id === opts.driver); b; b = doc.bones.find((x) => x.id === b.parent)) {
5295
+ if (opts.bones.includes(b.id)) throw new Error("secondary bones cannot be ancestors of the driver");
5296
+ }
5297
+ const n = Math.max(2, Math.ceil(anim.duration * fps)), dt = anim.duration / n;
5298
+ const base = { ...anim, tracks: anim.tracks.filter((t) => !(t.target === "bone" && opts.bones.includes(t.id) && t.prop === "rotation")) };
5299
+ const angular = (opts.driverProp ?? "rotation") === "rotation";
5300
+ const angles = Array.from({ length: n + 1 }, (_, i) => {
5301
+ const world = evaluate(doc, poseOf(base, dt * i)).bones.get(opts.driver).world;
5302
+ return angular ? rotationOf(world) : opts.driverProp === "x" ? world.tx : world.ty;
5303
+ });
5304
+ const seam = angles[n] - angles[0];
5305
+ 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");
5306
+ let next = doc;
5307
+ for (let bi = 0; bi < opts.bones.length; bi++) {
5308
+ const bone = doc.bones.find((b) => b.id === opts.bones[bi]);
5309
+ if (!bone) throw new Error(`unknown secondary bone "${opts.bones[bi]}"`);
5310
+ 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;
5311
+ let x = 0, velocity = 0, samples = [];
5312
+ const cycle = () => {
5313
+ samples = [x];
5314
+ for (let i = 1; i <= n; i++) {
5315
+ const difference = angles[i] - angles[i - 1];
5316
+ const drive = (angular ? wrap2(difference) : difference) / dt;
5317
+ for (let j2 = 0; j2 < sub; j2++) {
5318
+ velocity += (-omega * omega * x - 2 * damping * omega * velocity - gain * omega * drive) * h;
5319
+ x += velocity * h;
5320
+ if (Math.abs(x) > maxAngle) {
5321
+ x = Math.sign(x) * maxAngle;
5322
+ velocity = 0;
5323
+ }
5324
+ }
5325
+ samples.push(x);
5326
+ }
5327
+ };
5328
+ if (anim.loop) {
5329
+ let converged = false;
5330
+ for (let i = 0; i < 200; i++) {
5331
+ const oldX = x, oldV = velocity;
5332
+ cycle();
5333
+ if (Math.abs(x - oldX) < 1e-7 && Math.abs(velocity - oldV) < 1e-7) {
5334
+ converged = true;
5335
+ break;
5336
+ }
5337
+ }
5338
+ if (!converged) throw new Error("spring loop did not converge; increase damping or duration");
5339
+ samples[n] = samples[0];
5340
+ } else cycle();
5341
+ const origin = anim.loop ? samples[0] : 0;
5342
+ const amplitude = Math.max(...samples.map((v) => Math.abs(v - origin)));
5343
+ const scale = Math.min(1, maxAngle / Math.max(amplitude, 1e-12));
5344
+ 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);
5345
+ }
5346
+ return next;
5347
+ }
5348
+
5349
+ // src/rig-control-qa.ts
5350
+ function snapshot(doc, values2) {
5351
+ const skel = evaluate(doc, controlPose(doc, values2));
5352
+ return doc.slots.flatMap((slot) => {
5353
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5354
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5355
+ return points ? [{ id: slot.id, alpha, points }] : [];
5356
+ });
5357
+ }
5358
+ function checkControlCases(doc, cases) {
5359
+ const errors = [];
5360
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5361
+ cases.forEach((c, index) => {
5362
+ const skel = evaluate(doc, controlPose(doc, c.values));
5363
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5364
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5365
+ for (const att of doc.attachments) if (att.type === "mesh") {
5366
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5367
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5368
+ else if (foldedTriangles(before, after, att.triangles).length || att.triangles.some((_, i) => i % 3 === 0 && Math.abs(triangleArea(before, ...att.triangles.slice(i, i + 3))) > 1e-8 && Math.abs(triangleArea(after, ...att.triangles.slice(i, i + 3))) < 1e-8))
5369
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5370
+ }
5371
+ });
5372
+ const neutral = JSON.stringify(snapshot(doc, {}));
5373
+ const effects = (doc.controls ?? []).map((c) => ({
5374
+ id: c.id,
5375
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5376
+ }));
5377
+ return {
5378
+ passed: errors.length === 0,
5379
+ errors,
5380
+ effects,
5381
+ scope: "Finite transforms and mesh orientation at the listed poses; individual control geometry/opacity effects. Images require visual review; this is not an artwork acceptance result."
5382
+ };
5383
+ }
5384
+ function makeControlSweep(doc, probes) {
5385
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5386
+ const animation = {
5387
+ id: "control_sweep",
5388
+ duration: Math.max(1, cases.length - 1),
5389
+ loop: false,
5390
+ events: [],
5391
+ tracks: (doc.controls ?? []).map((c) => ({ target: "control", id: c.id, prop: "value", keys: cases.map((p, i) => ({ t: i, v: p.values[c.id], ease: "step" })) }))
5392
+ };
5393
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5394
+ }
5395
+
4367
5396
  // src/rig-api.ts
4368
5397
  var tools = [];
4369
5398
  function tool(name, description, input, run) {
4370
5399
  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) });
5400
+ tools.push({ name, description, input: z3.object(input).strict(), run: (args) => typeof args.path === "string" ? withRigQueue(args.path, () => handler(args)) : handler(args) });
4372
5401
  }
4373
5402
  async function callTool(name, args) {
4374
5403
  const t = tools.find((t2) => t2.name === name);
@@ -4384,24 +5413,24 @@ async function callTool(name, args) {
4384
5413
  var closeTools = () => closePreview();
4385
5414
  var text = (s, doc) => ({ text: s, doc });
4386
5415
  var fail = (e) => ({ text: `ERROR: ${e instanceof Error ? e.message : String(e)}`, isError: true });
4387
- var Path = z2.string().describe("path to rig.json; assets are resolved relative to its directory");
4388
- var Library = z2.string().optional().describe("template library JSON; default presets.json next to rig.json");
5416
+ var Path = z3.string().describe("path to rig.json; assets are resolved relative to its directory");
5417
+ var Library = z3.string().optional().describe("template library JSON; default presets.json next to rig.json");
4389
5418
  async function load(path) {
4390
- return RigDocument.parse(JSON.parse(await readFile8(path, "utf8")));
5419
+ return RigDocument.parse(JSON.parse(await readFile9(path, "utf8")));
4391
5420
  }
4392
5421
  var save = (path, doc) => saveJson(path, doc);
4393
5422
  var exists2 = (p) => stat2(p).then(() => true, () => false);
4394
- var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join7(dirname6(rigPath), p);
4395
- var srcFor = (rigPath, file) => relative2(dirname6(rigPath), file).split("\\").join("/");
4396
- var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join7(dirname6(rigPath), "presets.json");
5423
+ var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join9(dirname8(rigPath), p);
5424
+ var srcFor = (rigPath, file) => relative2(dirname8(rigPath), file).split("\\").join("/");
5425
+ var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join9(dirname8(rigPath), "presets.json");
4397
5426
  async function pngInfo(rigPath, file) {
4398
5427
  const abs = resolveFrom(rigPath, file);
4399
- const [width, height] = pngSize(await readFile8(abs));
5428
+ const [width, height] = pngSize(await readFile9(abs));
4400
5429
  return { abs, width, height, src: srcFor(rigPath, abs) };
4401
5430
  }
4402
5431
  async function mutate(path, op, summary = false) {
4403
5432
  try {
4404
- const raw = await readFile8(path, "utf8");
5433
+ const raw = await readFile9(path, "utf8");
4405
5434
  const prev = RigDocument.parse(JSON.parse(raw));
4406
5435
  const next = parse(await op(prev));
4407
5436
  await pushHistory(path, raw);
@@ -4415,7 +5444,7 @@ changed:${changes.length ? "\n " + changes.join("\n ") : " nothing"}`, next);
4415
5444
  }
4416
5445
  }
4417
5446
  async function create(path, doc) {
4418
- if (await exists2(path)) await pushHistory(path, await readFile8(path, "utf8"));
5447
+ if (await exists2(path)) await pushHistory(path, await readFile9(path, "utf8"));
4419
5448
  const saved = parse(doc);
4420
5449
  await save(path, saved);
4421
5450
  return text(describe(saved), saved);
@@ -4427,18 +5456,91 @@ function previewCheck(doc, animation) {
4427
5456
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
4428
5457
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
4429
5458
  }
4430
- var TrackRef = { target: z2.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z2.string(), prop: z2.string() };
4431
- var KeyIn = z2.object({
4432
- t: z2.number().describe("seconds"),
4433
- v: z2.union([z2.number(), z2.string(), z2.null(), z2.array(z2.number()), z2.array(z2.string())]),
4434
- 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")
5459
+ 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", "control"]), id: z3.string(), prop: z3.string() };
5460
+ tool(
5461
+ "rig_set_control",
5462
+ "Create or replace a semantic control with a finite min/max/default range. Animate target=control, prop=value. Changing a range requires matching binding grids.",
5463
+ { path: Path, control: RigControl },
5464
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5465
+ );
5466
+ tool(
5467
+ "rig_set_control_binding",
5468
+ "Create or replace a 1D/2D grid of absolute keyforms. First axis varies fastest. Channels support bone transforms, slot alpha and mesh/path vertex offsets. Each channel has one owner; animate controls rather than owned channels.",
5469
+ { path: Path, binding: ControlBinding },
5470
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5471
+ );
5472
+ tool(
5473
+ "rig_remove_control_binding",
5474
+ "Remove a control binding, restoring its channels to setup/animation values.",
5475
+ { path: Path, id: z3.string() },
5476
+ async ({ path, id }) => mutate(path, (d) => {
5477
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5478
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5479
+ })
5480
+ );
5481
+ tool(
5482
+ "rig_remove_control",
5483
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5484
+ { path: Path, id: z3.string() },
5485
+ async ({ path, id }) => mutate(path, (d) => {
5486
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5487
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5488
+ return { ...d, controls: d.controls.filter((c) => c.id !== id), animations: d.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => t.target !== "control" || t.id !== id) })) };
5489
+ })
5490
+ );
5491
+ tool(
5492
+ "rig_sweep_controls",
5493
+ "Render an explicit control grid (max 256 poses) through PixiJS, with PNGs, contact sheet and report tying cases to source/texture/renderer hashes. Geometry checks are not visual acceptance. Use a new output directory outside the packed rig.",
5494
+ {
5495
+ path: Path,
5496
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5497
+ output: z3.string(),
5498
+ scale: z3.number().positive().max(2).default(0.5),
5499
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5500
+ },
5501
+ async ({ path, probes, output, scale, region }) => {
5502
+ try {
5503
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5504
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5505
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5506
+ const qa = checkControlCases(d, sweep.cases);
5507
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5508
+ await mkdir6(out, { recursive: true });
5509
+ const frames = [];
5510
+ for (let i = 0; i < r.frames.length; i++) {
5511
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5512
+ await writeFile7(join9(out, file), r.frames[i]);
5513
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5514
+ }
5515
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5516
+ const report = {
5517
+ version: 1,
5518
+ sourceRig: sha256(raw),
5519
+ identity: r.identity,
5520
+ scale,
5521
+ region: region ?? null,
5522
+ ...qa,
5523
+ frames,
5524
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5525
+ };
5526
+ await saveJson(join9(out, "report.json"), report);
5527
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5528
+ } catch (e) {
5529
+ return fail(e);
5530
+ }
5531
+ }
5532
+ );
5533
+ var KeyIn = z3.object({
5534
+ t: z3.number().describe("seconds"),
5535
+ v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5536
+ 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")
4435
5537
  });
4436
5538
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
4437
5539
  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].";
4438
5540
  tool(
4439
5541
  "rig_create",
4440
5542
  "Create a new empty rig document with a root bone at bottom-center.",
4441
- { 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") },
5543
+ { 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") },
4442
5544
  async ({ path, name, width, height, notes }) => {
4443
5545
  try {
4444
5546
  return await create(path, createDoc(name, width, height, notes));
@@ -4450,7 +5552,7 @@ tool(
4450
5552
  tool(
4451
5553
  "rig_create_symbol",
4452
5554
  "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.",
4453
- { 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() },
5555
+ { 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() },
4454
5556
  async ({ path, image, name, margin, notes }) => {
4455
5557
  try {
4456
5558
  const { width, height, src } = await pngInfo(path, image);
@@ -4460,22 +5562,22 @@ tool(
4460
5562
  }
4461
5563
  }
4462
5564
  );
4463
- var ReferenceIn = z2.object({
4464
- src: z2.string().describe("PNG next to rig.json (relative path)"),
4465
- opacity: z2.number().min(0).max(1).optional(),
4466
- x: z2.number().optional(),
4467
- y: z2.number().optional(),
4468
- scale: z2.number().positive().optional()
5565
+ var ReferenceIn = z3.object({
5566
+ src: z3.string().describe("PNG next to rig.json (relative path)"),
5567
+ opacity: z3.number().min(0).max(1).optional(),
5568
+ x: z3.number().optional(),
5569
+ y: z3.number().optional(),
5570
+ scale: z3.number().positive().optional()
4469
5571
  }).strict();
4470
5572
  tool(
4471
5573
  "rig_set_meta",
4472
5574
  "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.",
4473
5575
  {
4474
5576
  path: Path,
4475
- name: z2.string().optional(),
4476
- width: z2.number().int().positive().optional(),
4477
- height: z2.number().int().positive().optional(),
4478
- notes: z2.string().optional(),
5577
+ name: z3.string().optional(),
5578
+ width: z3.number().int().positive().optional(),
5579
+ height: z3.number().int().positive().optional(),
5580
+ notes: z3.string().optional(),
4479
5581
  reference: ReferenceIn.nullable().optional()
4480
5582
  },
4481
5583
  async ({ path, ...patch }) => mutate(path, (d) => setMeta(d, patch))
@@ -4483,7 +5585,7 @@ tool(
4483
5585
  tool(
4484
5586
  "rig_get",
4485
5587
  "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.",
4486
- { path: Path, verbose: z2.boolean().default(false), full: z2.boolean().default(false) },
5588
+ { path: Path, verbose: z3.boolean().default(false), full: z3.boolean().default(false) },
4487
5589
  async ({ path, verbose, full }) => {
4488
5590
  try {
4489
5591
  const d = await load(path);
@@ -4496,7 +5598,7 @@ tool(
4496
5598
  tool(
4497
5599
  "rig_get_attachment",
4498
5600
  "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.",
4499
- { path: Path, id: z2.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
5601
+ { path: Path, id: z3.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
4500
5602
  async ({ path, id }) => {
4501
5603
  try {
4502
5604
  const d = await load(path);
@@ -4514,10 +5616,10 @@ tool(
4514
5616
  "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.",
4515
5617
  {
4516
5618
  path: Path,
4517
- animation: z2.string().optional(),
4518
- t: z2.number().min(0).optional(),
4519
- bones: z2.array(z2.string()).optional(),
4520
- slots: z2.array(z2.string()).optional()
5619
+ animation: z3.string().optional(),
5620
+ t: z3.number().min(0).optional(),
5621
+ bones: z3.array(z3.string()).optional(),
5622
+ slots: z3.array(z3.string()).optional()
4521
5623
  },
4522
5624
  async ({ path, ...opts }) => {
4523
5625
  try {
@@ -4546,11 +5648,11 @@ tool(
4546
5648
  );
4547
5649
  tool(
4548
5650
  "rig_check",
4549
- "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.",
4550
- { path: Path, animation: z2.string().optional().describe("limit to one animation (rest pose is always checked)") },
4551
- async ({ path, animation }) => {
5651
+ "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.",
5652
+ { 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() },
5653
+ async ({ path, ...opts }) => {
4552
5654
  try {
4553
- return text(summarize(checkRig(await load(path), { animation }), 100));
5655
+ return text(summarize(checkRig(await load(path), opts), 100));
4554
5656
  } catch (e) {
4555
5657
  return fail(e);
4556
5658
  }
@@ -4561,12 +5663,12 @@ tool(
4561
5663
  "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.",
4562
5664
  {
4563
5665
  path: Path,
4564
- json: z2.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
4565
- atlas: z2.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
4566
- image: z2.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
4567
- name: z2.string().optional().describe("default: the json file name"),
4568
- margin: z2.number().optional().describe("canvas margin around the skeleton bounds, px"),
4569
- notes: z2.string().optional()
5666
+ json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5667
+ atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5668
+ image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5669
+ name: z3.string().optional().describe("default: the json file name"),
5670
+ margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5671
+ notes: z3.string().optional()
4570
5672
  },
4571
5673
  async ({ path, json, atlas, image, name, margin, notes }) => {
4572
5674
  try {
@@ -4584,13 +5686,13 @@ warnings:
4584
5686
  tool(
4585
5687
  "rig_import_layers",
4586
5688
  "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.",
4587
- { 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") },
5689
+ { 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") },
4588
5690
  async ({ path, layersDir, relPrefix }) => mutate(path, (d) => importLayers(d, resolveFrom(path, layersDir), relPrefix), true)
4589
5691
  );
4590
5692
  tool(
4591
5693
  "rig_replace_asset",
4592
5694
  "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.",
4593
- { 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") },
5695
+ { 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") },
4594
5696
  async ({ path, id, file, prompt }) => mutate(path, async (d) => {
4595
5697
  const { width, height, src } = await pngInfo(path, file);
4596
5698
  return replaceAsset(d, id, { src, width, height, prompt, inpainted: true });
@@ -4599,27 +5701,27 @@ tool(
4599
5701
  tool(
4600
5702
  "rig_layer_prompt",
4601
5703
  "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.",
4602
- { path: Path, id: z2.string() },
4603
- async ({ path, id }) => {
5704
+ { path: Path, method: z3.enum(["part", "repair"]).default("part"), removed: z3.array(z3.string()).optional(), id: z3.string() },
5705
+ async ({ path, id, method, removed }) => {
4604
5706
  try {
4605
- return text(layerPrompt(await load(path), id));
5707
+ return text(layerPrompt(await load(path), id, { method, removed }));
4606
5708
  } catch (e) {
4607
5709
  return fail(e);
4608
5710
  }
4609
5711
  }
4610
5712
  );
4611
- 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");
4612
- var OriginIn = z2.object({ sourceImage: z2.string().optional(), bbox: Frame.optional(), inpainted: z2.boolean().default(false), prompt: z2.string().optional() }).strict();
5713
+ 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");
5714
+ 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();
4613
5715
  tool(
4614
5716
  "rig_set_asset",
4615
5717
  "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.",
4616
5718
  {
4617
5719
  path: Path,
4618
- id: z2.string(),
4619
- src: z2.string().optional(),
5720
+ id: z3.string(),
5721
+ src: z3.string().optional(),
4620
5722
  frame: Frame.nullable().optional().describe("null = the whole image again"),
4621
- width: z2.number().int().positive().optional(),
4622
- height: z2.number().int().positive().optional(),
5723
+ width: z3.number().int().positive().optional(),
5724
+ height: z3.number().int().positive().optional(),
4623
5725
  origin: OriginIn.optional()
4624
5726
  },
4625
5727
  async ({ path, id, src, ...patch }) => mutateWith(path, async (d, say) => {
@@ -4637,19 +5739,19 @@ tool(
4637
5739
  tool(
4638
5740
  "rig_remove_asset",
4639
5741
  "Remove an asset nothing shows any more (refused, with the list, while an attachment still uses it).",
4640
- { path: Path, id: z2.string() },
5742
+ { path: Path, id: z3.string() },
4641
5743
  async ({ path, id }) => mutate(path, (d) => removeAsset(d, id))
4642
5744
  );
4643
5745
  tool(
4644
5746
  "rig_place_asset",
4645
5747
  "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.",
4646
- { path: Path, asset: z2.string(), bone: z2.string(), x: z2.number(), y: z2.number(), slot: z2.string().optional(), z: z2.number().int().optional() },
5748
+ { path: Path, asset: z3.string(), bone: z3.string(), x: z3.number(), y: z3.number(), slot: z3.string().optional(), z: z3.number().int().optional() },
4647
5749
  async ({ path, ...p }) => mutate(path, (d) => placeAsset(d, p), true)
4648
5750
  );
4649
5751
  tool(
4650
5752
  "rig_undo",
4651
5753
  "Restore the rig as it was `steps` mutations ago (every mutation snapshots the previous file into .rig-history/ next to rig.json).",
4652
- { path: Path, steps: z2.number().int().min(1).default(1) },
5754
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4653
5755
  async ({ path, steps }) => {
4654
5756
  try {
4655
5757
  const doc = parse(JSON.parse(await undo(path, steps)));
@@ -4678,13 +5780,13 @@ tool(
4678
5780
  tool(
4679
5781
  "rig_diff",
4680
5782
  "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.",
4681
- { path: Path, steps: z2.number().int().min(1).default(1) },
5783
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4682
5784
  async ({ path, steps }) => {
4683
5785
  try {
4684
5786
  const history = await listHistory(path);
4685
5787
  if (!history.length) return text("no history for this rig");
4686
5788
  if (steps > history.length) return text(`only ${history.length} snapshot${history.length === 1 ? "" : "s"} available`);
4687
- const old = RigDocument.parse(JSON.parse(await readFile8(history[history.length - steps], "utf8")));
5789
+ const old = RigDocument.parse(JSON.parse(await readFile9(history[history.length - steps], "utf8")));
4688
5790
  const lines = diffDocs(old, await load(path));
4689
5791
  return text(lines.length ? lines.join("\n") : "no differences");
4690
5792
  } catch (e) {
@@ -4692,19 +5794,19 @@ tool(
4692
5794
  }
4693
5795
  }
4694
5796
  );
4695
- 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();
5797
+ 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();
4696
5798
  var BoneFields = {
4697
- parent: z2.string().optional(),
4698
- role: z2.string().optional(),
4699
- length: z2.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
4700
- inherit: z2.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
4701
- color: z2.string().optional().describe("editor colour"),
5799
+ parent: z3.string().optional(),
5800
+ role: z3.string().optional(),
5801
+ length: z3.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
5802
+ inherit: z3.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
5803
+ color: z3.string().optional().describe("editor colour"),
4702
5804
  transform: TransformPatch.optional()
4703
5805
  };
4704
5806
  tool(
4705
5807
  "rig_add_bone",
4706
5808
  "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).",
4707
- { 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") },
5809
+ { 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") },
4708
5810
  async ({ path, bind, ...bone }) => mutateWith(path, (d, say) => {
4709
5811
  let next = addBone(d, bone);
4710
5812
  for (const s of bind ?? []) {
@@ -4718,13 +5820,13 @@ tool(
4718
5820
  tool(
4719
5821
  "rig_update_bone",
4720
5822
  "Patch bone fields (parent, role, length, inherit, color, transform \u2014 partial, merged). parent=null detaches the bone into a root of its own.",
4721
- { 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)") },
5823
+ { 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)") },
4722
5824
  async ({ path, id, ...patch }) => mutate(path, (d) => updateBone(d, id, patch))
4723
5825
  );
4724
5826
  tool(
4725
5827
  "rig_remove_bone",
4726
5828
  "Remove a bone; children and slots re-parent to its parent.",
4727
- { path: Path, id: z2.string() },
5829
+ { path: Path, id: z3.string() },
4728
5830
  async ({ path, id }) => mutateWith(path, (d, say) => {
4729
5831
  const parent = d.bones.find((b) => b.id === id)?.parent;
4730
5832
  const slots = d.slots.filter((s) => s.bone === id).map((s) => s.id);
@@ -4735,18 +5837,19 @@ tool(
4735
5837
  );
4736
5838
  tool(
4737
5839
  "rig_set_slot",
4738
- "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).",
5840
+ "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).",
4739
5841
  {
4740
5842
  path: Path,
4741
- id: z2.string(),
4742
- bone: z2.string().optional(),
4743
- attachment: z2.string().nullable().optional(),
4744
- z: z2.number().int().optional(),
4745
- alpha: z2.number().optional(),
4746
- blend: z2.enum(["normal", "add", "multiply", "screen"]).optional(),
4747
- color: z2.string().optional()
5843
+ id: z3.string(),
5844
+ bone: z3.string().optional(),
5845
+ clip: z3.string().nullable().optional(),
5846
+ attachment: z3.string().nullable().optional(),
5847
+ z: z3.number().int().optional(),
5848
+ alpha: z3.number().optional(),
5849
+ blend: z3.enum(["normal", "add", "multiply", "screen"]).optional(),
5850
+ color: z3.string().optional()
4748
5851
  },
4749
- async ({ path, attachment, bone, ...slot }) => mutateWith(path, (d, say) => {
5852
+ async ({ path, attachment, bone, clip, ...slot }) => mutateWith(path, (d, say) => {
4750
5853
  let next = d;
4751
5854
  const cur = d.slots.find((s) => s.id === slot.id);
4752
5855
  if (cur && bone !== void 0 && bone !== cur.bone) {
@@ -4754,7 +5857,7 @@ tool(
4754
5857
  next = r.doc;
4755
5858
  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)`);
4756
5859
  }
4757
- return setSlot(next, { ...slot, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
5860
+ return setSlot(next, { ...slot, ...clip === void 0 ? {} : { clip: clip ?? void 0 }, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
4758
5861
  })
4759
5862
  );
4760
5863
  tool(
@@ -4762,21 +5865,21 @@ tool(
4762
5865
  "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.",
4763
5866
  {
4764
5867
  path: Path,
4765
- id: z2.string(),
4766
- slot: z2.string().optional(),
4767
- asset: z2.string().optional(),
4768
- x: z2.number().optional(),
4769
- y: z2.number().optional(),
4770
- rotation: z2.number().optional(),
4771
- scaleX: z2.number().optional(),
4772
- scaleY: z2.number().optional(),
4773
- width: z2.number().optional(),
4774
- height: z2.number().optional()
5868
+ id: z3.string(),
5869
+ slot: z3.string().optional(),
5870
+ asset: z3.string().optional(),
5871
+ x: z3.number().optional(),
5872
+ y: z3.number().optional(),
5873
+ rotation: z3.number().optional(),
5874
+ scaleX: z3.number().optional(),
5875
+ scaleY: z3.number().optional(),
5876
+ width: z3.number().optional(),
5877
+ height: z3.number().optional()
4775
5878
  },
4776
5879
  async ({ path, ...att }) => mutate(path, (d) => setAttachment(d, att))
4777
5880
  );
4778
- 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");
4779
- 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");
5881
+ 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");
5882
+ 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");
4780
5883
  async function mutateWith(path, op, summary = false) {
4781
5884
  const lines = [];
4782
5885
  const res = await mutate(path, (d) => op(d, (l) => lines.push(l)), summary);
@@ -4791,14 +5894,15 @@ var meshOf = (doc, id) => {
4791
5894
  };
4792
5895
  tool(
4793
5896
  "rig_mesh_from_image",
4794
- "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`.",
5897
+ "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`.",
4795
5898
  {
4796
5899
  path: Path,
4797
- id: z2.string().describe("attachment id (a region)"),
4798
- spacing: z2.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
4799
- tolerance: z2.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
4800
- margin: z2.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
4801
- threshold: z2.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
5900
+ id: z3.string().describe("attachment id (a region)"),
5901
+ spacing: z3.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
5902
+ tolerance: z3.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
5903
+ margin: z3.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
5904
+ minIslandRatio: z3.number().min(0).max(1).default(0.05).describe("minimum island area relative to largest; 0 preserves small intentional fragments"),
5905
+ threshold: z3.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
4802
5906
  },
4803
5907
  async ({ path, id, ...opts }) => mutateWith(path, async (d, say) => {
4804
5908
  const att = d.attachments.find((a) => a.id === id);
@@ -4807,15 +5911,15 @@ tool(
4807
5911
  const asset = d.assets.find((a) => a.id === att.asset);
4808
5912
  if (!asset) throw new Error(`unknown asset "${att.asset}"`);
4809
5913
  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`);
4810
- const png = await readFile8(resolveFrom(path, asset.src)).catch((e) => {
5914
+ const png = await readFile9(resolveFrom(path, asset.src)).catch((e) => {
4811
5915
  throw new Error(`asset "${asset.id}": cannot read ${asset.src} (${e.code ?? e.message})`);
4812
5916
  });
4813
5917
  const { spacing, ...trace } = opts;
4814
5918
  const mask = alphaMaskFromPng(png, asset.frame);
4815
5919
  const islands = traceHulls(mask, trace);
4816
- const mesh = meshFromIslands(islands, att, asset, mask, spacing);
4817
- const { doc, droppedTracks } = convertToMesh(d, mesh);
4818
- say(`${id}=mesh(${mesh.uvs.length / 2}v, ${mesh.triangles.length / 3}t, hull ${mesh.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
5920
+ const mesh2 = meshFromIslands(islands, att, asset, mask, spacing);
5921
+ const { doc, droppedTracks } = convertToMesh(d, mesh2);
5922
+ say(`${id}=mesh(${mesh2.uvs.length / 2}v, ${mesh2.triangles.length / 3}t, hull ${mesh2.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
4819
5923
  if (droppedTracks.length) say(`dropped lattice deform tracks: ${droppedTracks.join(", ")}`);
4820
5924
  return doc;
4821
5925
  })
@@ -4825,10 +5929,10 @@ tool(
4825
5929
  "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.",
4826
5930
  {
4827
5931
  path: Path,
4828
- id: z2.string().describe("mesh attachment id"),
4829
- bones: z2.array(z2.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
4830
- 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"),
4831
- maxInfluences: z2.number().int().min(1).default(2).describe("bones per vertex")
5932
+ id: z3.string().describe("mesh attachment id"),
5933
+ bones: z3.array(z3.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
5934
+ 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"),
5935
+ maxInfluences: z3.number().int().min(1).default(2).describe("bones per vertex")
4832
5936
  },
4833
5937
  async ({ path, id, bones, radius, maxInfluences }) => mutateWith(path, (d, say) => {
4834
5938
  const att = meshOf(d, id);
@@ -4850,12 +5954,12 @@ tool(
4850
5954
  "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.",
4851
5955
  {
4852
5956
  path: Path,
4853
- id: z2.string().describe("mesh attachment id"),
4854
- vertices: z2.array(z2.number()).optional(),
5957
+ id: z3.string().describe("mesh attachment id"),
5958
+ vertices: z3.array(z3.number()).optional(),
4855
5959
  weights: WeightsIn.optional(),
4856
- uvs: z2.array(z2.number()).optional(),
4857
- triangles: z2.array(z2.number().int().nonnegative()).optional(),
4858
- hull: z2.number().int().nonnegative().optional()
5960
+ uvs: z3.array(z3.number()).optional(),
5961
+ triangles: z3.array(z3.number().int().nonnegative()).optional(),
5962
+ hull: z3.number().int().nonnegative().optional()
4859
5963
  },
4860
5964
  async ({ path, id, ...patch }) => mutateWith(path, (d, say) => {
4861
5965
  const { doc, dropped } = setMesh(d, meshOf(d, id).id, patch);
@@ -4866,7 +5970,7 @@ tool(
4866
5970
  tool(
4867
5971
  "rig_set_weights",
4868
5972
  "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.",
4869
- { path: Path, id: z2.string().describe("mesh attachment id"), weights: WeightsIn },
5973
+ { path: Path, id: z3.string().describe("mesh attachment id"), weights: WeightsIn },
4870
5974
  async ({ path, id, weights }) => mutateWith(path, (d, say) => {
4871
5975
  const { doc, dropped } = setWeights(d, meshOf(d, id).id, weights);
4872
5976
  if (dropped.length) say(`dropped deform tracks: ${dropped.join(", ")}`);
@@ -4876,19 +5980,19 @@ tool(
4876
5980
  tool(
4877
5981
  "rig_remove_attachment",
4878
5982
  "Remove an attachment; slots showing it become hidden, keys naming it become null.",
4879
- { path: Path, id: z2.string() },
5983
+ { path: Path, id: z3.string() },
4880
5984
  async ({ path, id }) => mutate(path, (d) => removeAttachment(d, id))
4881
5985
  );
4882
5986
  tool(
4883
5987
  "rig_remove_slot",
4884
5988
  "Remove a slot and its tracks.",
4885
- { path: Path, id: z2.string() },
5989
+ { path: Path, id: z3.string() },
4886
5990
  async ({ path, id }) => mutate(path, (d) => removeSlot(d, id), true)
4887
5991
  );
4888
5992
  tool(
4889
5993
  "rig_set_draw_order",
4890
5994
  "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.",
4891
- { path: Path, order: z2.array(z2.string()).min(1) },
5995
+ { path: Path, order: z3.array(z3.string()).min(1) },
4892
5996
  async ({ path, order }) => mutate(path, (d) => setDrawOrder(d, order))
4893
5997
  );
4894
5998
  function namesId(v, id) {
@@ -4900,7 +6004,7 @@ function namesId(v, id) {
4900
6004
  tool(
4901
6005
  "rig_rename",
4902
6006
  "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.",
4903
- { path: Path, kind: z2.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z2.string(), to: z2.string() },
6007
+ { path: Path, kind: z3.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z3.string(), to: z3.string() },
4904
6008
  async ({ path, kind, id, to }) => mutateWith(path, async (d, say) => {
4905
6009
  if (await exists2(libraryFor(path))) say(`note: ${libraryFor(path)} is not rewritten \u2014 templates that name "${id}" by id still do`);
4906
6010
  const next = rename2(d, kind, id, to);
@@ -4910,25 +6014,27 @@ tool(
4910
6014
  return next;
4911
6015
  }, true)
4912
6016
  );
4913
- var ConstraintBones = z2.array(z2.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
4914
- var ConstraintOrder = z2.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
4915
- var PathMixes = { mixRotate: z2.number().optional(), mixX: z2.number().optional(), mixY: z2.number().optional() };
4916
- var Mixes = { ...PathMixes, mixScaleX: z2.number().optional(), mixScaleY: z2.number().optional(), mixShearY: z2.number().optional() };
6017
+ var ConstraintBones = z3.array(z3.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
6018
+ var ConstraintOrder = z3.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
6019
+ var PathMixes = { mixRotate: z3.number().optional(), mixX: z3.number().optional(), mixY: z3.number().optional() };
6020
+ var Mixes = { ...PathMixes, mixScaleX: z3.number().optional(), mixScaleY: z3.number().optional(), mixShearY: z3.number().optional() };
4917
6021
  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.";
4918
6022
  tool(
4919
6023
  "rig_set_ik",
4920
- "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,
6024
+ "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,
4921
6025
  {
4922
6026
  path: Path,
4923
- id: z2.string(),
6027
+ id: z3.string(),
4924
6028
  bones: ConstraintBones,
4925
- target: z2.string().optional().describe("BONE the chain reaches for"),
4926
- mix: z2.number().optional(),
4927
- softness: z2.number().optional(),
4928
- bendPositive: z2.boolean().optional(),
4929
- compress: z2.boolean().optional(),
4930
- stretch: z2.boolean().optional(),
4931
- uniform: z2.boolean().optional(),
6029
+ target: z3.string().optional().describe("BONE the chain reaches for"),
6030
+ mix: z3.number().optional(),
6031
+ softness: z3.number().optional(),
6032
+ bendPositive: z3.boolean().optional(),
6033
+ iterations: z3.number().int().min(1).max(1e3).optional(),
6034
+ tolerance: z3.number().positive().optional(),
6035
+ compress: z3.boolean().optional(),
6036
+ stretch: z3.boolean().optional(),
6037
+ uniform: z3.boolean().optional(),
4932
6038
  order: ConstraintOrder
4933
6039
  },
4934
6040
  async ({ path, id, ...patch }) => mutate(path, (d) => setConstraint(d, "ik", id, patch))
@@ -4938,17 +6044,17 @@ tool(
4938
6044
  "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,
4939
6045
  {
4940
6046
  path: Path,
4941
- id: z2.string(),
6047
+ id: z3.string(),
4942
6048
  bones: ConstraintBones,
4943
- target: z2.string().optional().describe("BONE whose transform is copied"),
4944
- local: z2.boolean().optional(),
4945
- relative: z2.boolean().optional(),
4946
- rotation: z2.number().optional(),
4947
- x: z2.number().optional(),
4948
- y: z2.number().optional(),
4949
- scaleX: z2.number().optional(),
4950
- scaleY: z2.number().optional(),
4951
- shearY: z2.number().optional(),
6049
+ target: z3.string().optional().describe("BONE whose transform is copied"),
6050
+ local: z3.boolean().optional(),
6051
+ relative: z3.boolean().optional(),
6052
+ rotation: z3.number().optional(),
6053
+ x: z3.number().optional(),
6054
+ y: z3.number().optional(),
6055
+ scaleX: z3.number().optional(),
6056
+ scaleY: z3.number().optional(),
6057
+ shearY: z3.number().optional(),
4952
6058
  ...Mixes,
4953
6059
  order: ConstraintOrder
4954
6060
  },
@@ -4959,15 +6065,15 @@ tool(
4959
6065
  "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,
4960
6066
  {
4961
6067
  path: Path,
4962
- id: z2.string(),
6068
+ id: z3.string(),
4963
6069
  bones: ConstraintBones,
4964
- target: z2.string().optional().describe("SLOT showing a path attachment"),
4965
- positionMode: z2.enum(["fixed", "percent"]).optional(),
4966
- spacingMode: z2.enum(["length", "fixed", "percent", "proportional"]).optional(),
4967
- rotateMode: z2.enum(["tangent", "chain", "chainScale"]).optional(),
4968
- rotation: z2.number().optional(),
4969
- position: z2.number().optional(),
4970
- spacing: z2.number().optional(),
6070
+ target: z3.string().optional().describe("SLOT showing a path attachment"),
6071
+ positionMode: z3.enum(["fixed", "percent"]).optional(),
6072
+ spacingMode: z3.enum(["length", "fixed", "percent", "proportional"]).optional(),
6073
+ rotateMode: z3.enum(["tangent", "chain", "chainScale"]).optional(),
6074
+ rotation: z3.number().optional(),
6075
+ position: z3.number().optional(),
6076
+ spacing: z3.number().optional(),
4971
6077
  ...PathMixes,
4972
6078
  order: ConstraintOrder
4973
6079
  },
@@ -4976,7 +6082,7 @@ tool(
4976
6082
  tool(
4977
6083
  "rig_remove_constraint",
4978
6084
  "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.",
4979
- { path: Path, id: z2.string() },
6085
+ { path: Path, id: z3.string() },
4980
6086
  async ({ path, id }) => mutate(path, (d) => removeConstraint(d, id))
4981
6087
  );
4982
6088
  tool(
@@ -4984,13 +6090,14 @@ tool(
4984
6090
  "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.",
4985
6091
  {
4986
6092
  path: Path,
4987
- bone: z2.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
4988
- target: z2.string().optional().describe("id for the new target bone; default <bone>_ik"),
4989
- mix: z2.number().default(1),
4990
- softness: z2.number().default(0)
6093
+ bone: z3.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
6094
+ bones: z3.array(z3.string()).min(1).optional().describe("explicit parent-to-tip chain for long IK"),
6095
+ target: z3.string().optional().describe("id for the new target bone; default <bone>_ik"),
6096
+ mix: z3.number().default(1),
6097
+ softness: z3.number().default(0)
4991
6098
  },
4992
- async ({ path, bone, target, mix, softness }) => mutateWith(path, (d, say) => {
4993
- const r = addIkChain(d, bone, { target, mix, softness });
6099
+ async ({ path, bone, target, mix, softness, bones }) => mutateWith(path, (d, say) => {
6100
+ const r = addIkChain(d, bone, { target, mix, softness, bones });
4994
6101
  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`);
4995
6102
  return r.doc;
4996
6103
  }, true)
@@ -5000,12 +6107,12 @@ tool(
5000
6107
  "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.",
5001
6108
  {
5002
6109
  path: Path,
5003
- id: z2.string().describe("path attachment id"),
5004
- slot: z2.string(),
5005
- points: z2.array(z2.tuple([z2.number(), z2.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
5006
- vertices: z2.array(z2.number()).optional(),
6110
+ id: z3.string().describe("path attachment id"),
6111
+ slot: z3.string(),
6112
+ points: z3.array(z3.tuple([z3.number(), z3.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
6113
+ vertices: z3.array(z3.number()).optional(),
5007
6114
  weights: WeightsIn.optional(),
5008
- closed: z2.boolean().optional()
6115
+ closed: z3.boolean().optional()
5009
6116
  },
5010
6117
  async ({ path, id, slot, points, vertices, weights, closed }) => mutateWith(path, (d, say) => {
5011
6118
  const { doc: next, dropped } = setPathAttachment(d, id, slot, { points, vertices, weights, closed });
@@ -5020,10 +6127,10 @@ tool(
5020
6127
  "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).",
5021
6128
  {
5022
6129
  path: Path,
5023
- id: z2.string().describe("path attachment id"),
5024
- op: z2.enum(["insert", "remove"]),
5025
- index: z2.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
5026
- 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")
6130
+ id: z3.string().describe("path attachment id"),
6131
+ op: z3.enum(["insert", "remove"]),
6132
+ index: z3.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
6133
+ 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")
5027
6134
  },
5028
6135
  async ({ path, id, op, index, t }) => mutateWith(path, (d, say) => {
5029
6136
  const { doc, dropped } = pathAnchorOp(d, id, op, index, t);
@@ -5037,9 +6144,9 @@ tool(
5037
6144
  "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,
5038
6145
  {
5039
6146
  path: Path,
5040
- preset: z2.string().describe("built-in name or a template name from the library"),
5041
- params: z2.record(z2.unknown()).default({}).describe("partial override of defaults"),
5042
- id: z2.string().optional().describe("animation id for templates (default: template name)"),
6147
+ preset: z3.string().describe("built-in name or a template name from the library"),
6148
+ params: z3.record(z3.unknown()).default({}).describe("partial override of defaults"),
6149
+ id: z3.string().optional().describe("animation id for templates (default: template name)"),
5043
6150
  library: Library
5044
6151
  },
5045
6152
  async ({ path, preset, params, id, library }) => mutate(path, async (d) => {
@@ -5052,7 +6159,7 @@ tool(
5052
6159
  tool(
5053
6160
  "rig_save_as_preset",
5054
6161
  '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.',
5055
- { path: Path, animation: z2.string(), name: z2.string().describe("snake_case template name"), groups: z2.record(z2.array(z2.string())).optional(), library: Library },
6162
+ { path: Path, animation: z3.string(), name: z3.string().describe("snake_case template name"), groups: z3.record(z3.array(z3.string())).optional(), library: Library },
5056
6163
  async ({ path, animation, name, groups, library }) => {
5057
6164
  try {
5058
6165
  const tpl = extractTemplate(await load(path), animation, name, groups);
@@ -5084,7 +6191,7 @@ ${tpls.length ? tpls.map(describeTemplate).join("\n") : " none"}`);
5084
6191
  tool(
5085
6192
  "rig_get_preset",
5086
6193
  "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.",
5087
- { path: Path, preset: z2.string(), library: Library },
6194
+ { path: Path, preset: z3.string(), library: Library },
5088
6195
  async ({ path, preset, library }) => {
5089
6196
  try {
5090
6197
  if (preset in presets) {
@@ -5102,9 +6209,9 @@ tool(
5102
6209
  tool(
5103
6210
  "rig_set_keys",
5104
6211
  "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,
5105
- { path: Path, animation: z2.string(), ...TrackRef, keys: z2.array(KeyIn), merge: z2.boolean().default(false), duration: z2.number().optional(), loop: z2.boolean().optional() },
5106
- async ({ path, animation, target, id, prop, keys, merge, duration, loop }) => mutate(path, (d) => {
5107
- let n = setKeys(d, animation, { target, id, prop }, keys.map(roundKey), duration, { merge });
6212
+ { path: Path, animation: z3.string(), ...TrackRef, keys: z3.array(KeyIn), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
6213
+ async ({ path, animation, target, id, prop, interpolation, keys, merge, duration, loop }) => mutate(path, (d) => {
6214
+ let n = setKeys(d, animation, { target, id, prop, interpolation }, keys.map(roundKey), duration, { merge });
5108
6215
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5109
6216
  return n;
5110
6217
  })
@@ -5112,10 +6219,10 @@ tool(
5112
6219
  tool(
5113
6220
  "rig_set_tracks",
5114
6221
  "Set several tracks of one animation in a single call (same semantics as rig_set_keys for each). " + KEY_DOC,
5115
- { 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() },
6222
+ { 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() },
5116
6223
  async ({ path, animation, tracks, merge, duration, loop }) => mutate(path, (d) => {
5117
6224
  let n = d;
5118
- for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop }, t.keys.map(roundKey), duration, { merge });
6225
+ 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 });
5119
6226
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5120
6227
  return n;
5121
6228
  })
@@ -5125,11 +6232,11 @@ tool(
5125
6232
  "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.",
5126
6233
  {
5127
6234
  path: Path,
5128
- bone: z2.string(),
5129
- x: z2.number(),
5130
- y: z2.number(),
5131
- animation: z2.string().optional(),
5132
- t: z2.number().min(0).optional(),
6235
+ bone: z3.string(),
6236
+ x: z3.number(),
6237
+ y: z3.number(),
6238
+ animation: z3.string().optional(),
6239
+ t: z3.number().min(0).optional(),
5133
6240
  ease: Ease.optional()
5134
6241
  },
5135
6242
  async ({ path, bone, x, y, ...opts }) => mutateWith(path, (d, say) => {
@@ -5144,11 +6251,11 @@ tool(
5144
6251
  "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.",
5145
6252
  {
5146
6253
  path: Path,
5147
- animation: z2.string(),
5148
- from: z2.array(z2.string()).min(1),
5149
- to: z2.array(z2.string()).min(1),
5150
- phase: z2.number().min(0).max(1).default(0),
5151
- mirrorX: z2.boolean().default(false)
6254
+ animation: z3.string(),
6255
+ from: z3.array(z3.string()).min(1),
6256
+ to: z3.array(z3.string()).min(1),
6257
+ phase: z3.number().min(0).max(1).default(0),
6258
+ mirrorX: z3.boolean().default(false)
5152
6259
  },
5153
6260
  async ({ path, animation, from, to, phase, mirrorX }) => mutateWith(path, (d, say) => {
5154
6261
  const { doc, copied, empty, lossy } = copyTracks(d, animation, from, to, { phase, mirrorX });
@@ -5162,7 +6269,7 @@ tool(
5162
6269
  tool(
5163
6270
  "rig_adjust_track",
5164
6271
  "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.",
5165
- { path: Path, animation: z2.string(), ...TrackRef, amplitude: z2.number().optional(), offset: z2.number().optional(), timeScale: z2.number().positive().optional() },
6272
+ { path: Path, animation: z3.string(), ...TrackRef, amplitude: z3.number().optional(), offset: z3.number().optional(), timeScale: z3.number().positive().optional() },
5166
6273
  async ({ path, animation, target, id, prop, ...adj }) => mutate(path, (d) => adjustTrack(d, animation, { target, id, prop }, adj))
5167
6274
  );
5168
6275
  tool(
@@ -5170,47 +6277,108 @@ tool(
5170
6277
  "Animation properties without touching keys: duration (must cover the last key/event), loop, events (full list), rename. Creates the animation if missing.",
5171
6278
  {
5172
6279
  path: Path,
5173
- id: z2.string(),
5174
- duration: z2.number().positive().optional(),
5175
- loop: z2.boolean().optional(),
5176
- events: z2.array(z2.object({ t: z2.number().nonnegative(), name: z2.string(), payload: z2.record(z2.unknown()).optional() })).optional(),
5177
- rename: z2.string().optional()
6280
+ id: z3.string(),
6281
+ duration: z3.number().positive().optional(),
6282
+ loop: z3.boolean().optional(),
6283
+ events: z3.array(z3.object({ t: z3.number().nonnegative(), name: z3.string(), payload: z3.record(z3.unknown()).optional() })).optional(),
6284
+ rename: z3.string().optional()
5178
6285
  },
5179
6286
  async ({ path, id, ...patch }) => mutate(path, (d) => setAnimation(d, id, patch))
5180
6287
  );
5181
6288
  tool(
5182
6289
  "rig_remove_keys",
5183
6290
  "Remove the keys of one track at the given times; an emptied track is dropped. Drops the preset link.",
5184
- { path: Path, animation: z2.string(), ...TrackRef, times: z2.array(z2.number()).min(1) },
6291
+ { path: Path, animation: z3.string(), ...TrackRef, times: z3.array(z3.number()).min(1) },
5185
6292
  async ({ path, animation, target, id, prop, times: times2 }) => mutate(path, (d) => removeKeys(d, animation, { target, id, prop }, times2))
5186
6293
  );
5187
6294
  tool(
5188
6295
  "rig_remove_animation",
5189
6296
  "Delete an animation.",
5190
- { path: Path, id: z2.string() },
6297
+ { path: Path, id: z3.string() },
5191
6298
  async ({ path, id }) => mutate(path, (d) => removeAnimation(d, id), true)
5192
6299
  );
6300
+ tool(
6301
+ "rig_order_constraints",
6302
+ "Sort constraints by what they read and write, including target ancestors and skinned paths. Cycles are refused.",
6303
+ { path: Path },
6304
+ async ({ path }) => mutate(path, orderConstraints)
6305
+ );
6306
+ tool(
6307
+ "rig_gradient_weights",
6308
+ "Skin a mesh with a gradient along ordered chain joints, preserving its setup geometry.",
6309
+ { path: Path, id: z3.string(), bones: z3.array(z3.string()).min(2) },
6310
+ async ({ path, id, bones }) => mutate(path, (d) => gradientWeights(d, id, bones))
6311
+ );
6312
+ tool(
6313
+ "rig_smooth_weights",
6314
+ "Smooth weights over triangle neighbours. Locked vertex indices keep their weights. Deform tracks are dropped if the influence layout changes.",
6315
+ { 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([]) },
6316
+ async ({ path, id, iterations, strength, locked }) => mutate(path, (d) => smoothWeights(d, id, iterations, strength, locked))
6317
+ );
6318
+ tool(
6319
+ "rig_pin_weights",
6320
+ "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.",
6321
+ { 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() },
6322
+ async ({ path, id, bone, vertices, region }) => mutate(path, (d) => pinWeights(d, id, bone, vertices, region))
6323
+ );
6324
+ tool(
6325
+ "rig_attach_to_bone",
6326
+ "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.",
6327
+ { path: Path, animation: z3.string(), bone: z3.string(), target: z3.string(), t: z3.number().nonnegative() },
6328
+ async ({ path, animation, bone, target, t }) => mutate(path, (d) => attachToBone(d, animation, bone, target, t))
6329
+ );
6330
+ tool(
6331
+ "rig_bake_secondary",
6332
+ "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.",
6333
+ { 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) },
6334
+ async ({ path, animation, ...opts }) => mutate(path, (d) => bakeSecondary(d, animation, opts))
6335
+ );
5193
6336
  tool(
5194
6337
  "rig_render_preview",
5195
6338
  "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.",
5196
6339
  {
5197
6340
  path: Path,
5198
- animation: z2.string().optional(),
5199
- fps: z2.number().default(8),
5200
- count: z2.number().int().optional(),
5201
- times: z2.array(z2.number()).optional(),
5202
- overlayBones: z2.boolean().default(false),
5203
- onionSkin: z2.boolean().default(false),
5204
- region: z2.tuple([z2.number(), z2.number(), z2.number(), z2.number()]).optional(),
5205
- scale: z2.number().default(0.5).describe("downscale to save tokens"),
5206
- sheetColumns: z2.number().int().default(4),
5207
- saveTo: z2.string().optional().describe("also write sheet PNG here")
6341
+ animation: z3.string().optional(),
6342
+ fps: z3.number().default(8),
6343
+ count: z3.number().int().optional(),
6344
+ times: z3.array(z3.number()).optional(),
6345
+ transparent: z3.boolean().default(false),
6346
+ background: z3.string().optional(),
6347
+ weightBone: z3.string().optional(),
6348
+ debugFillMasks: z3.record(z3.string()).optional(),
6349
+ framesDir: z3.string().optional(),
6350
+ overlayBones: z3.boolean().default(false),
6351
+ onionSkin: z3.boolean().default(false),
6352
+ region: z3.tuple([z3.number(), z3.number(), z3.number(), z3.number()]).optional(),
6353
+ scale: z3.number().default(0.5).describe("downscale to save tokens"),
6354
+ sheetColumns: z3.number().int().default(4),
6355
+ saveTo: z3.string().optional().describe("also write sheet PNG here")
5208
6356
  },
5209
- async ({ path, saveTo, ...opts }) => {
6357
+ async ({ path, saveTo, framesDir, ...opts }) => {
5210
6358
  try {
5211
- const doc = await load(path);
5212
- const r = await renderPreview(doc, { assetsDir: dirname6(path), ...opts });
5213
- if (saveTo) await writeFile6(saveTo, r.sheet);
6359
+ const raw = await readFile9(path, "utf8");
6360
+ const doc = parse(JSON.parse(raw));
6361
+ const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6362
+ if (saveTo) await writeFile7(saveTo, r.sheet);
6363
+ if (framesDir) {
6364
+ const { mkdir: mkdir7 } = await import("node:fs/promises");
6365
+ await mkdir7(framesDir, { recursive: true });
6366
+ const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6367
+ for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6368
+ const manifest = {
6369
+ version: 1,
6370
+ rigFile: sha256(raw),
6371
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
6372
+ identity: r.identity,
6373
+ animation: opts.animation ?? null,
6374
+ transparent: opts.transparent,
6375
+ scale: opts.scale,
6376
+ times: r.times,
6377
+ frames,
6378
+ render: { background: opts.background, overlayBones: opts.overlayBones, onionSkin: opts.onionSkin, weightBone: opts.weightBone, debugFillMasks: opts.debugFillMasks ?? {}, region: opts.region }
6379
+ };
6380
+ await writeFile7(join9(framesDir, "manifest.json"), JSON.stringify(manifest, null, 2));
6381
+ }
5214
6382
  return { text: `${r.times.length} frames @ ${r.width}x${r.height}, t=[${r.times.join(", ")}]
5215
6383
  ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5216
6384
  } catch (e) {
@@ -5221,14 +6389,55 @@ ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5221
6389
  tool(
5222
6390
  "rig_export",
5223
6391
  "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.",
5224
- { 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) },
6392
+ { 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) },
5225
6393
  async ({ path, outDir, padding, maxSize }) => {
5226
6394
  try {
5227
6395
  const out = resolveFrom(path, outDir);
5228
- const doc = await buildAtlas(await load(path), dirname6(path), out, { padding, maxSize });
5229
- const [w, h] = pngSize(await readFile8(join7(out, "atlas.png")));
5230
- return text(`\u2192 ${join7(out, "rig.json")}
5231
- \u2192 ${join7(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6396
+ const doc = await buildAtlas(await load(path), dirname8(path), out, { padding, maxSize });
6397
+ const [w, h] = pngSize(await readFile9(join9(out, "atlas.png")));
6398
+ return text(`\u2192 ${join9(out, "rig.json")}
6399
+ \u2192 ${join9(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6400
+ } catch (e) {
6401
+ return fail(e);
6402
+ }
6403
+ }
6404
+ );
6405
+ tool(
6406
+ "rig_export_video",
6407
+ "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.",
6408
+ { 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() },
6409
+ async ({ path, outFile, ...opts }) => {
6410
+ try {
6411
+ const result = await exportVideo(await load(path), { assetsDir: dirname8(path), outFile: resolveFrom(path, outFile), ...opts });
6412
+ return text(`${resolveFrom(path, outFile)}: ${result.frames} frames, ${result.fps} fps, ${result.duration}s`);
6413
+ } catch (e) {
6414
+ return fail(e);
6415
+ }
6416
+ }
6417
+ );
6418
+ tool(
6419
+ "rig_verify_render",
6420
+ "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.",
6421
+ { path: Path, framesDir: z3.string(), animation: z3.string().optional(), times: z3.array(z3.number().nonnegative()).min(1), transparent: z3.boolean().optional() },
6422
+ async ({ path, framesDir, ...expected }) => {
6423
+ try {
6424
+ const here = dirname8(fileURLToPath2(import.meta.url));
6425
+ const source = join9(here, "preview/harness.js"), bundled = join9(here, "harness.js");
6426
+ const manifest = await verifyRenderManifest(path, framesDir, { ...expected, renderer: await exists2(source) ? source : bundled });
6427
+ return text(`verified ${manifest.frames.length}/${expected.times.length} frames; inputs, renderer and frame hashes match`);
6428
+ } catch (e) {
6429
+ return fail(e);
6430
+ }
6431
+ }
6432
+ );
6433
+ tool(
6434
+ "rig_run_gates",
6435
+ "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.",
6436
+ { path: Path, config: GateConfig },
6437
+ async ({ path, config }) => {
6438
+ try {
6439
+ const report = runGates(await load(path), config);
6440
+ return { text: JSON.stringify(report, null, 2), isError: !report.pass };
5232
6441
  } catch (e) {
5233
6442
  return fail(e);
5234
6443
  }