@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/cli.js CHANGED
@@ -3,10 +3,10 @@ import { resolve as resolve3 } from "node:path";
3
3
 
4
4
  // src/editor/server.ts
5
5
  import { createServer } from "node:http";
6
- import { readFile as readFile9, stat as stat3, readdir as readdir3, mkdir as mkdir5, open } from "node:fs/promises";
6
+ import { readFile as readFile10, stat as stat3, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
7
7
  import { watch as fsWatch } from "node:fs";
8
- import { resolve as resolve2, dirname as dirname7, basename as basename5, extname as extname2, sep, join as join8, relative as relative3 } from "node:path";
9
- import { fileURLToPath as fileURLToPath2 } from "node:url";
8
+ import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
9
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
10
10
 
11
11
  // src/rig-format.ts
12
12
  import { z } from "zod";
@@ -65,7 +65,7 @@ function minJacobian(offsets, w, h, samples = 17) {
65
65
  var ID_RE = /^[a-z][a-z0-9_]*$/;
66
66
  var Id = z.string().regex(ID_RE, "snake_case id");
67
67
  var Ease = z.union([
68
- z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "back_out", "elastic_out"]),
68
+ z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "minimum_jerk", "back_out", "elastic_out"]),
69
69
  // cubic-bezier control points, same semantics as CSS
70
70
  z.object({ bezier: z.tuple([z.number(), z.number(), z.number(), z.number()]) })
71
71
  ]);
@@ -97,6 +97,7 @@ var Asset = z.object({
97
97
  /** where this layer came from — provenance for agents */
98
98
  origin: z.object({
99
99
  sourceImage: z.string().optional(),
100
+ bboxSpace: z.enum(["source", "document"]).optional(),
100
101
  /** bbox in source image: [x, y, w, h] */
101
102
  bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
102
103
  inpainted: z.boolean().default(false),
@@ -119,6 +120,8 @@ var Bone = z.object({
119
120
  var Slot = z.object({
120
121
  id: Id,
121
122
  bone: Id,
123
+ /** Closed path attachment used as a moving clipping mask. */
124
+ clip: Id.optional(),
122
125
  /** active attachment id; undefined = hidden */
123
126
  attachment: Id.optional(),
124
127
  alpha: z.number().min(0).max(1).default(1),
@@ -181,6 +184,8 @@ var IkConstraint = z.object({
181
184
  order: z.number().int().default(0),
182
185
  bones: z.array(Id).min(1),
183
186
  target: Id,
187
+ iterations: z.number().int().min(1).max(1e3).optional(),
188
+ tolerance: z.number().positive().optional(),
184
189
  mix: z.number().default(1),
185
190
  softness: z.number().default(0),
186
191
  bendPositive: z.boolean().default(true),
@@ -233,7 +238,7 @@ var BoneProp = z.enum(["x", "y", "rotation", "scaleX", "scaleY", "shearX", "shea
233
238
  var SlotProp = z.enum(["alpha", "attachment", "z", "color"]);
234
239
  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");
235
240
  var IkProp = z.enum(["mix", "softness", "bendPositive", "compress", "stretch"]);
236
- var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY"]);
241
+ var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY", "x", "y", "rotation", "scaleX", "scaleY", "shearY"]);
237
242
  var PathProp = z.enum(["position", "spacing", "mixRotate", "mixX", "mixY"]);
238
243
  var KeyValue = z.union([z.number(), z.string(), z.null(), z.array(z.number()), z.array(z.string())]);
239
244
  var Key = z.object({
@@ -242,14 +247,16 @@ var Key = z.object({
242
247
  /** easing INTO the next key */
243
248
  ease: Ease.default("ease_in_out")
244
249
  });
250
+ var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
245
251
  var Track = z.discriminatedUnion("target", [
246
- z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, keys: z.array(Key).min(1) }),
247
- z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, keys: z.array(Key).min(1) }),
248
- z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, keys: z.array(Key).min(1) }),
249
- z.object({ target: z.literal("ik"), id: Id, prop: IkProp, keys: z.array(Key).min(1) }),
250
- z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, keys: z.array(Key).min(1) }),
251
- z.object({ target: z.literal("path"), id: Id, prop: PathProp, keys: z.array(Key).min(1) }),
252
- z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), keys: z.array(Key).min(1) })
252
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
253
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
254
+ z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
255
+ z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
256
+ z.object({ target: z.literal("ik"), id: Id, prop: IkProp, ...Interpolation, keys: z.array(Key).min(1) }),
257
+ z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, ...Interpolation, keys: z.array(Key).min(1) }),
258
+ z.object({ target: z.literal("path"), id: Id, prop: PathProp, ...Interpolation, keys: z.array(Key).min(1) }),
259
+ z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), ...Interpolation, keys: z.array(Key).min(1) })
253
260
  ]);
254
261
  var Event = z.object({
255
262
  t: z.number().nonnegative(),
@@ -265,6 +272,25 @@ var Animation = z.object({
265
272
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
266
273
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
267
274
  });
275
+ var RigControl = z.object({
276
+ id: Id,
277
+ label: z.string().optional(),
278
+ min: z.number().finite(),
279
+ max: z.number().finite(),
280
+ default: z.number().finite()
281
+ }).strict();
282
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
283
+ var ControlChannel = z.discriminatedUnion("target", [
284
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
285
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
286
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
287
+ ]);
288
+ var ControlBinding = z.object({
289
+ id: Id,
290
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
291
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
292
+ channels: z.array(ControlChannel).min(1)
293
+ }).strict();
268
294
  var Reference = z.object({
269
295
  src: z.string(),
270
296
  opacity: z.number().min(0).max(1).default(0.5),
@@ -290,7 +316,9 @@ var RigSchema = z.object({
290
316
  ik: z.array(IkConstraint).default([]),
291
317
  transform: z.array(TransformConstraint).default([]),
292
318
  path: z.array(PathConstraint).default([]),
293
- animations: z.array(Animation)
319
+ animations: z.array(Animation),
320
+ controls: z.array(RigControl).optional(),
321
+ controlBindings: z.array(ControlBinding).optional()
294
322
  });
295
323
  var RigDocument = z.preprocess((input) => {
296
324
  const v = input?.version;
@@ -365,6 +393,8 @@ function validate(doc) {
365
393
  dup(doc.ik, "ik");
366
394
  dup(doc.transform, "transform");
367
395
  dup(doc.path, "path");
396
+ dup(doc.controls ?? [], "control");
397
+ dup(doc.controlBindings ?? [], "control binding");
368
398
  const bones = new Set(doc.bones.map((b) => b.id));
369
399
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
370
400
  const slots = new Set(doc.slots.map((s2) => s2.id));
@@ -391,6 +421,10 @@ function validate(doc) {
391
421
  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)`);
392
422
  }
393
423
  for (const s2 of doc.slots) {
424
+ if (s2.clip) {
425
+ const mask = attachments.get(s2.clip);
426
+ if (mask?.type !== "path" || !mask.closed) errs.push(`slot "${s2.id}": clip must name a closed path attachment`);
427
+ }
394
428
  if (!bones.has(s2.bone)) errs.push(`slot "${s2.id}": unknown bone "${s2.bone}"`);
395
429
  if (s2.attachment) {
396
430
  const a = attachments.get(s2.attachment);
@@ -422,7 +456,8 @@ function validate(doc) {
422
456
  }
423
457
  }
424
458
  for (const c of doc.ik) {
425
- if (c.bones.length > 2) errs.push(`ik "${c.id}": must have 1 or 2 bones`);
459
+ for (let i = 1; i < c.bones.length; i++) if (doc.bones.find((b) => b.id === c.bones[i])?.parent !== c.bones[i - 1])
460
+ errs.push(`ik "${c.id}": bones must form a contiguous parent-to-child chain`);
426
461
  for (const b of c.bones) if (!bones.has(b)) errs.push(`ik "${c.id}": unknown bone "${b}"`);
427
462
  if (!bones.has(c.target)) errs.push(`ik "${c.id}": unknown target bone "${c.target}"`);
428
463
  }
@@ -434,13 +469,47 @@ function validate(doc) {
434
469
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
435
470
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
436
471
  }
472
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
473
+ const driven = /* @__PURE__ */ new Set();
474
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
475
+ errs.push(`control "${c.id}": require min < max and default within range`);
476
+ for (const b of doc.controlBindings ?? []) {
477
+ const prefix = `control binding "${b.id}"`;
478
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
479
+ for (const axis of b.axes) {
480
+ const c = controls.get(axis.control);
481
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
482
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
483
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
484
+ }
485
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
486
+ for (const ch of b.channels) {
487
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
488
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
489
+ driven.add(key);
490
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
491
+ if (ch.target === "deform") {
492
+ const att = attachments.get(ch.id);
493
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
494
+ else {
495
+ const n = att.weights ? att.weights.reduce((s2, a) => s2 + a.length * 2, 0) : att.vertices.length;
496
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
497
+ }
498
+ } else {
499
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
500
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
501
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
502
+ }
503
+ }
504
+ }
437
505
  for (const a of doc.animations) {
438
506
  const tracks = /* @__PURE__ */ new Set();
439
507
  for (const t of a.tracks) {
440
508
  const tid = trackId(t);
441
509
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
442
510
  tracks.add(tid);
443
- 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;
511
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
512
+ 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;
444
513
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
445
514
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
446
515
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
@@ -448,6 +517,10 @@ function validate(doc) {
448
517
  for (const k of t.keys) {
449
518
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
450
519
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
520
+ if (t.target === "control") {
521
+ const c = controls.get(t.id);
522
+ 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`);
523
+ }
451
524
  if (t.target === "slot" && t.prop === "attachment") {
452
525
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
453
526
  else if (typeof k.v === "string") {
@@ -487,9 +560,9 @@ function parse(input) {
487
560
  }
488
561
 
489
562
  // src/rig-api.ts
490
- import { z as z2 } from "zod";
491
- import { readFile as readFile8, writeFile as writeFile6, stat as stat2 } from "node:fs/promises";
492
- import { dirname as dirname6, join as join7, relative as relative2, isAbsolute } from "node:path";
563
+ import { z as z3 } from "zod";
564
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
565
+ import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
493
566
 
494
567
  // src/rig-io.ts
495
568
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
@@ -536,6 +609,65 @@ async function withRigQueue(path, op) {
536
609
  }
537
610
  }
538
611
 
612
+ // src/rig-controls.ts
613
+ function controlPose(doc, values2) {
614
+ const out = /* @__PURE__ */ new Map();
615
+ for (const [id, value] of Object.entries(values2)) {
616
+ const c = doc.controls?.find((c2) => c2.id === id);
617
+ if (!c) throw new Error(`unknown control "${id}"`);
618
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
619
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
620
+ }
621
+ return out;
622
+ }
623
+ function bracket(keys, value) {
624
+ let i = 0;
625
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
626
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
627
+ }
628
+ function sampleBinding(binding, values2) {
629
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
630
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
631
+ const width = binding.axes[0].keys.length, a = y * width + x;
632
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
633
+ return binding.channels.map((ch) => {
634
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
635
+ const at = (j2) => {
636
+ const top = mix(read(a, j2), read(a + 1, j2), u);
637
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
638
+ };
639
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
640
+ });
641
+ }
642
+ function resolveControls(doc, input) {
643
+ if (!doc.controlBindings?.length) return input;
644
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
645
+ for (const b of doc.controlBindings) {
646
+ const values2 = b.axes.map((axis) => {
647
+ const c = controls.get(axis.control);
648
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
649
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
650
+ return Math.max(c.min, Math.min(c.max, value));
651
+ });
652
+ const sampled2 = sampleBinding(b, values2);
653
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
654
+ }
655
+ return out;
656
+ }
657
+ function controlCases(doc, probes) {
658
+ let cases = [{ label: "default", values: {} }];
659
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
660
+ for (const [id, values2] of Object.entries(probes)) {
661
+ const c = doc.controls?.find((c2) => c2.id === id);
662
+ if (!c) throw new Error(`unknown control "${id}"`);
663
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
664
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
665
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
666
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
667
+ }
668
+ return cases;
669
+ }
670
+
539
671
  // src/rig-anim.ts
540
672
  var DEG2 = Math.PI / 180;
541
673
  var named = {
@@ -544,6 +676,7 @@ var named = {
544
676
  ease_in: (t) => t * t,
545
677
  ease_out: (t) => 1 - (1 - t) * (1 - t),
546
678
  ease_in_out: (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
679
+ minimum_jerk: (t) => t * t * t * (10 + t * (-15 + 6 * t)),
547
680
  back_out: (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2,
548
681
  elastic_out: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1
549
682
  };
@@ -575,7 +708,25 @@ function ease(spec) {
575
708
  return f;
576
709
  }
577
710
  var pk = (target, id, prop) => `${target}:${id}:${prop}`;
578
- function sample(track, t) {
711
+ function smoothValue(track, i, t, component, loop) {
712
+ const k = track.keys, n = k.length;
713
+ const v = (j2) => component === void 0 ? k[j2].v : k[j2].v[component];
714
+ const h = (j2) => k[j2 + 1].t - k[j2].t;
715
+ const d = (j2) => (v(j2 + 1) - v(j2)) / h(j2);
716
+ const harmonic = (a, b, h0, h1) => a * b <= 0 ? 0 : 3 * (h0 + h1) / ((2 * h1 + h0) / a + (h1 + 2 * h0) / b);
717
+ const tangent = (j2) => {
718
+ if (j2 === 0 || j2 === n - 1) {
719
+ if (loop && n > 2 && v(0) === v(n - 1) && k[0].ease !== "step" && k[n - 2].ease !== "step")
720
+ return harmonic(d(n - 2), d(0), h(n - 2), h(0));
721
+ return 0;
722
+ }
723
+ if (k[j2 - 1].ease === "step" || k[j2].ease === "step") return 0;
724
+ return harmonic(d(j2 - 1), d(j2), h(j2 - 1), h(j2));
725
+ };
726
+ const u = (t - k[i].t) / h(i), u2 = u * u, u3 = u2 * u;
727
+ 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);
728
+ }
729
+ function sample(track, t, loop = false) {
579
730
  const keys = track.keys;
580
731
  if (t <= keys[0].t) return keys[0].v;
581
732
  const last = keys[keys.length - 1];
@@ -583,22 +734,44 @@ function sample(track, t) {
583
734
  let i = 0;
584
735
  while (keys[i + 1].t <= t) i++;
585
736
  const a = keys[i], b = keys[i + 1];
737
+ if (a.ease === "step") return a.v;
586
738
  if (typeof a.v === "number" && typeof b.v === "number") {
739
+ if (track.interpolation === "pchip") return smoothValue(track, i, t, void 0, loop);
587
740
  const u = ease(a.ease)((t - a.t) / (b.t - a.t));
588
741
  return a.v + (b.v - a.v) * u;
589
742
  }
590
743
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
744
+ if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
591
745
  const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
592
746
  return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
593
747
  }
594
748
  return a.v;
595
749
  }
596
750
  function poseOf(anim, t, out = /* @__PURE__ */ new Map()) {
597
- for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t));
751
+ 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));
752
+ return out;
753
+ }
754
+ function blendPoses(from, to, w, restOf) {
755
+ if (w >= 1) return new Map(to);
756
+ const out = /* @__PURE__ */ new Map();
757
+ for (const k of /* @__PURE__ */ new Set([...from.keys(), ...to.keys()])) {
758
+ const f = from.has(k) ? from.get(k) : restOf(k);
759
+ const cur = to.has(k) ? to.get(k) : restOf(k);
760
+ if (typeof f === "number" && !Array.isArray(cur)) {
761
+ const target = typeof cur === "number" ? cur : restOf(k);
762
+ out.set(k, f + (target - f) * w);
763
+ } else if (Array.isArray(f) && typeof f[0] === "number") {
764
+ const fv = f, tv = Array.isArray(cur) && typeof cur[0] === "number" ? cur : fv.map(() => 0);
765
+ out.set(k, fv.map((v, i) => v + ((tv[i] ?? 0) - v) * w));
766
+ } else if (Array.isArray(cur) && typeof cur[0] === "number" && typeof f !== "string") {
767
+ out.set(k, cur.map((v) => v * w));
768
+ } else out.set(k, f ?? null);
769
+ }
598
770
  return out;
599
771
  }
600
772
  function restValue(doc, key) {
601
773
  const [target, id, prop] = key.split(":");
774
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
602
775
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
603
776
  if (target === "slot") {
604
777
  const s2 = doc.slots.find((s3) => s3.id === id);
@@ -705,7 +878,9 @@ function updateSubtree(skel, root3) {
705
878
  for (const k of kids.get(id) ?? []) stack.push(k.id);
706
879
  }
707
880
  }
708
- function evaluate(doc, pose) {
881
+ function evaluate(doc, pose, modifiers) {
882
+ pose = resolveControls(doc, pose);
883
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
709
884
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
710
885
  for (const b of boneOrder(doc)) {
711
886
  const s2 = localOf(doc, pose, b);
@@ -766,10 +941,43 @@ function aimAt(skel, boneId, tx, ty, mix, compress = false, stretch = false, uni
766
941
  updateSubtree(skel, boneId);
767
942
  }
768
943
  var shortest = (deg) => ((deg + 180) % 360 + 360) % 360 - 180;
944
+ function applyLongIk(skel, c, tx, ty, mix) {
945
+ const initial = c.bones.map((id) => ({ ...skel.bones.get(id) }));
946
+ const last = c.bones.at(-1), len = boneOf(skel.doc, last).length;
947
+ const end = () => apply(skel.bones.get(last).world, len, 0);
948
+ const tolerance = c.tolerance ?? 0.05;
949
+ const root3 = skel.bones.get(c.bones[0]).world, tip = end();
950
+ if (Math.abs((tip[0] - root3.tx) * (ty - root3.ty) - (tip[1] - root3.ty) * (tx - root3.tx)) < 1e-7 && Math.hypot(tx - root3.tx, ty - root3.ty) < Math.hypot(tip[0] - root3.tx, tip[1] - root3.ty) - tolerance) {
951
+ skel.bones.get(last).rotation += cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 5 : -5;
952
+ updateSubtree(skel, last);
953
+ }
954
+ for (let iter = 0; iter < (c.iterations ?? 100); iter++) {
955
+ const e = end();
956
+ if (Math.hypot(e[0] - tx, e[1] - ty) <= tolerance) break;
957
+ for (let i = c.bones.length - 1; i >= 0; i--) {
958
+ const id = c.bones[i], b = boneOf(skel.doc, id), st = skel.bones.get(id), w = st.world, e2 = end();
959
+ const angle = wrapRad(Math.atan2(ty - w.ty, tx - w.tx) - Math.atan2(e2[1] - w.ty, e2[0] - w.tx));
960
+ const cos = Math.cos(angle), sin = Math.sin(angle);
961
+ 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 });
962
+ }
963
+ }
964
+ if (mix < 1) {
965
+ c.bones.forEach((id, i) => {
966
+ const st = skel.bones.get(id), from = initial[i];
967
+ for (const p of ["rotation", "shearX", "shearY"]) st[p] = from[p] + shortest(st[p] - from[p]) * mix;
968
+ for (const p of ["x", "y", "scaleX", "scaleY"]) st[p] = from[p] + (st[p] - from[p]) * mix;
969
+ });
970
+ updateSubtree(skel, c.bones[0]);
971
+ }
972
+ }
769
973
  function applyIk(skel, c) {
770
974
  const mix = cnum(skel, "ik", c.id, "mix", c.mix);
771
975
  if (mix <= 0) return;
772
976
  const target = skel.bones.get(c.target).world;
977
+ if (c.bones.length > 2) {
978
+ applyLongIk(skel, c, target.tx, target.ty, mix);
979
+ return;
980
+ }
773
981
  const bendDir = cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 1 : -1;
774
982
  const compress = cnum(skel, "ik", c.id, "compress", c.compress) > 0.5, stretch = cnum(skel, "ik", c.id, "stretch", c.stretch) > 0.5;
775
983
  let softness = cnum(skel, "ik", c.id, "softness", c.softness);
@@ -904,6 +1112,7 @@ function applyTransformConstraint(skel, c) {
904
1112
  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);
905
1113
  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);
906
1114
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
1115
+ c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
907
1116
  const target = skel.bones.get(c.target), tw = target.world;
908
1117
  const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
909
1118
  for (const id of c.bones) {
@@ -1294,10 +1503,10 @@ function traceLoops(mask, threshold) {
1294
1503
  return loops;
1295
1504
  }
1296
1505
  function traceHulls(mask, opts = {}) {
1297
- const { threshold = 8, tolerance = 2, margin = 1 } = opts;
1506
+ const { threshold = 8, tolerance = 2, margin = 1, minIslandRatio = MIN_ISLAND_AREA } = opts;
1298
1507
  const loops = traceLoops(mask, threshold).map((l) => ({ l, a: signedArea(l) }));
1299
1508
  const biggest = loops.reduce((m, x) => Math.abs(x.a) > Math.abs(m.a) ? x : m, loops[0]);
1300
- 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));
1509
+ 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));
1301
1510
  return islands.map(({ l }) => {
1302
1511
  const s2 = simplify(dropCollinear(l), tolerance);
1303
1512
  return margin ? offsetPolygon(s2, margin) : s2;
@@ -1700,6 +1909,54 @@ function pathFromPoints(skel, slotId, points, closed) {
1700
1909
  }
1701
1910
  return { vertices, closed, lengths: curveLengths(flatten(ctrl.flat()), closed) };
1702
1911
  }
1912
+ function constraintDependencies(doc) {
1913
+ const parent = new Map(doc.bones.map((b) => [b.id, b.parent]));
1914
+ const ancestors = (id) => {
1915
+ const out = /* @__PURE__ */ new Set();
1916
+ for (let b = id; b && !out.has(b); b = parent.get(b)) out.add(b);
1917
+ return out;
1918
+ };
1919
+ const all = [
1920
+ ...doc.ik.map((c) => ({ kind: "ik", ...c })),
1921
+ ...doc.transform.map((c) => ({ kind: "transform", ...c })),
1922
+ ...doc.path.map((c) => ({ kind: "path", ...c }))
1923
+ ].sort((a, b) => a.order - b.order);
1924
+ 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))));
1925
+ const edges = [];
1926
+ all.forEach((reader, ri) => {
1927
+ if (!active2(reader)) return;
1928
+ const reads = /* @__PURE__ */ new Set();
1929
+ const add = (b) => ancestors(b).forEach((x) => reads.add(x));
1930
+ if (reader.kind === "path") {
1931
+ const slot = doc.slots.find((s2) => s2.id === reader.target);
1932
+ if (slot) add(slot.bone);
1933
+ for (const a of doc.attachments) if (a.slot === reader.target && a.type === "path") {
1934
+ for (const list of a.weights ?? []) for (const inf of list) if (inf.w !== 0) add(inf.bone);
1935
+ }
1936
+ } else add(reader.target);
1937
+ for (const b of reader.bones) {
1938
+ const p = parent.get(b);
1939
+ if (p && !reader.bones.includes(p)) add(p);
1940
+ }
1941
+ all.forEach((writer, wi) => {
1942
+ if (ri === wi || !active2(writer)) return;
1943
+ const bone = writer.bones.find((b) => reads.has(b));
1944
+ if (bone) edges.push({ reader: reader.id, readerKind: reader.kind, writer: writer.id, writerKind: writer.kind, bone, late: wi > ri });
1945
+ });
1946
+ });
1947
+ return { all, edges };
1948
+ }
1949
+ function orderConstraints(doc) {
1950
+ const { all, edges } = constraintDependencies(doc);
1951
+ const pending3 = [...all], ids = [];
1952
+ while (pending3.length) {
1953
+ const i = pending3.findIndex((c2) => edges.filter((e) => e.reader === c2.id && e.readerKind === c2.kind).every((e) => ids.includes(`${e.writerKind}:${e.writer}`)));
1954
+ if (i < 0) throw new Error(`constraint dependency cycle: ${pending3.map((c2) => c2.id).join(", ")}`);
1955
+ const c = pending3.splice(i, 1)[0];
1956
+ ids.push(`${c.kind}:${c.id}`);
1957
+ }
1958
+ 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}`) })) };
1959
+ }
1703
1960
 
1704
1961
  // src/rig-tools.ts
1705
1962
  function createDoc(name, width, height, notes) {
@@ -1801,13 +2058,13 @@ function placeAsset(doc, p) {
1801
2058
  const boneId = cur?.bone ?? p.bone;
1802
2059
  const world = evaluate(doc, /* @__PURE__ */ new Map()).bones.get(boneId)?.world;
1803
2060
  if (!world) throw new Error(`unknown bone "${boneId}"`);
1804
- const z3 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s2) => s2.z)) + 1 : 0);
1805
- let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z3 });
2061
+ const z4 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s2) => s2.z)) + 1 : 0);
2062
+ let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z4 });
1806
2063
  const [lx, ly] = apply(invert(world), p.x, p.y);
1807
2064
  next = setAttachment(next, { id: p.asset, slot: slotId, asset: p.asset, x: round4(lx), y: round4(ly) });
1808
2065
  return setSlot(next, { id: slotId, attachment: p.asset, ...p.z !== void 0 ? { z: p.z } : {} });
1809
2066
  }
1810
- function layerPrompt(doc, assetId) {
2067
+ function layerPrompt(doc, assetId, opts = {}) {
1811
2068
  const a = doc.assets.find((a2) => a2.id === assetId);
1812
2069
  if (!a) throw new Error(`unknown asset "${assetId}"`);
1813
2070
  const att = doc.attachments.find((a2) => a2.type !== "path" && a2.asset === assetId);
@@ -1820,10 +2077,15 @@ function layerPrompt(doc, assetId) {
1820
2077
  `Part: ${a.origin?.prompt ?? a.id}.`,
1821
2078
  `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.` : "."),
1822
2079
  bone ? `Rig context: attached to bone "${bone.id}"${bone.parent ? `, parent "${bone.parent}"` : ""}${children.length ? `; child parts: ${children.join(", ")}` : ""}.` : "Rig context: not attached yet.",
1823
- "Add about 20 px (source scale) of overlap beyond every joint end so rotation cannot expose a cut.",
2080
+ "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.",
1824
2081
  "Composition: orthographic front view, one connected opaque part only, same orientation as the current layer, centred with generous padding, no foreshortening.",
1825
- "Backdrop: perfectly flat uniform #00FF00 chroma-key background with no gradient, floor, shadow, text or watermark; do not use #00FF00 inside the part."
2082
+ "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."
1826
2083
  ];
2084
+ if (opts.method === "repair") lines.push(
2085
+ "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.",
2086
+ `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.`,
2087
+ "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."
2088
+ );
1827
2089
  return lines.join("\n");
1828
2090
  }
1829
2091
  function setMeta(doc, patch) {
@@ -1844,6 +2106,9 @@ function updateBone(doc, id, patch) {
1844
2106
  const i = doc.bones.findIndex((b) => b.id === id);
1845
2107
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1846
2108
  const cur = doc.bones[i];
2109
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2110
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2111
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1847
2112
  const { parent, ...rest } = patch;
1848
2113
  const next = RigSchema.shape.bones.element.parse({
1849
2114
  ...cur,
@@ -1876,6 +2141,11 @@ function patchDrawOrder(doc, fn) {
1876
2141
  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) })) }) })) };
1877
2142
  }
1878
2143
  function setSlot(doc, slot) {
2144
+ if (slot.alpha !== void 0) {
2145
+ for (const b of doc.controlBindings ?? [])
2146
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2147
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2148
+ }
1879
2149
  const cur = doc.slots.find((s2) => s2.id === slot.id);
1880
2150
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
1881
2151
  const base = doc.slots.find((s2) => s2.id === slot.id);
@@ -1887,7 +2157,7 @@ function removeSlot(doc, id) {
1887
2157
  const gone = new Set(doc.attachments.filter((a) => a.slot === id).map((a) => a.id));
1888
2158
  return patchDrawOrder({
1889
2159
  ...doc,
1890
- slots: doc.slots.filter((s2) => s2.id !== id),
2160
+ slots: doc.slots.filter((s2) => s2.id !== id).map((s2) => gone.has(s2.clip ?? "") ? { ...s2, clip: void 0 } : s2),
1891
2161
  attachments: doc.attachments.filter((a) => a.slot !== id),
1892
2162
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
1893
2163
  }, (o) => o.filter((s2) => s2 !== id));
@@ -1935,8 +2205,8 @@ function rebindSlot(doc, slotId, bone) {
1935
2205
  function setDrawOrder(doc, order) {
1936
2206
  const ids = new Set(doc.slots.map((s2) => s2.id));
1937
2207
  if (order.length !== ids.size || new Set(order).size !== order.length || !order.every((s2) => ids.has(s2))) throw new Error("order must list every slot exactly once");
1938
- const z3 = new Map(order.map((id, i) => [id, i]));
1939
- return { ...doc, slots: doc.slots.map((s2) => ({ ...s2, z: z3.get(s2.id) })) };
2208
+ const z4 = new Map(order.map((id, i) => [id, i]));
2209
+ return { ...doc, slots: doc.slots.map((s2) => ({ ...s2, z: z4.get(s2.id) })) };
1940
2210
  }
1941
2211
  function setAttachment(doc, att) {
1942
2212
  const cur = doc.attachments.find((a) => a.id === att.id);
@@ -1974,31 +2244,33 @@ function setMesh(doc, id, patch) {
1974
2244
  const len = deformLength(next);
1975
2245
  return dropVertexKeys(withAtt, id, (l) => l === len);
1976
2246
  }
1977
- var setWeights = (doc, id, weights) => setMesh(doc, id, { weights });
1978
- function convertToMesh(doc, mesh) {
1979
- const cur = doc.attachments.find((a) => a.id === mesh.id);
1980
- if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh.id}" is not a region`);
2247
+ var setWeights = (doc, id, weights) => {
2248
+ const old = doc.attachments.find((a) => a.id === id);
2249
+ 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));
2250
+ const result = setMesh(doc, id, { weights });
2251
+ if (sameLayout) return result;
2252
+ const cleared = dropVertexKeys(result.doc, id, () => false);
2253
+ return { doc: cleared.doc, dropped: [...result.dropped, ...cleared.dropped] };
2254
+ };
2255
+ function convertToMesh(doc, mesh2) {
2256
+ const cur = doc.attachments.find((a) => a.id === mesh2.id);
2257
+ if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh2.id}" is not a region`);
1981
2258
  const droppedTracks = [];
1982
2259
  const animations = doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => {
1983
- const drop = t.target === "deform" && t.id === mesh.id;
2260
+ const drop = t.target === "deform" && t.id === mesh2.id;
1984
2261
  if (drop) droppedTracks.push(`${trackId2(t)} in ${a.id}`);
1985
2262
  return !drop;
1986
2263
  }) }));
1987
- return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh.id ? Attachment.parse(mesh) : a), animations }, droppedTracks };
2264
+ return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh2.id ? Attachment.parse(mesh2) : a), animations }, droppedTracks };
1988
2265
  }
1989
2266
  function removeAttachment(doc, id) {
1990
2267
  return {
1991
2268
  ...doc,
1992
2269
  attachments: doc.attachments.filter((a) => a.id !== id),
1993
- slots: doc.slots.map((s2) => s2.attachment === id ? { ...s2, attachment: void 0 } : s2),
2270
+ slots: doc.slots.map((s2) => ({ ...s2, attachment: s2.attachment === id ? void 0 : s2.attachment, clip: s2.clip === id ? void 0 : s2.clip })),
1994
2271
  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) }))
1995
2272
  };
1996
2273
  }
1997
- function placementFromPivot(pivot, offset, w, h) {
1998
- const o = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, ...offset };
1999
- 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);
2000
- return { x: +x.toFixed(4) + 0, y: +y.toFixed(4) + 0, rotation: o.rotation, scaleX: o.scaleX, scaleY: o.scaleY };
2001
- }
2002
2274
  var CONSTRAINT_SCHEMA = { ik: IkConstraint, transform: TransformConstraint, path: PathConstraint };
2003
2275
  var article = (kind) => kind === "ik" ? "an" : "a";
2004
2276
  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;
@@ -2010,8 +2282,10 @@ function setConstraint(doc, kind, id, patch) {
2010
2282
  const given = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0));
2011
2283
  if (!cur && !(given.bones && given.target)) throw new Error(`no ${kind} constraint "${id}" yet: creating one needs at least bones and target`);
2012
2284
  const next = CONSTRAINT_SCHEMA[kind].parse({ ...cur, ...given, id });
2013
- if (kind === "ik" && next.bones.length > 2) throw new Error(`ik "${id}": 1 or 2 bones, got ${next.bones.length} (${next.bones.join(",")})`);
2014
2285
  const list = cur ? doc[kind].map((c) => c.id === id ? next : c) : [...doc[kind], next];
2286
+ if (kind === "ik") {
2287
+ 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`);
2288
+ }
2015
2289
  return { ...doc, [kind]: list };
2016
2290
  }
2017
2291
  function removeConstraint(doc, id) {
@@ -2026,8 +2300,10 @@ function removeConstraint(doc, id) {
2026
2300
  function addIkChain(doc, bone, opts = {}) {
2027
2301
  const id = `${bone}_ik`;
2028
2302
  if (constraintKind(doc, id)) throw new Error(`constraint "${id}" exists; remove it first (rig_remove_constraint) or patch it`);
2029
- const full = ikChainFor(doc, bone);
2303
+ const full = opts.bones ?? ikChainFor(doc, bone);
2304
+ if (!full.length || full.at(-1) !== bone) throw new Error("explicit chain must end at the named tip bone");
2030
2305
  const rootId = doc.bones.find((b) => !b.parent).id;
2306
+ if (opts.bones && full.includes(rootId)) throw new Error("explicit chain cannot include the root carrying its target");
2031
2307
  const note = full.includes(rootId) ? "single bone \u2014 the two-bone chain would include the root, which carries the target" : void 0;
2032
2308
  const chain = note ? [bone] : full;
2033
2309
  if (chain.length === 1 && !doc.bones.find((b) => b.id === bone).length)
@@ -2141,10 +2417,11 @@ function pathAnchorOp(doc, id, op, index, t = 0.5) {
2141
2417
  function setKeys(doc, animId, track, keys, duration, opts = {}) {
2142
2418
  const anim = doc.animations.find((a) => a.id === animId) ?? { id: animId, duration: duration ?? 1, loop: false, tracks: [], events: [] };
2143
2419
  const same = (t) => t.target === track.target && t.id === track.id && t.prop === track.prop;
2420
+ const old = anim.tracks.find(same);
2144
2421
  const given = keys.map((k) => ({ ...k, ease: k.ease ?? "ease_in_out" }));
2145
2422
  const kept = opts.merge ? (anim.tracks.find(same)?.keys ?? []).filter((k) => !given.some((g) => Math.abs(g.t - k.t) < 1e-9)) : [];
2146
2423
  const sorted = [...kept, ...given].sort((a, b) => a.t - b.t);
2147
- const edited = { ...track, keys: sorted };
2424
+ const edited = { ...old, ...track, interpolation: track.interpolation ?? old?.interpolation, keys: sorted };
2148
2425
  const tracks = anim.tracks.some(same) ? anim.tracks.map((t) => same(t) ? edited : t) : [...anim.tracks, edited];
2149
2426
  const dur = duration ?? Math.max(anim.duration, ...sorted.map((k) => k.t));
2150
2427
  const next = { ...anim, tracks, duration: dur, preset: void 0 };
@@ -2227,7 +2504,7 @@ function copyTracks(doc, animId, from, to, opts) {
2227
2504
  const shifted = round6(k.t + shift);
2228
2505
  return { t: shifted >= anim.duration ? shifted - anim.duration : shifted, v: mirror ? -k.v : k.v, ease: k.ease };
2229
2506
  });
2230
- next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop }, keys);
2507
+ next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop, interpolation: t.interpolation }, keys);
2231
2508
  copied++;
2232
2509
  }
2233
2510
  if (!any) empty.push(srcId);
@@ -2243,6 +2520,7 @@ function trackSummary(t) {
2243
2520
  function rename2(doc, kind, id, to) {
2244
2521
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2245
2522
  if (to === id) return doc;
2523
+ 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) })) };
2246
2524
  const sub = (v) => v === id ? to : v;
2247
2525
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2248
2526
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2287,7 +2565,7 @@ function rename2(doc, kind, id, to) {
2287
2565
  return {
2288
2566
  ...doc,
2289
2567
  attachments: doc.attachments.map((a) => ({ ...a, id: sub(a.id) })),
2290
- slots: doc.slots.map((s2) => s2.attachment ? { ...s2, attachment: sub(s2.attachment) } : s2)
2568
+ slots: doc.slots.map((s2) => ({ ...s2, attachment: s2.attachment ? sub(s2.attachment) : void 0, clip: s2.clip ? sub(s2.clip) : void 0 }))
2291
2569
  };
2292
2570
  }
2293
2571
  case "asset": {
@@ -2317,13 +2595,15 @@ function describeAttachment(doc, att) {
2317
2595
  }
2318
2596
  const n = vertexCount(att);
2319
2597
  const bones = att.weights ? [...new Set(att.weights.flat().map((i) => i.bone))] : [];
2320
- 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((s2) => s2.id === att.slot)?.bone ?? "?"}"'s space)`;
2598
+ 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((s2) => s2.id === att.slot)?.bone ?? "?"}"'s space)`;
2321
2599
  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"}`;
2322
2600
  const keyed = doc.animations.filter((a) => a.tracks.some((t) => t.target === "deform" && t.id === att.id && t.prop === "vertices")).map((a) => a.id);
2323
- return `${head}: ${shape}; ${skin}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2601
+ return `${head}: ${shape}; ${skin2}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
2324
2602
  }
2325
2603
  function describe(doc, opts = {}) {
2326
2604
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2605
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2606
+ 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(", ")}`);
2327
2607
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2328
2608
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2329
2609
  const place = (s2) => {
@@ -2481,9 +2761,11 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2481
2761
  let specs;
2482
2762
  try {
2483
2763
  specs = JSON.parse(await readFile(join2(dir, "layers.json"), "utf8"));
2484
- } catch {
2764
+ } catch (e) {
2765
+ if (e.code !== "ENOENT") throw e;
2485
2766
  specs = (await readdir(dir)).filter((f) => f.endsWith(".png")).sort().map((file) => ({ file }));
2486
2767
  }
2768
+ if (!Array.isArray(specs)) throw new Error("layers.json must be an array");
2487
2769
  let next = doc;
2488
2770
  const parsed = [];
2489
2771
  for (const s2 of specs) {
@@ -2491,21 +2773,32 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2491
2773
  const [w, h] = pngSize(buf);
2492
2774
  const m = basename2(s2.file, ".png").match(/^(?:(\d+)_)?(.+)$/);
2493
2775
  const id = s2.id ?? m[2].toLowerCase().replace(/[^a-z0-9_]/g, "_");
2494
- const z3 = s2.z ?? (m[1] ? +m[1] : parsed.length);
2776
+ const z4 = s2.z ?? (m[1] ? +m[1] : parsed.length);
2495
2777
  const role = s2.role ?? (DEFAULT_PARENT[id] || id === "root" ? id : void 0);
2496
2778
  const bbox = s2.bbox ?? [0, 0, w, h];
2497
- parsed.push({ ...s2, id, z: z3, role, w, h, bbox });
2779
+ if (bbox.length !== 4 || bbox.some((v) => !Number.isFinite(v)) || bbox[2] <= 0 || bbox[3] <= 0)
2780
+ throw new Error(`layer "${id}": bbox must have positive width and height`);
2781
+ parsed.push({ ...s2, id, z: z4, role, w, h, bbox });
2498
2782
  }
2499
2783
  const ids = new Set(parsed.map((p) => p.id));
2500
2784
  const rootId = next.bones.find((b) => !b.parent).id;
2501
2785
  const exists3 = (id) => ids.has(id) || next.bones.some((b) => b.id === id);
2502
2786
  const resolveParent = (p) => {
2503
- if (p.parent && exists3(p.parent)) return p.parent;
2787
+ if (p.parent) {
2788
+ if (!exists3(p.parent)) throw new Error(`layer "${p.id}": unknown parent "${p.parent}"`);
2789
+ return p.parent;
2790
+ }
2504
2791
  let cur = p.role && DEFAULT_PARENT[p.role];
2505
2792
  while (cur && !exists3(cur)) cur = DEFAULT_PARENT[cur];
2506
2793
  return cur ?? rootId;
2507
2794
  };
2508
- const dependency = (p) => p.bone && exists3(p.bone) ? p.bone : resolveParent(p);
2795
+ const dependency = (p) => {
2796
+ if (p.bone) {
2797
+ if (!exists3(p.bone)) throw new Error(`layer "${p.id}": unknown bone "${p.bone}"`);
2798
+ return p.bone;
2799
+ }
2800
+ return resolveParent(p);
2801
+ };
2509
2802
  const pending3 = [...parsed];
2510
2803
  while (pending3.length) {
2511
2804
  const i = pending3.findIndex((p2) => next.bones.some((b) => b.id === dependency(p2)));
@@ -2514,36 +2807,45 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2514
2807
  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`);
2515
2808
  const [bx, by, bw, bh] = p.bbox;
2516
2809
  const joint = p.joint ?? [bx + bw / 2, by + bh / 2];
2517
- const host = worldOf(next, target);
2810
+ const host = evaluate(next, /* @__PURE__ */ new Map()).bones.get(target).world;
2811
+ if (Math.abs(host.a * host.d - host.b * host.c) < 1e-10) throw new Error(`layer "${p.id}": singular host transform`);
2518
2812
  next = { ...next, assets: [...next.assets, RigSchema.shape.assets.element.parse({
2519
2813
  id: p.id,
2520
2814
  src: `${relPrefix}/${p.file}`,
2521
2815
  width: p.w,
2522
2816
  height: p.h,
2523
- origin: { sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2817
+ origin: { bboxSpace: "document", sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
2524
2818
  })] };
2525
- const pivot = { x: (joint[0] - bx) / bw, y: (joint[1] - by) / bh };
2819
+ const place = (slotId) => {
2820
+ const slot = next.slots.find((s2) => s2.id === slotId);
2821
+ const inv = invert(evaluate(next, /* @__PURE__ */ new Map()).bones.get(slot.bone).world);
2822
+ const [x, y] = apply(inv, bx + bw / 2, by + bh / 2);
2823
+ 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 };
2824
+ const sx = Math.hypot(m.a, m.b), sy = Math.hypot(m.c, m.d);
2825
+ if (Math.abs(m.a * m.c + m.b * m.d) > 1e-8 * sx * sy) {
2826
+ const vertices = [[bx, by], [bx + bw, by], [bx + bw, by + bh], [bx, by + bh]].flatMap(([x2, y2]) => apply(inv, x2, y2));
2827
+ 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 });
2828
+ next = { ...next, attachments: [...next.attachments.filter((a) => a.id !== p.id), att] };
2829
+ } 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 });
2830
+ };
2526
2831
  if (p.swap || p.bone) {
2527
2832
  const slotId = p.slot ?? p.id;
2528
2833
  if (!next.slots.some((s2) => s2.id === slotId)) next = setSlot(next, { id: slotId, bone: target, z: p.z, blend: p.blend });
2529
- 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) });
2834
+ place(slotId);
2530
2835
  if (!p.swap) next = setSlot(next, { id: slotId, attachment: p.id });
2531
2836
  continue;
2532
2837
  }
2533
- if (!next.bones.some((b) => b.id === p.id))
2534
- 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 } });
2838
+ if (!next.bones.some((b) => b.id === p.id)) {
2839
+ const [x, y] = apply(invert(host), ...joint);
2840
+ next = addBone(next, { id: p.id, parent: target, role: p.role, transform: { x, y } });
2841
+ }
2535
2842
  next = setSlot(next, { id: p.id, bone: p.id, z: p.z, blend: p.blend });
2536
- next = setAttachment(next, { id: p.id, slot: p.id, asset: p.id, ...placementFromPivot(pivot, {}, p.w, p.h) });
2843
+ place(p.id);
2537
2844
  next = setSlot(next, { id: p.id, attachment: p.id });
2538
2845
  }
2539
2846
  return next;
2540
2847
  }
2541
2848
  var s_bbox = (p) => p.bbox;
2542
- function worldOf(doc, id) {
2543
- const b = doc.bones.find((b2) => b2.id === id);
2544
- const p = b.parent ? worldOf(doc, b.parent) : { x: 0, y: 0 };
2545
- return { x: p.x + b.transform.x, y: p.y + b.transform.y };
2546
- }
2547
2849
 
2548
2850
  // src/rig-presets.ts
2549
2851
  var root = (doc) => doc.bones.find((b) => !b.parent);
@@ -2751,18 +3053,57 @@ function applyPreset(doc, name, params = {}) {
2751
3053
  return { ...doc, animations };
2752
3054
  }
2753
3055
 
3056
+ // src/preview/export-video.ts
3057
+ import { spawn } from "node:child_process";
3058
+ import { once } from "node:events";
3059
+ import { mkdtemp, writeFile as writeFile2, rm as rm2, rename as rename3, mkdir as mkdir2 } from "node:fs/promises";
3060
+ import { dirname as dirname4, join as join5 } from "node:path";
3061
+
2754
3062
  // src/preview/render-preview.ts
2755
3063
  import { chromium } from "playwright";
2756
- import { readFile as readFile2 } from "node:fs/promises";
2757
- import { join as join3, dirname as dirname2 } from "node:path";
3064
+ import { readFile as readFile3 } from "node:fs/promises";
3065
+ import { join as join4, dirname as dirname3 } from "node:path";
2758
3066
  import { fileURLToPath } from "node:url";
2759
- var HERE = dirname2(fileURLToPath(import.meta.url));
3067
+
3068
+ // src/rig-qa.ts
3069
+ import { createHash } from "node:crypto";
3070
+ import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises";
3071
+ import { dirname as dirname2, join as join3 } from "node:path";
3072
+ var sha256 = (data) => createHash("sha256").update(data).digest("hex");
3073
+ async function verifyRenderManifest(rigPath, framesDir, expected) {
3074
+ const m = JSON.parse(await readFile2(join3(framesDir, "manifest.json"), "utf8"));
3075
+ if (m.version !== 1 || !m.frames?.length || !m.times?.length) throw new Error("missing or empty capture manifest");
3076
+ const raw = await readFile2(rigPath, "utf8");
3077
+ if (sha256(raw) !== m.rigFile) throw new Error("stale render: rig file changed");
3078
+ const doc = parse(JSON.parse(raw));
3079
+ if (sha256(JSON.stringify(doc)) !== m.identity.document) throw new Error("stale render: rig changed");
3080
+ if (sha256(await readFile2(expected.renderer)) !== m.identity.renderer) throw new Error("stale render: renderer changed");
3081
+ 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`);
3082
+ 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`);
3083
+ 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");
3084
+ if (expected.transparent !== void 0 && m.transparent !== expected.transparent) throw new Error("capture has the wrong alpha mode");
3085
+ 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");
3086
+ const files2 = (await readdir2(framesDir)).filter((f) => f.endsWith(".png")).sort();
3087
+ if (JSON.stringify(files2) !== JSON.stringify(m.frames.map((f) => f.file).sort())) throw new Error("capture directory has missing or unmanifested frames");
3088
+ for (const f of m.frames) {
3089
+ if (!/^\d{6}\.png$/.test(f.file)) throw new Error("invalid capture filename");
3090
+ if (sha256(await readFile2(join3(framesDir, f.file))) !== f.sha256) throw new Error(`frame ${f.file} changed since capture`);
3091
+ }
3092
+ return m;
3093
+ }
3094
+
3095
+ // src/preview/render-preview.ts
3096
+ import { PNG } from "pngjs";
3097
+ var HERE = dirname3(fileURLToPath(import.meta.url));
2760
3098
  var browser;
2761
3099
  var page;
2762
3100
  var harnessJs;
2763
3101
  var pending2 = Promise.resolve();
2764
3102
  async function getPage() {
2765
- if (page && !page.isClosed()) return page;
3103
+ const latestHarness = await readFile3(join4(HERE, "harness.js"), "utf8");
3104
+ if (page && !page.isClosed() && latestHarness === harnessJs) return page;
3105
+ if (page && !page.isClosed()) await page.close();
3106
+ harnessJs = latestHarness;
2766
3107
  if (browser && !browser.isConnected()) browser = void 0;
2767
3108
  browser ??= await chromium.launch({
2768
3109
  headless: true,
@@ -2772,7 +3113,7 @@ async function getPage() {
2772
3113
  try {
2773
3114
  next.on("pageerror", (e) => console.error("[harness]", e.message));
2774
3115
  await next.setContent(`<!doctype html><html><body style="margin:0;background:transparent"></body></html>`);
2775
- harnessJs ??= await readFile2(join3(HERE, "harness.js"), "utf8");
3116
+ harnessJs ??= await readFile3(join4(HERE, "harness.js"), "utf8");
2776
3117
  await next.addScriptTag({ content: harnessJs });
2777
3118
  page = next;
2778
3119
  return next;
@@ -2810,6 +3151,8 @@ async function render(doc, opts) {
2810
3151
  if (opts.count !== void 0) positive(opts.count, "count", true);
2811
3152
  const anim = opts.animation === void 0 ? void 0 : doc.animations.find((a) => a.id === opts.animation);
2812
3153
  if (opts.animation !== void 0 && !anim) throw new Error(`unknown animation "${opts.animation}"`);
3154
+ if (opts.weightBone && !doc.bones.some((b) => b.id === opts.weightBone)) throw new Error(`unknown weight bone "${opts.weightBone}"`);
3155
+ 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}"`);
2813
3156
  if (opts.times && (!opts.times.length || opts.times.some((t) => !Number.isFinite(t) || t < 0)))
2814
3157
  throw new Error("times must contain finite, nonnegative sample times");
2815
3158
  if (opts.region && (opts.region.length !== 4 || opts.region.some((v) => !Number.isFinite(v)) || opts.region[2] <= 0 || opts.region[3] <= 0))
@@ -2826,12 +3169,27 @@ async function render(doc, opts) {
2826
3169
  }
2827
3170
  }
2828
3171
  const assets = {};
3172
+ const identity = { document: sha256(JSON.stringify(doc)), assets: {}, renderer: "", masks: {} };
2829
3173
  for (const a of doc.assets) {
2830
3174
  if (assets[a.src]) continue;
2831
- const buf = await readFile2(join3(opts.assetsDir, a.src));
3175
+ let buf = await readFile3(join4(opts.assetsDir, a.src));
3176
+ identity.assets[a.src] = sha256(buf);
3177
+ if (opts.debugFillMasks?.[a.src]) {
3178
+ const maskData = await readFile3(join4(opts.assetsDir, opts.debugFillMasks[a.src]));
3179
+ identity.masks[opts.debugFillMasks[a.src]] = sha256(maskData);
3180
+ const png = PNG.sync.read(buf), mask = PNG.sync.read(maskData);
3181
+ if (png.width !== mask.width || png.height !== mask.height) throw new Error(`fill mask dimensions differ for ${a.src}`);
3182
+ for (let i = 0; i < png.data.length; i += 4) if (mask.data[i] > 127 && mask.data[i + 3] > 0) {
3183
+ png.data[i] = 255;
3184
+ png.data[i + 1] = 0;
3185
+ png.data[i + 2] = 255;
3186
+ }
3187
+ buf = PNG.sync.write(png);
3188
+ }
2832
3189
  assets[a.src] = `data:${mime(a.src)};base64,${buf.toString("base64")}`;
2833
3190
  }
2834
3191
  const p = await getPage();
3192
+ identity.renderer = sha256(harnessJs);
2835
3193
  const res = await p.evaluate(
2836
3194
  async ({ doc: doc2, assets: assets2, scale, ro }) => {
2837
3195
  const h = window.rigHarness;
@@ -2842,10 +3200,11 @@ async function render(doc, opts) {
2842
3200
  doc,
2843
3201
  assets,
2844
3202
  scale: opts.scale ?? 1,
2845
- ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
3203
+ 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 }
2846
3204
  }
2847
3205
  );
2848
3206
  return {
3207
+ identity,
2849
3208
  frames: res.frames.map(fromDataUrl),
2850
3209
  sheet: fromDataUrl(res.sheet),
2851
3210
  times: times2,
@@ -2854,6 +3213,146 @@ async function render(doc, opts) {
2854
3213
  };
2855
3214
  }
2856
3215
 
3216
+ // src/preview/export-video.ts
3217
+ async function exportVideo(doc, opts) {
3218
+ const anim = doc.animations.find((a) => a.id === opts.animation), fps = opts.fps ?? 60;
3219
+ if (!anim) throw new Error(`unknown animation "${opts.animation}"`);
3220
+ if (!Number.isInteger(fps) || fps < 1 || fps > 240) throw new Error("video fps must be an integer between 1 and 240");
3221
+ const count = Math.max(1, Math.round(anim.duration * fps));
3222
+ if (count > 1e4) throw new Error("video exceeds 10000 frames; export shorter clips");
3223
+ await mkdir2(dirname4(opts.outFile), { recursive: true });
3224
+ const temp = await mkdtemp(join5(dirname4(opts.outFile), ".golem-video-"));
3225
+ try {
3226
+ for (let start = 0; start < count; start += 24) {
3227
+ const times2 = Array.from({ length: Math.min(24, count - start) }, (_, i) => (start + i) / fps);
3228
+ const rendered = await renderPreview(doc, { ...opts, transparent: false, times: times2 });
3229
+ await Promise.all(rendered.frames.map((frame, i) => writeFile2(join5(temp, `${String(start + i).padStart(6, "0")}.png`), frame)));
3230
+ }
3231
+ const output = join5(temp, "preview.mp4");
3232
+ 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"] });
3233
+ let diagnostic = "";
3234
+ proc.stderr.on("data", (chunk) => {
3235
+ diagnostic = (diagnostic + chunk).slice(-8e3);
3236
+ });
3237
+ const [code] = await once(proc, "close");
3238
+ if (code !== 0) throw new Error(`MP4 encoder failed: ${diagnostic}`);
3239
+ await rename3(output, opts.outFile);
3240
+ return { frames: count, fps, duration: count / fps };
3241
+ } finally {
3242
+ await rm2(temp, { recursive: true, force: true });
3243
+ }
3244
+ }
3245
+
3246
+ // src/rig-quality.ts
3247
+ var values = (v) => typeof v === "number" ? [v] : Array.isArray(v) && v.every((n) => typeof n === "number") ? v : void 0;
3248
+ var delta = (a, b) => a.map((v, i) => v - b[i]);
3249
+ var magnitude = (a) => Math.max(0, ...a.map(Math.abs));
3250
+ function motionQuality(doc, anim, opts = {}) {
3251
+ const out = [], D = anim.duration;
3252
+ const report = (code, subject, message, time, level = "warn") => out.push({ level, code, subject, animation: anim.id, time, message });
3253
+ for (const tr of anim.tracks) {
3254
+ const id = `${tr.target}.${tr.id}.${tr.prop}`, looping = anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === D;
3255
+ const at = (t) => sample(tr, t, looping), numeric = tr.keys.every((k) => values(k.v) !== void 0);
3256
+ if (anim.loop) {
3257
+ const first = at(0), last = at(D), av = values(first), bv = values(last);
3258
+ const diff = av && bv && av.length === bv.length ? magnitude(delta(av, bv)) : first === last || JSON.stringify(first) === JSON.stringify(last) ? 0 : Infinity;
3259
+ 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`);
3260
+ if (numeric && diff <= 1e-3 && tr.keys.length > 1) {
3261
+ 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);
3262
+ 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);
3263
+ const dv = magnitude(delta(va, vb));
3264
+ 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`);
3265
+ }
3266
+ }
3267
+ if (!numeric) continue;
3268
+ for (let i = 1; i < tr.keys.length - 1; i++) {
3269
+ const p = tr.keys[i - 1], k = tr.keys[i], n = tr.keys[i + 1];
3270
+ if (p.ease === "step" || k.ease === "step") continue;
3271
+ const h = Math.min(k.t - p.t, n.t - k.t) / 1e3;
3272
+ 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);
3273
+ const before = delta(values(k.v), values(p.v)), after = delta(values(n.v), values(k.v));
3274
+ const pass = before.some((v, j2) => v * after[j2] > 1e-6);
3275
+ const speed = Math.max(magnitude(before) / (k.t - p.t), magnitude(after) / (n.t - k.t));
3276
+ 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);
3277
+ const jump = magnitude(delta(left, right));
3278
+ 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);
3279
+ const acceleration = (a, b, c) => values(at(b)).map((v, j2) => (values(at(c))[j2] - 2 * v + values(at(a))[j2]) / (h * h));
3280
+ const al = acceleration(k.t - 2 * h, k.t - h, k.t), ar = acceleration(k.t, k.t + h, k.t + 2 * h);
3281
+ const aj = magnitude(delta(al, ar));
3282
+ 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");
3283
+ }
3284
+ }
3285
+ const steps = Math.max(2, Math.ceil(D * 60));
3286
+ const times2 = new Set(Array.from({ length: steps + 1 }, (_, i) => i * D / steps));
3287
+ for (const tr of anim.tracks) for (const k of tr.keys) {
3288
+ times2.add(k.t);
3289
+ if (k.t > 0) times2.add(Math.max(0, k.t - 1e-5));
3290
+ }
3291
+ const samples = [...times2].sort((a, b) => a - b).map((t) => ({ t, pose: poseOf(anim, t) }));
3292
+ for (const bone of opts.secondaryBones ?? []) {
3293
+ if (!doc.bones.some((b) => b.id === bone)) throw new Error(`unknown secondary bone "${bone}"`);
3294
+ const local = samples.map((s2) => evaluate(doc, s2.pose).bones.get(bone));
3295
+ const curves = ["rotation", "x", "y"].map((prop) => local.map((b) => b[prop]));
3296
+ 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`);
3297
+ }
3298
+ const reported = /* @__PURE__ */ new Set();
3299
+ for (const [front, back] of opts.frontOf ?? []) {
3300
+ if (![front, back].every((id) => doc.slots.some((s2) => s2.id === id))) throw new Error(`unknown frontOf slots: ${front}, ${back}`);
3301
+ for (const { t, pose } of samples) {
3302
+ const skel = evaluate(doc, pose);
3303
+ if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3304
+ const visible = (id) => {
3305
+ const slot = doc.slots.find((s2) => s2.id === id);
3306
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3307
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3308
+ };
3309
+ if (!visible(front) || !visible(back)) continue;
3310
+ const order = pose.get(pk("drawOrder", "order", "order"));
3311
+ const z4 = (id) => order ? order.indexOf(id) : Number(pose.get(pk("slot", id, "z")) ?? doc.slots.find((s2) => s2.id === id).z);
3312
+ const pair = JSON.stringify([front, back]);
3313
+ if (z4(front) <= z4(back) && !reported.has(pair)) {
3314
+ reported.add(pair);
3315
+ report("draw_order_rule", front, `"${front}" must stay explicitly in front of "${back}" according to the brief (equal z is ambiguous)`, t);
3316
+ }
3317
+ }
3318
+ }
3319
+ 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)) {
3320
+ const a = evaluate(doc, poseOf(anim, Math.max(0, key.t - 1e-6))), b = evaluate(doc, poseOf(anim, key.t));
3321
+ const before = a.attachment.get(tr.id), after = b.attachment.get(tr.id);
3322
+ if (!before || !after || before.id === after.id) continue;
3323
+ const landmarksA = opts.landmarks?.[before.id], landmarksB = opts.landmarks?.[after.id];
3324
+ if (!landmarksA && !landmarksB) continue;
3325
+ if (!landmarksA || !landmarksB || !Object.keys(landmarksA).some((name) => landmarksB[name])) {
3326
+ report("landmark_unmeasured", tr.id, `swap ${before.id} \u2192 ${after.id} has no shared declared landmarks`, key.t);
3327
+ continue;
3328
+ }
3329
+ const point = (skel, att, uv) => {
3330
+ if (att.type === "region") {
3331
+ const [w, h] = regionSize(doc, att);
3332
+ return apply(regionMatrix(skel, att.slot, att), (uv[0] - 0.5) * w, (uv[1] - 0.5) * h);
3333
+ }
3334
+ if (att.type !== "mesh") return;
3335
+ const v = attachmentWorldVertices(skel, att.slot, att);
3336
+ for (let i = 0; i < att.triangles.length; i += 3) {
3337
+ 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;
3338
+ if (Math.abs(det) < 1e-12) continue;
3339
+ const s2 = ((uv[0] - ux) * cy - (uv[1] - uy) * cx) / det, r = (bx * (uv[1] - uy) - by * (uv[0] - ux)) / det;
3340
+ if (s2 >= -1e-6 && r >= -1e-6 && s2 + r <= 1 + 1e-6) return [v[ia * 2] * (1 - s2 - r) + v[ib * 2] * s2 + v[ic * 2] * r, v[ia * 2 + 1] * (1 - s2 - r) + v[ib * 2 + 1] * s2 + v[ic * 2 + 1] * r];
3341
+ }
3342
+ };
3343
+ for (const [name, uv] of Object.entries(landmarksA)) if (landmarksB[name]) {
3344
+ const p = point(a, before, uv), q = point(b, after, landmarksB[name]);
3345
+ if (!p || !q) {
3346
+ report("landmark_unmeasured", tr.id, `landmark "${name}" is outside attachment geometry`, key.t);
3347
+ continue;
3348
+ }
3349
+ const distance = Math.hypot(p[0] - q[0], p[1] - q[1]);
3350
+ 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);
3351
+ }
3352
+ }
3353
+ return out;
3354
+ }
3355
+
2857
3356
  // src/rig-check.ts
2858
3357
  var FOLD_ERROR_RATIO = 0.05;
2859
3358
  var IK_SLACK = 1;
@@ -2862,17 +3361,25 @@ var isNumeric = (t) => t.target !== "slot" || t.prop !== "attachment";
2862
3361
  var trackId3 = (t) => `${t.target}.${t.id}.${t.prop}`;
2863
3362
  var fmt = (n) => String(Math.round(n * 1e3) / 1e3);
2864
3363
  var times = (a, fps) => {
2865
- const n = Math.max(1, Math.round(a.duration * fps)), out = [];
2866
- for (let i = 0; i <= n; i++) out.push(Math.min(a.duration, i / fps));
3364
+ const n = Math.max(1, Math.ceil(a.duration * fps)), out = [];
3365
+ for (let i = 0; i <= n; i++) out.push(a.duration * i / n);
2867
3366
  return out;
2868
3367
  };
2869
3368
  function checkRig(doc, opts = {}) {
2870
3369
  const fps = opts.fps ?? 30, dfps = opts.deformFps ?? 60, overlapMin = opts.overlap ?? 0.6, margin = opts.margin ?? 1;
3370
+ if (![fps, dfps].every((v) => Number.isFinite(v) && v > 0)) throw new Error("check sampling rates must be positive");
2871
3371
  const anims = opts.animation === void 0 ? doc.animations : doc.animations.filter((a) => a.id === opts.animation);
2872
3372
  if (opts.animation !== void 0 && !anims.length) throw new Error(`unknown animation "${opts.animation}"`);
2873
3373
  const out = [];
3374
+ for (const e of constraintDependencies(doc).edges.filter((e2) => e2.late)) out.push({
3375
+ level: "warn",
3376
+ code: "constraint_order",
3377
+ subject: e.reader,
3378
+ message: `constraint "${e.reader}" reads bone "${e.bone}" before later constraint "${e.writer}" moves it; use rig_order_constraints or set explicit order`
3379
+ });
2874
3380
  const W = doc.meta.width, H = doc.meta.height;
2875
3381
  const overflowOf = (pose, slotId, skel) => {
3382
+ pose = skel.pose;
2876
3383
  const pts = slotOutline(doc, pose, slotId, 4, skel);
2877
3384
  if (!pts) return void 0;
2878
3385
  const slot = doc.slots.find((s2) => s2.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3042,6 +3549,7 @@ function checkRig(doc, opts = {}) {
3042
3549
  return rest;
3043
3550
  };
3044
3551
  for (const a of anims) {
3552
+ out.push(...motionQuality(doc, a, opts));
3045
3553
  for (const t of a.tracks) {
3046
3554
  if (!isNumeric(t)) continue;
3047
3555
  const vals = t.keys.map((k) => k.v);
@@ -3070,6 +3578,7 @@ function checkRig(doc, opts = {}) {
3070
3578
  }
3071
3579
  }
3072
3580
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3581
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3073
3582
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3074
3583
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3075
3584
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3149,7 +3658,9 @@ function checkRig(doc, opts = {}) {
3149
3658
  if (!sorted.length) continue;
3150
3659
  const typical = sorted[Math.floor(sorted.length / 2)];
3151
3660
  const worst2 = ds.reduce((m, x) => x.d > m.d ? x : m, ds[0]);
3152
- if (worst2.d > Math.max(6, 5 * typical)) {
3661
+ const index = ds.indexOf(worst2);
3662
+ const neighbours = Math.max(ds[index - 1]?.d ?? 0, ds[index + 1]?.d ?? 0);
3663
+ if (worst2.d > Math.max(6, 5 * typical, 3 * neighbours)) {
3153
3664
  const stepped = stepTimes.some((st) => st > worst2.t - 1 / dfps - 1e-9 && st <= worst2.t + 1e-9);
3154
3665
  out.push({
3155
3666
  level: stepped ? "info" : "warn",
@@ -3200,7 +3711,7 @@ function checkRig(doc, opts = {}) {
3200
3711
  for (const att of doc.attachments) {
3201
3712
  if (att.type !== "region") continue;
3202
3713
  const asset = doc.assets.find((a) => a.id === att.asset), bbox = asset?.origin?.bbox;
3203
- if (!asset || !bbox || !doc.slots.some((s2) => s2.id === att.slot)) continue;
3714
+ if (!asset || !bbox || asset.origin?.bboxSpace === "source" || !doc.slots.some((s2) => s2.id === att.slot)) continue;
3204
3715
  const [cx, cy] = apply(regionMatrix(restWorld, att.slot, att), 0, 0);
3205
3716
  const ex = bbox[0] + bbox[2] / 2, ey = bbox[1] + bbox[3] / 2, dx = cx - ex, dy = cy - ey;
3206
3717
  const dist = Math.hypot(dx, dy);
@@ -3245,7 +3756,8 @@ function checkRig(doc, opts = {}) {
3245
3756
  for (const att of doc.attachments) if (att.type !== "path" && shownAttachments.has(att.id)) used.add(att.asset);
3246
3757
  for (const asset of doc.assets) if (!used.has(asset.id))
3247
3758
  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` });
3248
- for (const s2 of doc.slots) if (!s2.attachment && !swappedIn.has(s2.id))
3759
+ const maskSlots = new Set(doc.slots.flatMap((s2) => s2.clip ? [doc.attachments.find((a) => a.id === s2.clip)?.slot] : []));
3760
+ for (const s2 of doc.slots) if (!s2.attachment && !swappedIn.has(s2.id) && !maskSlots.has(s2.id))
3249
3761
  out.push({ level: "warn", code: "never_visible", subject: s2.id, message: `slot "${s2.id}" has no attachment and no animation ever shows one` });
3250
3762
  return out;
3251
3763
  }
@@ -3273,14 +3785,14 @@ function summarize(findings, max = 12) {
3273
3785
  }
3274
3786
 
3275
3787
  // src/rig-history.ts
3276
- import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, writeFile as writeFile2, unlink } from "node:fs/promises";
3277
- import { basename as basename3, dirname as dirname3, join as join4 } from "node:path";
3788
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile4, writeFile as writeFile3, unlink } from "node:fs/promises";
3789
+ import { basename as basename3, dirname as dirname5, join as join6 } from "node:path";
3278
3790
  function historyDir(rigPath) {
3279
- return join4(dirname3(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3791
+ return join6(dirname5(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
3280
3792
  }
3281
3793
  async function files(dir) {
3282
3794
  try {
3283
- return (await readdir2(dir)).filter((f) => /^\d{4,}\.json$/.test(f)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
3795
+ return (await readdir3(dir)).filter((f) => /^\d{4,}\.json$/.test(f)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
3284
3796
  } catch {
3285
3797
  return [];
3286
3798
  }
@@ -3289,22 +3801,22 @@ async function snapshots(rigPath) {
3289
3801
  return files(historyDir(rigPath));
3290
3802
  }
3291
3803
  async function listHistory(rigPath) {
3292
- return (await snapshots(rigPath)).map((f) => join4(historyDir(rigPath), f));
3804
+ return (await snapshots(rigPath)).map((f) => join6(historyDir(rigPath), f));
3293
3805
  }
3294
- var redoDir = (rigPath) => join4(historyDir(rigPath), "redo");
3806
+ var redoDir = (rigPath) => join6(historyDir(rigPath), "redo");
3295
3807
  async function listRedo(rigPath) {
3296
- return (await files(redoDir(rigPath))).map((f) => join4(redoDir(rigPath), f));
3808
+ return (await files(redoDir(rigPath))).map((f) => join6(redoDir(rigPath), f));
3297
3809
  }
3298
3810
  async function clearRedo(rigPath) {
3299
3811
  for (const f of await listRedo(rigPath)) await unlink(f);
3300
3812
  }
3301
3813
  async function pushInto(dir, contents, keep) {
3302
- await mkdir2(dir, { recursive: true });
3814
+ await mkdir3(dir, { recursive: true });
3303
3815
  const fs = await files(dir);
3304
3816
  const next = fs.length ? +fs[fs.length - 1].slice(0, -5) + 1 : 1;
3305
- const file = join4(dir, `${String(next).padStart(4, "0")}.json`);
3306
- await writeFile2(file, contents);
3307
- for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join4(dir, f));
3817
+ const file = join6(dir, `${String(next).padStart(4, "0")}.json`);
3818
+ await writeFile3(file, contents);
3819
+ for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join6(dir, f));
3308
3820
  return file;
3309
3821
  }
3310
3822
  async function pushHistory(rigPath, contents, keep = 50, opts = {}) {
@@ -3317,12 +3829,12 @@ async function popHistory(rigPath, steps = 1) {
3317
3829
  if (!Number.isInteger(steps) || steps < 1 || steps > files2.length)
3318
3830
  throw new Error(`only ${files2.length} snapshot${files2.length === 1 ? "" : "s"} available, cannot undo ${steps}`);
3319
3831
  const dir = historyDir(rigPath);
3320
- const contents = await readFile3(join4(dir, files2[files2.length - steps]), "utf8");
3321
- for (const f of files2.slice(files2.length - steps)) await unlink(join4(dir, f));
3832
+ const contents = await readFile4(join6(dir, files2[files2.length - steps]), "utf8");
3833
+ for (const f of files2.slice(files2.length - steps)) await unlink(join6(dir, f));
3322
3834
  return contents;
3323
3835
  }
3324
3836
  async function undo(rigPath, steps = 1) {
3325
- const current = await readFile3(rigPath, "utf8");
3837
+ const current = await readFile4(rigPath, "utf8");
3326
3838
  const restored = await popHistory(rigPath, steps);
3327
3839
  await pushInto(redoDir(rigPath), current, 50);
3328
3840
  return restored;
@@ -3331,8 +3843,8 @@ async function redo(rigPath) {
3331
3843
  const entries2 = await listRedo(rigPath);
3332
3844
  if (!entries2.length) throw new Error("nothing to redo");
3333
3845
  const file = entries2[entries2.length - 1];
3334
- const contents = await readFile3(file, "utf8");
3335
- await pushHistory(rigPath, await readFile3(rigPath, "utf8"), 50, { keepRedo: true });
3846
+ const contents = await readFile4(file, "utf8");
3847
+ await pushHistory(rigPath, await readFile4(rigPath, "utf8"), 50, { keepRedo: true });
3336
3848
  await unlink(file);
3337
3849
  return contents;
3338
3850
  }
@@ -3403,11 +3915,13 @@ function diffDocs(a, b) {
3403
3915
  ["attachments", "attachment"],
3404
3916
  ["ik", "ik"],
3405
3917
  ["transform", "transform"],
3406
- ["path", "path"]
3918
+ ["path", "path"],
3919
+ ["controls", "control"],
3920
+ ["controlBindings", "control binding"]
3407
3921
  ];
3408
3922
  for (const [kind, name] of kinds) {
3409
- const ma = new Map(a[kind].map((x) => [x.id, x]));
3410
- const mb = new Map(b[kind].map((x) => [x.id, x]));
3923
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3924
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3411
3925
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3412
3926
  for (const [id, x] of ma) {
3413
3927
  const y = mb.get(id);
@@ -3426,9 +3940,9 @@ function diffDocs(a, b) {
3426
3940
  }
3427
3941
 
3428
3942
  // src/rig-atlas.ts
3429
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
3430
- import { join as join5 } from "node:path";
3431
- import { PNG } from "pngjs";
3943
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
3944
+ import { join as join7 } from "node:path";
3945
+ import { PNG as PNG2 } from "pngjs";
3432
3946
  function packRects(rects, opts = {}) {
3433
3947
  const extrude = opts.extrude ?? 1, pad = Math.max(opts.padding ?? 2, 2 * extrude), max = opts.maxSize ?? 4096;
3434
3948
  const sorted = [...rects].sort((a, b) => b.height - a.height || b.width - a.width || a.id.localeCompare(b.id));
@@ -3486,10 +4000,10 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3486
4000
  for (const a of doc.assets) {
3487
4001
  if (!/\.png$/i.test(a.src)) throw new Error(`atlas requires PNG assets: "${a.src}" (asset "${a.id}") is not a PNG`);
3488
4002
  let png = files2.get(a.src);
3489
- if (!png) files2.set(a.src, png = PNG.sync.read(await readFile4(join5(assetsDir, a.src))));
4003
+ if (!png) files2.set(a.src, png = PNG2.sync.read(await readFile5(join7(assetsDir, a.src))));
3490
4004
  if (a.frame) {
3491
- const [fx, fy, fw, fh] = a.frame, sub = new PNG({ width: fw, height: fh });
3492
- PNG.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
4005
+ const [fx, fy, fw, fh] = a.frame, sub = new PNG2({ width: fw, height: fh });
4006
+ PNG2.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
3493
4007
  png = sub;
3494
4008
  }
3495
4009
  if (png.width !== a.width || png.height !== a.height)
@@ -3497,26 +4011,26 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
3497
4011
  images.set(a.id, png);
3498
4012
  }
3499
4013
  const packed = packRects(doc.assets.map((a) => ({ id: a.id, width: a.width, height: a.height })), opts);
3500
- const sheet = new PNG({ width: packed.width, height: packed.height });
4014
+ const sheet = new PNG2({ width: packed.width, height: packed.height });
3501
4015
  for (const [id, [x, y, w, h]] of packed.frames) {
3502
- PNG.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
4016
+ PNG2.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
3503
4017
  extrudeFrame(sheet, x, y, w, h, opts.extrude ?? 1);
3504
4018
  }
3505
- await mkdir3(outDir, { recursive: true });
3506
- await writeFile3(join5(outDir, "atlas.png"), PNG.sync.write(sheet));
4019
+ await mkdir4(outDir, { recursive: true });
4020
+ await writeFile4(join7(outDir, "atlas.png"), PNG2.sync.write(sheet));
3507
4021
  const out = { ...doc, assets: doc.assets.map((a) => ({ ...a, src: "atlas.png", frame: packed.frames.get(a.id) })) };
3508
- await saveJson(join5(outDir, "rig.json"), out);
4022
+ await saveJson(join7(outDir, "rig.json"), out);
3509
4023
  return out;
3510
4024
  }
3511
4025
 
3512
4026
  // src/spine-import.ts
3513
- import { readFile as readFile6, stat, writeFile as writeFile4 } from "node:fs/promises";
3514
- import { basename as basename4, dirname as dirname4, extname, join as join6, relative } from "node:path";
4027
+ import { readFile as readFile7, stat, writeFile as writeFile5 } from "node:fs/promises";
4028
+ import { basename as basename4, dirname as dirname6, extname, join as join8, relative } from "node:path";
3515
4029
 
3516
4030
  // src/ktx2.ts
3517
- import { readFile as readFile5 } from "node:fs/promises";
4031
+ import { readFile as readFile6 } from "node:fs/promises";
3518
4032
  import { createRequire } from "node:module";
3519
- import { PNG as PNG2 } from "pngjs";
4033
+ import { PNG as PNG3 } from "pngjs";
3520
4034
  var require2 = createRequire(import.meta.url);
3521
4035
  var basisPromise;
3522
4036
  async function basis2() {
@@ -3528,8 +4042,8 @@ async function basis2() {
3528
4042
  } catch {
3529
4043
  throw new Error("KTX2 pages need the basis transcoder from three.js: `npm i -D three`, or convert the page to PNG yourself");
3530
4044
  }
3531
- const wasm = await readFile5(jsPath.replace(/\.js$/, ".wasm"));
3532
- const src = await readFile5(jsPath, "utf8");
4045
+ const wasm = await readFile6(jsPath.replace(/\.js$/, ".wasm"));
4046
+ const src = await readFile6(jsPath, "utf8");
3533
4047
  const mod = { exports: {} };
3534
4048
  new Function("require", "module", "exports", "__dirname", "__filename", src)(require2, mod, mod.exports, jsPath.replace(/\/[^/]+$/, ""), jsPath);
3535
4049
  const Basis = await mod.exports({ wasmBinary: wasm });
@@ -3556,9 +4070,9 @@ async function ktx2ToRgba(data) {
3556
4070
  }
3557
4071
  async function ktx2ToPng(data) {
3558
4072
  const { rgba, ...info } = await ktx2ToRgba(data);
3559
- const png = new PNG2({ width: info.width, height: info.height });
4073
+ const png = new PNG3({ width: info.width, height: info.height });
3560
4074
  png.data = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
3561
- return { ...info, png: PNG2.sync.write(png) };
4075
+ return { ...info, png: PNG3.sync.write(png) };
3562
4076
  }
3563
4077
 
3564
4078
  // src/spine-import.ts
@@ -3698,9 +4212,9 @@ function importSpine(json, atlasText, opts = {}) {
3698
4212
  };
3699
4213
  const sequences = /* @__PURE__ */ new Map();
3700
4214
  const skinnedName = /* @__PURE__ */ new Map();
3701
- for (const skin of skins) {
3702
- const prefix = skin.name === "default" ? "" : `${skin.name}_`;
3703
- for (const [slotName, atts] of Object.entries(skin.attachments ?? {}))
4215
+ for (const skin2 of skins) {
4216
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4217
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
3704
4218
  for (const [name, a] of Object.entries(atts)) {
3705
4219
  const key = `${slotName} ${name}`;
3706
4220
  const seq = a.sequence;
@@ -3765,11 +4279,11 @@ function importSpine(json, atlasText, opts = {}) {
3765
4279
  }
3766
4280
  return { weights };
3767
4281
  };
3768
- for (const skin of skins) {
3769
- const prefix = skin.name === "default" ? "" : `${skin.name}_`;
3770
- for (const [slotName, atts] of Object.entries(skin.attachments ?? {})) {
4282
+ for (const skin2 of skins) {
4283
+ const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4284
+ for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
3771
4285
  if (!slotIndexByName.has(slotName)) {
3772
- warnings.push(`skin "${skin.name}": unknown slot "${slotName}"`);
4286
+ warnings.push(`skin "${skin2.name}": unknown slot "${slotName}"`);
3773
4287
  continue;
3774
4288
  }
3775
4289
  const slot = slotIds.get(slotName);
@@ -4100,9 +4614,9 @@ function importSpine(json, atlasText, opts = {}) {
4100
4614
  const attFrames = attachmentFrames.get(slotName);
4101
4615
  const setupName = spineSlots.find((x) => x.name === slotName)?.attachment ?? null;
4102
4616
  const at = (xs, t) => {
4103
- let hit;
4104
- for (const x of xs) if (x.t <= t + 1e-9) hit = x;
4105
- return hit;
4617
+ let hit2;
4618
+ for (const x of xs) if (x.t <= t + 1e-9) hit2 = x;
4619
+ return hit2;
4106
4620
  };
4107
4621
  const shownAt = (t) => {
4108
4622
  let name = setupName;
@@ -4208,21 +4722,21 @@ async function firstExisting(candidates, what) {
4208
4722
  throw new Error(`${what} not found, tried: ${candidates.join(", ")}`);
4209
4723
  }
4210
4724
  async function importSpineProject(opts) {
4211
- const json = JSON.parse(await readFile6(opts.json, "utf8"));
4725
+ const json = JSON.parse(await readFile7(opts.json, "utf8"));
4212
4726
  const stem = opts.json.replace(/\.json$/i, "");
4213
4727
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4214
- const atlasText = await readFile6(atlasPath, "utf8");
4728
+ const atlasText = await readFile7(atlasPath, "utf8");
4215
4729
  const { pages } = parseAtlas(atlasText);
4216
4730
  if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
4217
- const pageStem = join6(dirname4(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4218
- const imagePath = opts.image ?? await firstExisting([join6(dirname4(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4219
- const rigDir = dirname4(opts.rigPath);
4731
+ const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4732
+ const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4733
+ const rigDir = dirname6(opts.rigPath);
4220
4734
  let pngPath = imagePath;
4221
4735
  if (extname(imagePath).toLowerCase() === ".ktx2") {
4222
- pngPath = join6(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4736
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4223
4737
  if (!await exists(pngPath)) {
4224
- const { png } = await ktx2ToPng(new Uint8Array(await readFile6(imagePath)));
4225
- await writeFile4(pngPath, png);
4738
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4739
+ await writeFile5(pngPath, png);
4226
4740
  }
4227
4741
  }
4228
4742
  const pageImage = relative(rigDir, pngPath).split("\\").join("/");
@@ -4244,7 +4758,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4244
4758
  const tracks = [];
4245
4759
  const skipped = [];
4246
4760
  for (const t of anim.tracks) {
4247
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4761
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4248
4762
  skipped.push(trackId5(t));
4249
4763
  continue;
4250
4764
  }
@@ -4342,11 +4856,11 @@ function describeTemplate(t) {
4342
4856
  }
4343
4857
 
4344
4858
  // src/rig-template-library.ts
4345
- import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir4 } from "node:fs/promises";
4346
- import { dirname as dirname5 } from "node:path";
4859
+ import { readFile as readFile8, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
4860
+ import { dirname as dirname7 } from "node:path";
4347
4861
  async function loadLibrary(path) {
4348
4862
  try {
4349
- return JSON.parse(await readFile7(path, "utf8"));
4863
+ return JSON.parse(await readFile8(path, "utf8"));
4350
4864
  } catch (e) {
4351
4865
  if (e.code === "ENOENT") return [];
4352
4866
  throw e;
@@ -4357,14 +4871,14 @@ async function saveTemplate(path, tpl) {
4357
4871
  const i = all.findIndex((t) => t.name === tpl.name);
4358
4872
  if (i >= 0) all[i] = tpl;
4359
4873
  else all.push(tpl);
4360
- await mkdir4(dirname5(path), { recursive: true });
4361
- await writeFile5(path, JSON.stringify(all, null, 2));
4874
+ await mkdir5(dirname7(path), { recursive: true });
4875
+ await writeFile6(path, JSON.stringify(all, null, 2));
4362
4876
  }
4363
4877
 
4364
4878
  // src/rig-mesh-image.ts
4365
- import { PNG as PNG3 } from "pngjs";
4879
+ import { PNG as PNG4 } from "pngjs";
4366
4880
  function alphaMaskFromPng(buf, frame) {
4367
- const png = PNG3.sync.read(buf);
4881
+ const png = PNG4.sync.read(buf);
4368
4882
  const [fx, fy, w, h] = frame ?? [0, 0, png.width, png.height];
4369
4883
  if (fx < 0 || fy < 0 || w < 1 || h < 1 || fx + w > png.width || fy + h > png.height) {
4370
4884
  throw new Error(`frame [${fx}, ${fy}, ${w}, ${h}] is outside the ${png.width}x${png.height} image`);
@@ -4374,11 +4888,526 @@ function alphaMaskFromPng(buf, frame) {
4374
4888
  return { width: w, height: h, alpha };
4375
4889
  }
4376
4890
 
4891
+ // src/rig-gates.ts
4892
+ import { z as z2 } from "zod";
4893
+
4894
+ // src/rig-state.ts
4895
+ var AnimationState = class {
4896
+ constructor(sink, opts = {}) {
4897
+ this.sink = sink;
4898
+ this.opts = opts;
4899
+ this.defaultMix = opts.mix ?? 0;
4900
+ }
4901
+ onEvent;
4902
+ base;
4903
+ main;
4904
+ queue = [];
4905
+ overlays = /* @__PURE__ */ new Map();
4906
+ /** last composed pose without overlays — mix snapshots start here */
4907
+ pose = /* @__PURE__ */ new Map();
4908
+ from;
4909
+ mixLeft = 0;
4910
+ mixTotal = 0;
4911
+ /** a mix begun in this update shows weight 0 for the frame it starts on */
4912
+ mixFresh = false;
4913
+ defaultMix;
4914
+ /** id of the animation currently shown (main, else base) */
4915
+ get current() {
4916
+ return (this.main ?? this.base)?.anim.id;
4917
+ }
4918
+ get baseId() {
4919
+ return this.base?.anim.id;
4920
+ }
4921
+ channel(id, loop, priority = 0) {
4922
+ const anim = this.sink.doc.animations.find((a) => a.id === id);
4923
+ if (!anim) {
4924
+ const message = `unknown animation "${id}"; available: ${this.sink.doc.animations.map((a) => a.id).join(", ")}`;
4925
+ if (this.opts.missingAnimation === "throw") throw new Error(message);
4926
+ (this.opts.onWarning ?? console.warn)(message);
4927
+ return void 0;
4928
+ }
4929
+ return { anim, time: 0, loop: loop ?? anim.loop, done: false, priority, fresh: true };
4930
+ }
4931
+ setBase(id, opts = {}) {
4932
+ const next = id === void 0 ? void 0 : this.channel(id);
4933
+ if (id !== void 0 && !next) return;
4934
+ this.base = next;
4935
+ if (!this.main || this.main.done) {
4936
+ this.main = void 0;
4937
+ this.beginMix(opts.mix);
4938
+ this.update(0);
4939
+ }
4940
+ }
4941
+ play(id, opts = {}) {
4942
+ const ch = this.channel(id, opts.loop, opts.priority ?? 0);
4943
+ if (!ch) {
4944
+ this.main?.settle?.("interrupted");
4945
+ for (const queued of this.queue) queued.settle?.("dropped");
4946
+ this.main = void 0;
4947
+ this.queue = [];
4948
+ this.beginMix(opts.mix);
4949
+ this.update(0);
4950
+ opts.onComplete?.("dropped");
4951
+ return Promise.resolve("dropped");
4952
+ }
4953
+ const promise = new Promise((resolve4) => {
4954
+ ch.settle = (r) => {
4955
+ ch.settle = void 0;
4956
+ opts.onComplete?.(r);
4957
+ resolve4(r);
4958
+ };
4959
+ });
4960
+ const cur = this.main, busy = cur !== void 0 && !cur.done, mode = opts.ifBusy ?? "queue";
4961
+ if (!busy || mode === "interrupt" || ch.priority >= cur.priority) {
4962
+ if (busy) cur.settle?.("interrupted");
4963
+ this.main = ch;
4964
+ this.beginMix(opts.mix);
4965
+ this.update(0);
4966
+ } else if (mode === "drop") ch.settle("dropped");
4967
+ else this.queue.push(ch);
4968
+ return promise;
4969
+ }
4970
+ /** Independent loop on top of the shown pose; `undefined` removes it. */
4971
+ setOverlay(name, id) {
4972
+ if (id === void 0) this.overlays.delete(name);
4973
+ else {
4974
+ const ch = this.channel(id);
4975
+ if (ch) this.overlays.set(name, ch);
4976
+ }
4977
+ }
4978
+ /** Clear everything and show the setup pose. Pending plays resolve as "stopped". */
4979
+ stop() {
4980
+ this.main?.settle?.("stopped");
4981
+ for (const ch of this.queue) ch.settle?.("stopped");
4982
+ this.main = this.base = void 0;
4983
+ this.queue = [];
4984
+ this.overlays.clear();
4985
+ this.from = void 0;
4986
+ this.mixLeft = this.mixTotal = 0;
4987
+ this.mixFresh = false;
4988
+ this.pose = /* @__PURE__ */ new Map();
4989
+ this.sink.setPose(/* @__PURE__ */ new Map());
4990
+ }
4991
+ update(dt) {
4992
+ if (this.base) this.advance(this.base, dt, this.main === void 0);
4993
+ if (this.main) {
4994
+ const wasDone = this.main.done;
4995
+ this.advance(this.main, dt, true);
4996
+ if (this.main.done && !wasDone) {
4997
+ this.main.settle?.("completed");
4998
+ const next = this.queue.shift();
4999
+ if (next || this.base) {
5000
+ this.main = next;
5001
+ this.beginMix();
5002
+ }
5003
+ }
5004
+ }
5005
+ const shown = this.main ?? this.base;
5006
+ let pose = shown ? poseOf(shown.anim, shown.time) : /* @__PURE__ */ new Map();
5007
+ if (this.from) {
5008
+ if (!this.mixFresh) this.mixLeft = Math.max(0, this.mixLeft - dt);
5009
+ this.mixFresh = false;
5010
+ const w = this.mixTotal > 0 ? 1 - this.mixLeft / this.mixTotal : 1;
5011
+ pose = blendPoses(this.from, pose, w, (k) => restValue(this.sink.doc, k));
5012
+ if (this.mixLeft === 0) this.from = void 0;
5013
+ }
5014
+ this.pose = pose;
5015
+ if (this.overlays.size) {
5016
+ pose = new Map(pose);
5017
+ for (const [name, ov] of this.overlays) {
5018
+ this.advance(ov, dt, false);
5019
+ poseOf(ov.anim, ov.time, pose);
5020
+ if (ov.done) this.overlays.delete(name);
5021
+ }
5022
+ }
5023
+ this.sink.setPose(pose);
5024
+ }
5025
+ beginMix(mix = this.defaultMix) {
5026
+ if (mix > 0) {
5027
+ this.from = new Map(this.pose);
5028
+ this.mixLeft = this.mixTotal = mix;
5029
+ this.mixFresh = true;
5030
+ } else {
5031
+ this.from = void 0;
5032
+ this.mixLeft = this.mixTotal = 0;
5033
+ this.mixFresh = false;
5034
+ }
5035
+ }
5036
+ advance(ch, dt, emit) {
5037
+ if (ch.done) return;
5038
+ const prev = ch.fresh ? -1 : ch.time, d = ch.anim.duration;
5039
+ ch.fresh = false;
5040
+ ch.time += dt;
5041
+ if (emit) {
5042
+ for (const e of ch.anim.events) if (hit(e.t, prev, ch.time, d, ch.loop)) this.onEvent?.(e.name, e.payload);
5043
+ }
5044
+ if (ch.time >= d) {
5045
+ if (ch.loop) ch.time %= d;
5046
+ else {
5047
+ ch.time = d;
5048
+ ch.done = true;
5049
+ }
5050
+ }
5051
+ }
5052
+ };
5053
+ function hit(t, prev, cur, d, loop) {
5054
+ if (cur < d || !loop) return t > prev && t <= cur;
5055
+ return t > prev && t <= d || t <= cur % d;
5056
+ }
5057
+
5058
+ // src/rig-gates.ts
5059
+ var Limit = z2.number().nonnegative().optional();
5060
+ var Window = z2.tuple([z2.number().nonnegative(), z2.number().nonnegative()]).optional();
5061
+ var GateConfig = z2.object({
5062
+ animation: z2.string(),
5063
+ fps: z2.number().int().min(1).max(240).default(60),
5064
+ requiredAnimations: z2.array(z2.string()).default([]),
5065
+ 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([]),
5066
+ distances: z2.array(z2.object({ a: z2.string(), b: z2.string(), min: Limit, max: Limit, minRatio: Limit, maxRatio: Limit, during: Window })).default([]),
5067
+ 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([]),
5068
+ 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([])
5069
+ }).strict();
5070
+ var norm = (v) => Math.hypot(...v);
5071
+ var subtract = (a, b) => a.map((v, i) => v - b[i]);
5072
+ var wrap = (v) => ((v + 180) % 360 + 360) % 360 - 180;
5073
+ function runGates(doc, config) {
5074
+ const cfg = GateConfig.parse(config), animation = doc.animations.find((a) => a.id === cfg.animation);
5075
+ if (!animation) throw new Error(`unknown animation "${cfg.animation}"`);
5076
+ 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");
5077
+ const failures = [];
5078
+ const metrics = {};
5079
+ for (const id of cfg.requiredAnimations) if (!doc.animations.some((a) => a.id === id)) failures.push(`required animation missing: ${id}`);
5080
+ const n = Math.max(1, Math.ceil(animation.duration * cfg.fps)), dt = animation.duration / n;
5081
+ const frames = Array.from({ length: n + 1 }, (_, i) => {
5082
+ const t = i * dt, pose = poseOf(animation, t);
5083
+ return { t, pose, skel: evaluate(doc, pose) };
5084
+ });
5085
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5086
+ const bone = (s2, id, channel, space) => {
5087
+ const b = s2.bones.get(id);
5088
+ if (!b) throw new Error(`unknown gate bone "${id}"`);
5089
+ 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];
5090
+ };
5091
+ const inside = (t, w) => !w || t >= w[0] - 1e-9 && t <= w[1] + 1e-9;
5092
+ const windowCheck = (w) => {
5093
+ if (w && (w[0] > w[1] || w[1] > animation.duration)) throw new Error("invalid gate time window");
5094
+ };
5095
+ for (const rule of cfg.motion) {
5096
+ 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");
5097
+ windowCheck(rule.during);
5098
+ const fs = frames.filter((f) => inside(f.t, rule.during));
5099
+ if (fs.length < 4) throw new Error(`motion gate ${rule.bone} needs at least four samples`);
5100
+ const vs = fs.map((f) => bone(f.skel, rule.bone, rule.channel, rule.space));
5101
+ 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]);
5102
+ const derivative = (xs) => xs.slice(1).map((v, i) => subtract(v, xs[i]).map((v2) => v2 / dt));
5103
+ const velocity = derivative(vs), acceleration = derivative(velocity), jerk = derivative(acceleration);
5104
+ const range = vs[0].map((_, i) => Math.max(...vs.map((v) => v[i])) - Math.min(...vs.map((v) => v[i])));
5105
+ const setup = bone(rest, rule.bone, rule.channel, rule.space);
5106
+ 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)))) };
5107
+ const id = `${rule.bone}.${rule.channel}.${rule.space}`;
5108
+ metrics[id] = m;
5109
+ if (rule.minAmplitude !== void 0 && m.amplitude < rule.minAmplitude) failures.push(`${id}: amplitude ${m.amplitude} < ${rule.minAmplitude}`);
5110
+ for (const [limit, key] of [["maxSpeed", "speed"], ["maxAcceleration", "acceleration"], ["maxJerk", "jerk"], ["maxStep", "step"], ["maxSetupDrift", "setupDrift"]])
5111
+ if (rule[limit] !== void 0 && m[key] > rule[limit]) failures.push(`${id}: ${key} ${m[key]} > ${rule[limit]}`);
5112
+ }
5113
+ for (const rule of cfg.distances) {
5114
+ if ([rule.min, rule.max, rule.minRatio, rule.maxRatio].every((v) => v === void 0)) throw new Error("distance gate needs an explicit threshold");
5115
+ windowCheck(rule.during);
5116
+ const fs = frames.filter((f) => inside(f.t, rule.during));
5117
+ if (!fs.length) throw new Error("distance gate has no samples");
5118
+ const distance = (s2) => norm(subtract(bone(s2, rule.a, "position", "world"), bone(s2, rule.b, "position", "world")));
5119
+ const setup = distance(rest), values2 = fs.map((f) => distance(f.skel)), lo = Math.min(...values2), hi = Math.max(...values2);
5120
+ metrics[`${rule.a}-${rule.b}`] = { samples: fs.length, min: lo, max: hi, setup };
5121
+ if ((rule.minRatio !== void 0 || rule.maxRatio !== void 0) && setup < 1e-9) throw new Error("distance ratio needs noncoincident setup landmarks");
5122
+ 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`);
5123
+ }
5124
+ for (const rule of cfg.conditions) {
5125
+ if (rule.min === void 0 && rule.max === void 0) throw new Error("condition gate needs an explicit threshold");
5126
+ const slot = doc.slots.find((s2) => s2.id === rule.whenSlotVisible);
5127
+ if (!slot) throw new Error(`unknown gate slot "${rule.whenSlotVisible}"`);
5128
+ const ts = new Set(frames.map((f) => f.t));
5129
+ for (const tr of animation.tracks) for (const k of tr.keys) {
5130
+ ts.add(k.t);
5131
+ if (k.t > 0) ts.add(Math.max(0, k.t - 1e-6));
5132
+ }
5133
+ let active2 = 0, bad = 0;
5134
+ for (const t of ts) {
5135
+ const pose = poseOf(animation, t), s2 = evaluate(doc, pose);
5136
+ const color = s2.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5137
+ const alpha = Number(s2.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
5138
+ if (!s2.attachment.get(slot.id) || alpha <= 0.01) continue;
5139
+ active2++;
5140
+ const v = bone(s2, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
5141
+ if (rule.min !== void 0 && v < rule.min || rule.max !== void 0 && v > rule.max) bad++;
5142
+ }
5143
+ metrics[`condition:${slot.id}:${rule.bone}.${rule.prop}`] = { active: active2, bad };
5144
+ if (bad || !active2 && rule.required) failures.push(`${slot.id} condition: ${bad} invalid visible samples, ${active2} active samples`);
5145
+ }
5146
+ for (const tr of cfg.transitions) {
5147
+ for (const id of [tr.from, tr.to]) if (!doc.animations.some((a) => a.id === id)) throw new Error(`missing transition animation ${id}`);
5148
+ const sink = { doc, pose: /* @__PURE__ */ new Map(), setPose(p) {
5149
+ this.pose = p;
5150
+ } }, state = new AnimationState(sink, { mix: tr.mix, missingAnimation: "throw" });
5151
+ state.setBase(tr.from, { mix: 0 });
5152
+ state.update(tr.fromTime);
5153
+ let previous = evaluate(doc, sink.pose), previousPose = sink.pose, maxStep = 0;
5154
+ state.play(tr.to);
5155
+ const measure = () => {
5156
+ const next = evaluate(doc, sink.pose);
5157
+ for (const b of doc.bones) {
5158
+ const a = previous.bones.get(b.id).world, c = next.bones.get(b.id).world;
5159
+ const p = apply(a, b.length, 0), q = apply(c, b.length, 0);
5160
+ maxStep = Math.max(maxStep, Math.hypot(c.tx - a.tx, c.ty - a.ty), Math.hypot(q[0] - p[0], q[1] - p[1]));
5161
+ }
5162
+ for (const s2 of doc.slots) {
5163
+ if (previous.attachment.get(s2.id)?.id !== next.attachment.get(s2.id)?.id) continue;
5164
+ const before = slotOutline(doc, previousPose, s2.id, 1, previous), after = slotOutline(doc, sink.pose, s2.id, 1, next);
5165
+ 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]));
5166
+ }
5167
+ previous = next;
5168
+ previousPose = sink.pose;
5169
+ };
5170
+ measure();
5171
+ const count = Math.ceil(tr.duration * cfg.fps);
5172
+ for (let i = 0; i < count; i++) {
5173
+ state.update(1 / cfg.fps);
5174
+ measure();
5175
+ }
5176
+ state.stop();
5177
+ metrics[`transition:${tr.from}->${tr.to}`] = { samples: count + 1, maxStep, mix: tr.mix };
5178
+ if (maxStep > tr.maxStep) failures.push(`transition ${tr.from}->${tr.to}: step ${maxStep} > ${tr.maxStep}`);
5179
+ }
5180
+ return { pass: failures.length === 0, animation: animation.id, fps: cfg.fps, interval: dt, expected: n + 1, inspected: frames.length, metrics, failures };
5181
+ }
5182
+
5183
+ // src/rig-api.ts
5184
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5185
+
5186
+ // src/rig-motion.ts
5187
+ var wrap2 = (x) => ((x + 180) % 360 + 360) % 360 - 180;
5188
+ var mesh = (doc, id) => {
5189
+ const a = doc.attachments.find((a2) => a2.id === id);
5190
+ if (a?.type !== "mesh") throw new Error(`expected mesh "${id}"`);
5191
+ return a;
5192
+ };
5193
+ function skin(doc, a, weights) {
5194
+ const skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5195
+ return weights.map((list, i) => {
5196
+ const sum = [...list.values()].reduce((a2, b) => a2 + b, 0);
5197
+ if (!(sum > 0)) throw new Error(`vertex ${i}: weights sum to zero`);
5198
+ return [...list].filter(([, w]) => w > 1e-9).map(([bone, w]) => {
5199
+ const b = skel.bones.get(bone);
5200
+ if (!b) throw new Error(`unknown weight bone "${bone}"`);
5201
+ const [x, y] = apply(invert(b.world), world[i * 2], world[i * 2 + 1]);
5202
+ return { bone, x, y, w: w / sum };
5203
+ });
5204
+ });
5205
+ }
5206
+ function gradientWeights(doc, id, bones) {
5207
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map());
5208
+ if (bones.length < 2 || new Set(bones).size !== bones.length) throw new Error("gradient needs at least two distinct bones");
5209
+ const points = bones.map((id2) => {
5210
+ const b = skel.bones.get(id2);
5211
+ if (!b) throw new Error(`unknown bone "${id2}"`);
5212
+ return [b.world.tx, b.world.ty];
5213
+ });
5214
+ const world = attachmentWorldVertices(skel, a.slot, a), weights = [];
5215
+ for (let v = 0; v < world.length; v += 2) {
5216
+ let best = Infinity, segment = 0, u = 0;
5217
+ for (let i = 0; i < points.length - 1; i++) {
5218
+ const [x, y] = points[i], dx = points[i + 1][0] - x, dy = points[i + 1][1] - y, l2 = dx * dx + dy * dy;
5219
+ if (l2 < 1e-12) throw new Error("gradient chain contains coincident joints");
5220
+ const t = Math.max(0, Math.min(1, ((world[v] - x) * dx + (world[v + 1] - y) * dy) / l2));
5221
+ const d = (world[v] - x - t * dx) ** 2 + (world[v + 1] - y - t * dy) ** 2;
5222
+ if (d < best) {
5223
+ best = d;
5224
+ segment = i;
5225
+ u = t;
5226
+ }
5227
+ }
5228
+ weights.push(/* @__PURE__ */ new Map([[bones[segment], 1 - u], [bones[segment + 1], u]]));
5229
+ }
5230
+ return setWeights(doc, id, skin(doc, a, weights)).doc;
5231
+ }
5232
+ function smoothWeights(doc, id, iterations = 3, strength = 0.5, locked = []) {
5233
+ const a = mesh(doc, id);
5234
+ if (!a.weights) throw new Error("skin the mesh before smoothing weights");
5235
+ if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100 || strength < 0 || strength > 1) throw new Error("invalid smoothing options");
5236
+ const fixed = new Set(locked), neighbours = a.weights.map(() => /* @__PURE__ */ new Set());
5237
+ for (const v of fixed) if (!Number.isInteger(v) || v < 0 || v >= neighbours.length) throw new Error(`invalid locked vertex ${v}`);
5238
+ 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);
5239
+ let w = a.weights.map((list) => new Map(list.map((i) => [i.bone, i.w])));
5240
+ for (let iter = 0; iter < iterations; iter++) w = w.map((own, i) => {
5241
+ if (fixed.has(i) || !neighbours[i].size) return own;
5242
+ const next = new Map([...own].map(([b, v]) => [b, v * (1 - strength)]));
5243
+ 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);
5244
+ return next;
5245
+ });
5246
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5247
+ }
5248
+ function pinWeights(doc, id, bone, vertices, region) {
5249
+ const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
5250
+ if (!skel.bones.has(bone)) throw new Error(`unknown bone "${bone}"`);
5251
+ if (!vertices?.length && !region) throw new Error("select vertices or a document-space region");
5252
+ const selected = new Set(vertices);
5253
+ for (const v of selected) if (!Number.isInteger(v) || v < 0 || v >= world.length / 2) throw new Error(`invalid vertex ${v}`);
5254
+ const host = doc.slots.find((s2) => s2.id === a.slot).bone;
5255
+ let count = 0;
5256
+ const w = Array.from({ length: world.length / 2 }, (_, i) => {
5257
+ const x = world[i * 2], y = world[i * 2 + 1];
5258
+ const inside = region && x >= region[0] && y >= region[1] && x <= region[0] + region[2] && y <= region[1] + region[3];
5259
+ if (selected.has(i) || inside) {
5260
+ count++;
5261
+ return /* @__PURE__ */ new Map([[bone, 1]]);
5262
+ }
5263
+ return new Map(a.weights?.[i].map((inf) => [inf.bone, inf.w]) ?? [[host, 1]]);
5264
+ });
5265
+ if (!count) throw new Error("selection contains no mesh vertices");
5266
+ return setWeights(doc, id, skin(doc, a, w)).doc;
5267
+ }
5268
+ function attachToBone(doc, animation, bone, target, t) {
5269
+ const anim = doc.animations.find((a) => a.id === animation);
5270
+ if (!anim || t < 0 || t > anim.duration) throw new Error("attach requires an existing animation and time within its duration");
5271
+ const skel = evaluate(doc, poseOf(anim, t)), from = skel.bones.get(bone)?.world, to = skel.bones.get(target)?.world;
5272
+ if (!from || !to || bone === target) throw new Error("attach needs distinct existing bones");
5273
+ 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");
5274
+ if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5275
+ const [x, y] = apply(invert(to), from.tx, from.ty);
5276
+ const id = `attach_${bone}_${target}`;
5277
+ const offset = {
5278
+ x,
5279
+ y,
5280
+ rotation: wrap2(rotationOf(from) - rotationOf(to)),
5281
+ scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5282
+ scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5283
+ 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)
5284
+ };
5285
+ const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5286
+ let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
5287
+ for (const c of next.transform.filter((c2) => c2.id.startsWith(`attach_${bone}_`) && c2.bones.length === 1 && c2.bones[0] === bone)) {
5288
+ for (const prop of ["mixX", "mixY", "mixRotate", "mixScaleX", "mixScaleY", "mixShearY"]) {
5289
+ const has = anim.tracks.some((tr) => tr.target === "transform" && tr.id === c.id && tr.prop === prop);
5290
+ const keys = [...!has && t > 0 ? [{ t: 0, v: 0, ease: "step" }] : [], { t, v: c.id === id ? 1 : 0, ease: "step" }];
5291
+ next = setKeys(next, animation, { target: "transform", id: c.id, prop }, keys, void 0, { merge: true });
5292
+ }
5293
+ }
5294
+ for (const [prop, v] of Object.entries(offset)) next = setKeys(next, animation, { target: "transform", id, prop }, [{ t, v, ease: "step" }], void 0, { merge: true });
5295
+ return next;
5296
+ }
5297
+ function bakeSecondary(doc, animation, opts) {
5298
+ const anim = doc.animations.find((a) => a.id === animation);
5299
+ if (!anim) throw new Error(`unknown animation "${animation}"`);
5300
+ const fps = opts.fps ?? 60, hz = opts.frequency ?? 2.5, damping = opts.damping ?? 0.65, gain = opts.gain ?? 0.2, maxAngle = opts.maxAngle ?? 30;
5301
+ if (fps <= 0 || hz <= 0 || damping <= 0 || maxAngle <= 0 || !opts.bones.length) throw new Error("invalid spring parameters");
5302
+ 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");
5303
+ if (new Set(opts.bones).size !== opts.bones.length) throw new Error("secondary bones must be distinct");
5304
+ for (let b = doc.bones.find((b2) => b2.id === opts.driver); b; b = doc.bones.find((x) => x.id === b.parent)) {
5305
+ if (opts.bones.includes(b.id)) throw new Error("secondary bones cannot be ancestors of the driver");
5306
+ }
5307
+ const n = Math.max(2, Math.ceil(anim.duration * fps)), dt = anim.duration / n;
5308
+ const base = { ...anim, tracks: anim.tracks.filter((t) => !(t.target === "bone" && opts.bones.includes(t.id) && t.prop === "rotation")) };
5309
+ const angular = (opts.driverProp ?? "rotation") === "rotation";
5310
+ const angles = Array.from({ length: n + 1 }, (_, i) => {
5311
+ const world = evaluate(doc, poseOf(base, dt * i)).bones.get(opts.driver).world;
5312
+ return angular ? rotationOf(world) : opts.driverProp === "x" ? world.tx : world.ty;
5313
+ });
5314
+ const seam = angles[n] - angles[0];
5315
+ 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");
5316
+ let next = doc;
5317
+ for (let bi = 0; bi < opts.bones.length; bi++) {
5318
+ const bone = doc.bones.find((b) => b.id === opts.bones[bi]);
5319
+ if (!bone) throw new Error(`unknown secondary bone "${opts.bones[bi]}"`);
5320
+ 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;
5321
+ let x = 0, velocity = 0, samples = [];
5322
+ const cycle = () => {
5323
+ samples = [x];
5324
+ for (let i = 1; i <= n; i++) {
5325
+ const difference = angles[i] - angles[i - 1];
5326
+ const drive = (angular ? wrap2(difference) : difference) / dt;
5327
+ for (let j2 = 0; j2 < sub; j2++) {
5328
+ velocity += (-omega * omega * x - 2 * damping * omega * velocity - gain * omega * drive) * h;
5329
+ x += velocity * h;
5330
+ if (Math.abs(x) > maxAngle) {
5331
+ x = Math.sign(x) * maxAngle;
5332
+ velocity = 0;
5333
+ }
5334
+ }
5335
+ samples.push(x);
5336
+ }
5337
+ };
5338
+ if (anim.loop) {
5339
+ let converged = false;
5340
+ for (let i = 0; i < 200; i++) {
5341
+ const oldX = x, oldV = velocity;
5342
+ cycle();
5343
+ if (Math.abs(x - oldX) < 1e-7 && Math.abs(velocity - oldV) < 1e-7) {
5344
+ converged = true;
5345
+ break;
5346
+ }
5347
+ }
5348
+ if (!converged) throw new Error("spring loop did not converge; increase damping or duration");
5349
+ samples[n] = samples[0];
5350
+ } else cycle();
5351
+ const origin = anim.loop ? samples[0] : 0;
5352
+ const amplitude = Math.max(...samples.map((v) => Math.abs(v - origin)));
5353
+ const scale = Math.min(1, maxAngle / Math.max(amplitude, 1e-12));
5354
+ 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);
5355
+ }
5356
+ return next;
5357
+ }
5358
+
5359
+ // src/rig-control-qa.ts
5360
+ function snapshot(doc, values2) {
5361
+ const skel = evaluate(doc, controlPose(doc, values2));
5362
+ return doc.slots.flatMap((slot) => {
5363
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5364
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5365
+ return points ? [{ id: slot.id, alpha, points }] : [];
5366
+ });
5367
+ }
5368
+ function checkControlCases(doc, cases) {
5369
+ const errors = [];
5370
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5371
+ cases.forEach((c, index) => {
5372
+ const skel = evaluate(doc, controlPose(doc, c.values));
5373
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5374
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5375
+ for (const att of doc.attachments) if (att.type === "mesh") {
5376
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5377
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5378
+ 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))
5379
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5380
+ }
5381
+ });
5382
+ const neutral = JSON.stringify(snapshot(doc, {}));
5383
+ const effects = (doc.controls ?? []).map((c) => ({
5384
+ id: c.id,
5385
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5386
+ }));
5387
+ return {
5388
+ passed: errors.length === 0,
5389
+ errors,
5390
+ effects,
5391
+ 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."
5392
+ };
5393
+ }
5394
+ function makeControlSweep(doc, probes) {
5395
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5396
+ const animation = {
5397
+ id: "control_sweep",
5398
+ duration: Math.max(1, cases.length - 1),
5399
+ loop: false,
5400
+ events: [],
5401
+ 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" })) }))
5402
+ };
5403
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5404
+ }
5405
+
4377
5406
  // src/rig-api.ts
4378
5407
  var tools = [];
4379
5408
  function tool(name, description, input, run) {
4380
5409
  const handler = run;
4381
- tools.push({ name, description, input: z2.object(input).strict(), run: (args2) => typeof args2.path === "string" ? withRigQueue(args2.path, () => handler(args2)) : handler(args2) });
5410
+ tools.push({ name, description, input: z3.object(input).strict(), run: (args2) => typeof args2.path === "string" ? withRigQueue(args2.path, () => handler(args2)) : handler(args2) });
4382
5411
  }
4383
5412
  async function callTool(name, args2) {
4384
5413
  const t = tools.find((t2) => t2.name === name);
@@ -4394,24 +5423,24 @@ async function callTool(name, args2) {
4394
5423
  var closeTools = () => closePreview();
4395
5424
  var text = (s2, doc) => ({ text: s2, doc });
4396
5425
  var fail = (e) => ({ text: `ERROR: ${e instanceof Error ? e.message : String(e)}`, isError: true });
4397
- var Path = z2.string().describe("path to rig.json; assets are resolved relative to its directory");
4398
- var Library = z2.string().optional().describe("template library JSON; default presets.json next to rig.json");
5426
+ var Path = z3.string().describe("path to rig.json; assets are resolved relative to its directory");
5427
+ var Library = z3.string().optional().describe("template library JSON; default presets.json next to rig.json");
4399
5428
  async function load(path) {
4400
- return RigDocument.parse(JSON.parse(await readFile8(path, "utf8")));
5429
+ return RigDocument.parse(JSON.parse(await readFile9(path, "utf8")));
4401
5430
  }
4402
5431
  var save = (path, doc) => saveJson(path, doc);
4403
5432
  var exists2 = (p) => stat2(p).then(() => true, () => false);
4404
- var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join7(dirname6(rigPath), p);
4405
- var srcFor = (rigPath, file) => relative2(dirname6(rigPath), file).split("\\").join("/");
4406
- var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join7(dirname6(rigPath), "presets.json");
5433
+ var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join9(dirname8(rigPath), p);
5434
+ var srcFor = (rigPath, file) => relative2(dirname8(rigPath), file).split("\\").join("/");
5435
+ var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join9(dirname8(rigPath), "presets.json");
4407
5436
  async function pngInfo(rigPath, file) {
4408
5437
  const abs = resolveFrom(rigPath, file);
4409
- const [width, height] = pngSize(await readFile8(abs));
5438
+ const [width, height] = pngSize(await readFile9(abs));
4410
5439
  return { abs, width, height, src: srcFor(rigPath, abs) };
4411
5440
  }
4412
5441
  async function mutate(path, op, summary = false) {
4413
5442
  try {
4414
- const raw = await readFile8(path, "utf8");
5443
+ const raw = await readFile9(path, "utf8");
4415
5444
  const prev = RigDocument.parse(JSON.parse(raw));
4416
5445
  const next = parse(await op(prev));
4417
5446
  await pushHistory(path, raw);
@@ -4425,7 +5454,7 @@ changed:${changes.length ? "\n " + changes.join("\n ") : " nothing"}`, next);
4425
5454
  }
4426
5455
  }
4427
5456
  async function create(path, doc) {
4428
- if (await exists2(path)) await pushHistory(path, await readFile8(path, "utf8"));
5457
+ if (await exists2(path)) await pushHistory(path, await readFile9(path, "utf8"));
4429
5458
  const saved = parse(doc);
4430
5459
  await save(path, saved);
4431
5460
  return text(describe(saved), saved);
@@ -4437,18 +5466,91 @@ function previewCheck(doc, animation) {
4437
5466
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
4438
5467
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
4439
5468
  }
4440
- var TrackRef = { target: z2.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z2.string(), prop: z2.string() };
4441
- var KeyIn = z2.object({
4442
- t: z2.number().describe("seconds"),
4443
- v: z2.union([z2.number(), z2.string(), z2.null(), z2.array(z2.number()), z2.array(z2.string())]),
4444
- 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")
5469
+ 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() };
5470
+ tool(
5471
+ "rig_set_control",
5472
+ "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.",
5473
+ { path: Path, control: RigControl },
5474
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5475
+ );
5476
+ tool(
5477
+ "rig_set_control_binding",
5478
+ "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.",
5479
+ { path: Path, binding: ControlBinding },
5480
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5481
+ );
5482
+ tool(
5483
+ "rig_remove_control_binding",
5484
+ "Remove a control binding, restoring its channels to setup/animation values.",
5485
+ { path: Path, id: z3.string() },
5486
+ async ({ path, id }) => mutate(path, (d) => {
5487
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5488
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5489
+ })
5490
+ );
5491
+ tool(
5492
+ "rig_remove_control",
5493
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5494
+ { path: Path, id: z3.string() },
5495
+ async ({ path, id }) => mutate(path, (d) => {
5496
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5497
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5498
+ 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) })) };
5499
+ })
5500
+ );
5501
+ tool(
5502
+ "rig_sweep_controls",
5503
+ "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.",
5504
+ {
5505
+ path: Path,
5506
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5507
+ output: z3.string(),
5508
+ scale: z3.number().positive().max(2).default(0.5),
5509
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5510
+ },
5511
+ async ({ path, probes, output, scale, region }) => {
5512
+ try {
5513
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5514
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5515
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5516
+ const qa = checkControlCases(d, sweep.cases);
5517
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5518
+ await mkdir6(out, { recursive: true });
5519
+ const frames = [];
5520
+ for (let i = 0; i < r.frames.length; i++) {
5521
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5522
+ await writeFile7(join9(out, file), r.frames[i]);
5523
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5524
+ }
5525
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5526
+ const report = {
5527
+ version: 1,
5528
+ sourceRig: sha256(raw),
5529
+ identity: r.identity,
5530
+ scale,
5531
+ region: region ?? null,
5532
+ ...qa,
5533
+ frames,
5534
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5535
+ };
5536
+ await saveJson(join9(out, "report.json"), report);
5537
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5538
+ } catch (e) {
5539
+ return fail(e);
5540
+ }
5541
+ }
5542
+ );
5543
+ var KeyIn = z3.object({
5544
+ t: z3.number().describe("seconds"),
5545
+ v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5546
+ 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")
4445
5547
  });
4446
5548
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
4447
5549
  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].";
4448
5550
  tool(
4449
5551
  "rig_create",
4450
5552
  "Create a new empty rig document with a root bone at bottom-center.",
4451
- { 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") },
5553
+ { 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") },
4452
5554
  async ({ path, name, width, height, notes }) => {
4453
5555
  try {
4454
5556
  return await create(path, createDoc(name, width, height, notes));
@@ -4460,7 +5562,7 @@ tool(
4460
5562
  tool(
4461
5563
  "rig_create_symbol",
4462
5564
  "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.",
4463
- { 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() },
5565
+ { 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() },
4464
5566
  async ({ path, image, name, margin, notes }) => {
4465
5567
  try {
4466
5568
  const { width, height, src } = await pngInfo(path, image);
@@ -4470,22 +5572,22 @@ tool(
4470
5572
  }
4471
5573
  }
4472
5574
  );
4473
- var ReferenceIn = z2.object({
4474
- src: z2.string().describe("PNG next to rig.json (relative path)"),
4475
- opacity: z2.number().min(0).max(1).optional(),
4476
- x: z2.number().optional(),
4477
- y: z2.number().optional(),
4478
- scale: z2.number().positive().optional()
5575
+ var ReferenceIn = z3.object({
5576
+ src: z3.string().describe("PNG next to rig.json (relative path)"),
5577
+ opacity: z3.number().min(0).max(1).optional(),
5578
+ x: z3.number().optional(),
5579
+ y: z3.number().optional(),
5580
+ scale: z3.number().positive().optional()
4479
5581
  }).strict();
4480
5582
  tool(
4481
5583
  "rig_set_meta",
4482
5584
  "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.",
4483
5585
  {
4484
5586
  path: Path,
4485
- name: z2.string().optional(),
4486
- width: z2.number().int().positive().optional(),
4487
- height: z2.number().int().positive().optional(),
4488
- notes: z2.string().optional(),
5587
+ name: z3.string().optional(),
5588
+ width: z3.number().int().positive().optional(),
5589
+ height: z3.number().int().positive().optional(),
5590
+ notes: z3.string().optional(),
4489
5591
  reference: ReferenceIn.nullable().optional()
4490
5592
  },
4491
5593
  async ({ path, ...patch }) => mutate(path, (d) => setMeta(d, patch))
@@ -4493,7 +5595,7 @@ tool(
4493
5595
  tool(
4494
5596
  "rig_get",
4495
5597
  "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.",
4496
- { path: Path, verbose: z2.boolean().default(false), full: z2.boolean().default(false) },
5598
+ { path: Path, verbose: z3.boolean().default(false), full: z3.boolean().default(false) },
4497
5599
  async ({ path, verbose, full }) => {
4498
5600
  try {
4499
5601
  const d = await load(path);
@@ -4506,7 +5608,7 @@ tool(
4506
5608
  tool(
4507
5609
  "rig_get_attachment",
4508
5610
  "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.",
4509
- { path: Path, id: z2.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
5611
+ { path: Path, id: z3.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
4510
5612
  async ({ path, id }) => {
4511
5613
  try {
4512
5614
  const d = await load(path);
@@ -4524,10 +5626,10 @@ tool(
4524
5626
  "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.",
4525
5627
  {
4526
5628
  path: Path,
4527
- animation: z2.string().optional(),
4528
- t: z2.number().min(0).optional(),
4529
- bones: z2.array(z2.string()).optional(),
4530
- slots: z2.array(z2.string()).optional()
5629
+ animation: z3.string().optional(),
5630
+ t: z3.number().min(0).optional(),
5631
+ bones: z3.array(z3.string()).optional(),
5632
+ slots: z3.array(z3.string()).optional()
4531
5633
  },
4532
5634
  async ({ path, ...opts }) => {
4533
5635
  try {
@@ -4556,11 +5658,11 @@ tool(
4556
5658
  );
4557
5659
  tool(
4558
5660
  "rig_check",
4559
- "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.",
4560
- { path: Path, animation: z2.string().optional().describe("limit to one animation (rest pose is always checked)") },
4561
- async ({ path, animation }) => {
5661
+ "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.",
5662
+ { 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() },
5663
+ async ({ path, ...opts }) => {
4562
5664
  try {
4563
- return text(summarize(checkRig(await load(path), { animation }), 100));
5665
+ return text(summarize(checkRig(await load(path), opts), 100));
4564
5666
  } catch (e) {
4565
5667
  return fail(e);
4566
5668
  }
@@ -4571,12 +5673,12 @@ tool(
4571
5673
  "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.",
4572
5674
  {
4573
5675
  path: Path,
4574
- json: z2.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
4575
- atlas: z2.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
4576
- image: z2.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
4577
- name: z2.string().optional().describe("default: the json file name"),
4578
- margin: z2.number().optional().describe("canvas margin around the skeleton bounds, px"),
4579
- notes: z2.string().optional()
5676
+ json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5677
+ atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5678
+ image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5679
+ name: z3.string().optional().describe("default: the json file name"),
5680
+ margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5681
+ notes: z3.string().optional()
4580
5682
  },
4581
5683
  async ({ path, json, atlas, image, name, margin, notes }) => {
4582
5684
  try {
@@ -4594,13 +5696,13 @@ warnings:
4594
5696
  tool(
4595
5697
  "rig_import_layers",
4596
5698
  "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.",
4597
- { 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") },
5699
+ { 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") },
4598
5700
  async ({ path, layersDir, relPrefix }) => mutate(path, (d) => importLayers(d, resolveFrom(path, layersDir), relPrefix), true)
4599
5701
  );
4600
5702
  tool(
4601
5703
  "rig_replace_asset",
4602
5704
  "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.",
4603
- { 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") },
5705
+ { 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") },
4604
5706
  async ({ path, id, file, prompt }) => mutate(path, async (d) => {
4605
5707
  const { width, height, src } = await pngInfo(path, file);
4606
5708
  return replaceAsset(d, id, { src, width, height, prompt, inpainted: true });
@@ -4609,27 +5711,27 @@ tool(
4609
5711
  tool(
4610
5712
  "rig_layer_prompt",
4611
5713
  "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.",
4612
- { path: Path, id: z2.string() },
4613
- async ({ path, id }) => {
5714
+ { path: Path, method: z3.enum(["part", "repair"]).default("part"), removed: z3.array(z3.string()).optional(), id: z3.string() },
5715
+ async ({ path, id, method, removed }) => {
4614
5716
  try {
4615
- return text(layerPrompt(await load(path), id));
5717
+ return text(layerPrompt(await load(path), id, { method, removed }));
4616
5718
  } catch (e) {
4617
5719
  return fail(e);
4618
5720
  }
4619
5721
  }
4620
5722
  );
4621
- 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");
4622
- var OriginIn = z2.object({ sourceImage: z2.string().optional(), bbox: Frame.optional(), inpainted: z2.boolean().default(false), prompt: z2.string().optional() }).strict();
5723
+ 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");
5724
+ 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();
4623
5725
  tool(
4624
5726
  "rig_set_asset",
4625
5727
  "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.",
4626
5728
  {
4627
5729
  path: Path,
4628
- id: z2.string(),
4629
- src: z2.string().optional(),
5730
+ id: z3.string(),
5731
+ src: z3.string().optional(),
4630
5732
  frame: Frame.nullable().optional().describe("null = the whole image again"),
4631
- width: z2.number().int().positive().optional(),
4632
- height: z2.number().int().positive().optional(),
5733
+ width: z3.number().int().positive().optional(),
5734
+ height: z3.number().int().positive().optional(),
4633
5735
  origin: OriginIn.optional()
4634
5736
  },
4635
5737
  async ({ path, id, src, ...patch }) => mutateWith(path, async (d, say) => {
@@ -4647,19 +5749,19 @@ tool(
4647
5749
  tool(
4648
5750
  "rig_remove_asset",
4649
5751
  "Remove an asset nothing shows any more (refused, with the list, while an attachment still uses it).",
4650
- { path: Path, id: z2.string() },
5752
+ { path: Path, id: z3.string() },
4651
5753
  async ({ path, id }) => mutate(path, (d) => removeAsset(d, id))
4652
5754
  );
4653
5755
  tool(
4654
5756
  "rig_place_asset",
4655
5757
  "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.",
4656
- { path: Path, asset: z2.string(), bone: z2.string(), x: z2.number(), y: z2.number(), slot: z2.string().optional(), z: z2.number().int().optional() },
5758
+ { path: Path, asset: z3.string(), bone: z3.string(), x: z3.number(), y: z3.number(), slot: z3.string().optional(), z: z3.number().int().optional() },
4657
5759
  async ({ path, ...p }) => mutate(path, (d) => placeAsset(d, p), true)
4658
5760
  );
4659
5761
  tool(
4660
5762
  "rig_undo",
4661
5763
  "Restore the rig as it was `steps` mutations ago (every mutation snapshots the previous file into .rig-history/ next to rig.json).",
4662
- { path: Path, steps: z2.number().int().min(1).default(1) },
5764
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4663
5765
  async ({ path, steps }) => {
4664
5766
  try {
4665
5767
  const doc = parse(JSON.parse(await undo(path, steps)));
@@ -4688,13 +5790,13 @@ tool(
4688
5790
  tool(
4689
5791
  "rig_diff",
4690
5792
  "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.",
4691
- { path: Path, steps: z2.number().int().min(1).default(1) },
5793
+ { path: Path, steps: z3.number().int().min(1).default(1) },
4692
5794
  async ({ path, steps }) => {
4693
5795
  try {
4694
5796
  const history = await listHistory(path);
4695
5797
  if (!history.length) return text("no history for this rig");
4696
5798
  if (steps > history.length) return text(`only ${history.length} snapshot${history.length === 1 ? "" : "s"} available`);
4697
- const old = RigDocument.parse(JSON.parse(await readFile8(history[history.length - steps], "utf8")));
5799
+ const old = RigDocument.parse(JSON.parse(await readFile9(history[history.length - steps], "utf8")));
4698
5800
  const lines = diffDocs(old, await load(path));
4699
5801
  return text(lines.length ? lines.join("\n") : "no differences");
4700
5802
  } catch (e) {
@@ -4702,19 +5804,19 @@ tool(
4702
5804
  }
4703
5805
  }
4704
5806
  );
4705
- 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();
5807
+ 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();
4706
5808
  var BoneFields = {
4707
- parent: z2.string().optional(),
4708
- role: z2.string().optional(),
4709
- length: z2.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
4710
- inherit: z2.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
4711
- color: z2.string().optional().describe("editor colour"),
5809
+ parent: z3.string().optional(),
5810
+ role: z3.string().optional(),
5811
+ length: z3.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
5812
+ inherit: z3.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
5813
+ color: z3.string().optional().describe("editor colour"),
4712
5814
  transform: TransformPatch.optional()
4713
5815
  };
4714
5816
  tool(
4715
5817
  "rig_add_bone",
4716
5818
  "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).",
4717
- { 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") },
5819
+ { 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") },
4718
5820
  async ({ path, bind, ...bone }) => mutateWith(path, (d, say) => {
4719
5821
  let next = addBone(d, bone);
4720
5822
  for (const s2 of bind ?? []) {
@@ -4728,13 +5830,13 @@ tool(
4728
5830
  tool(
4729
5831
  "rig_update_bone",
4730
5832
  "Patch bone fields (parent, role, length, inherit, color, transform \u2014 partial, merged). parent=null detaches the bone into a root of its own.",
4731
- { 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)") },
5833
+ { 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)") },
4732
5834
  async ({ path, id, ...patch }) => mutate(path, (d) => updateBone(d, id, patch))
4733
5835
  );
4734
5836
  tool(
4735
5837
  "rig_remove_bone",
4736
5838
  "Remove a bone; children and slots re-parent to its parent.",
4737
- { path: Path, id: z2.string() },
5839
+ { path: Path, id: z3.string() },
4738
5840
  async ({ path, id }) => mutateWith(path, (d, say) => {
4739
5841
  const parent = d.bones.find((b) => b.id === id)?.parent;
4740
5842
  const slots = d.slots.filter((s2) => s2.bone === id).map((s2) => s2.id);
@@ -4745,18 +5847,19 @@ tool(
4745
5847
  );
4746
5848
  tool(
4747
5849
  "rig_set_slot",
4748
- "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).",
5850
+ "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).",
4749
5851
  {
4750
5852
  path: Path,
4751
- id: z2.string(),
4752
- bone: z2.string().optional(),
4753
- attachment: z2.string().nullable().optional(),
4754
- z: z2.number().int().optional(),
4755
- alpha: z2.number().optional(),
4756
- blend: z2.enum(["normal", "add", "multiply", "screen"]).optional(),
4757
- color: z2.string().optional()
5853
+ id: z3.string(),
5854
+ bone: z3.string().optional(),
5855
+ clip: z3.string().nullable().optional(),
5856
+ attachment: z3.string().nullable().optional(),
5857
+ z: z3.number().int().optional(),
5858
+ alpha: z3.number().optional(),
5859
+ blend: z3.enum(["normal", "add", "multiply", "screen"]).optional(),
5860
+ color: z3.string().optional()
4758
5861
  },
4759
- async ({ path, attachment, bone, ...slot }) => mutateWith(path, (d, say) => {
5862
+ async ({ path, attachment, bone, clip, ...slot }) => mutateWith(path, (d, say) => {
4760
5863
  let next = d;
4761
5864
  const cur = d.slots.find((s2) => s2.id === slot.id);
4762
5865
  if (cur && bone !== void 0 && bone !== cur.bone) {
@@ -4764,7 +5867,7 @@ tool(
4764
5867
  next = r.doc;
4765
5868
  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)`);
4766
5869
  }
4767
- return setSlot(next, { ...slot, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
5870
+ return setSlot(next, { ...slot, ...clip === void 0 ? {} : { clip: clip ?? void 0 }, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
4768
5871
  })
4769
5872
  );
4770
5873
  tool(
@@ -4772,21 +5875,21 @@ tool(
4772
5875
  "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.",
4773
5876
  {
4774
5877
  path: Path,
4775
- id: z2.string(),
4776
- slot: z2.string().optional(),
4777
- asset: z2.string().optional(),
4778
- x: z2.number().optional(),
4779
- y: z2.number().optional(),
4780
- rotation: z2.number().optional(),
4781
- scaleX: z2.number().optional(),
4782
- scaleY: z2.number().optional(),
4783
- width: z2.number().optional(),
4784
- height: z2.number().optional()
5878
+ id: z3.string(),
5879
+ slot: z3.string().optional(),
5880
+ asset: z3.string().optional(),
5881
+ x: z3.number().optional(),
5882
+ y: z3.number().optional(),
5883
+ rotation: z3.number().optional(),
5884
+ scaleX: z3.number().optional(),
5885
+ scaleY: z3.number().optional(),
5886
+ width: z3.number().optional(),
5887
+ height: z3.number().optional()
4785
5888
  },
4786
5889
  async ({ path, ...att }) => mutate(path, (d) => setAttachment(d, att))
4787
5890
  );
4788
- 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");
4789
- 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");
5891
+ 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");
5892
+ 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");
4790
5893
  async function mutateWith(path, op, summary = false) {
4791
5894
  const lines = [];
4792
5895
  const res = await mutate(path, (d) => op(d, (l) => lines.push(l)), summary);
@@ -4801,14 +5904,15 @@ var meshOf = (doc, id) => {
4801
5904
  };
4802
5905
  tool(
4803
5906
  "rig_mesh_from_image",
4804
- "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`.",
5907
+ "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`.",
4805
5908
  {
4806
5909
  path: Path,
4807
- id: z2.string().describe("attachment id (a region)"),
4808
- spacing: z2.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
4809
- tolerance: z2.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
4810
- margin: z2.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
4811
- threshold: z2.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
5910
+ id: z3.string().describe("attachment id (a region)"),
5911
+ spacing: z3.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
5912
+ tolerance: z3.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
5913
+ margin: z3.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
5914
+ minIslandRatio: z3.number().min(0).max(1).default(0.05).describe("minimum island area relative to largest; 0 preserves small intentional fragments"),
5915
+ threshold: z3.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
4812
5916
  },
4813
5917
  async ({ path, id, ...opts }) => mutateWith(path, async (d, say) => {
4814
5918
  const att = d.attachments.find((a) => a.id === id);
@@ -4817,15 +5921,15 @@ tool(
4817
5921
  const asset = d.assets.find((a) => a.id === att.asset);
4818
5922
  if (!asset) throw new Error(`unknown asset "${att.asset}"`);
4819
5923
  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`);
4820
- const png = await readFile8(resolveFrom(path, asset.src)).catch((e) => {
5924
+ const png = await readFile9(resolveFrom(path, asset.src)).catch((e) => {
4821
5925
  throw new Error(`asset "${asset.id}": cannot read ${asset.src} (${e.code ?? e.message})`);
4822
5926
  });
4823
5927
  const { spacing, ...trace } = opts;
4824
5928
  const mask = alphaMaskFromPng(png, asset.frame);
4825
5929
  const islands = traceHulls(mask, trace);
4826
- const mesh = meshFromIslands(islands, att, asset, mask, spacing);
4827
- const { doc, droppedTracks } = convertToMesh(d, mesh);
4828
- say(`${id}=mesh(${mesh.uvs.length / 2}v, ${mesh.triangles.length / 3}t, hull ${mesh.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
5930
+ const mesh2 = meshFromIslands(islands, att, asset, mask, spacing);
5931
+ const { doc, droppedTracks } = convertToMesh(d, mesh2);
5932
+ say(`${id}=mesh(${mesh2.uvs.length / 2}v, ${mesh2.triangles.length / 3}t, hull ${mesh2.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
4829
5933
  if (droppedTracks.length) say(`dropped lattice deform tracks: ${droppedTracks.join(", ")}`);
4830
5934
  return doc;
4831
5935
  })
@@ -4835,10 +5939,10 @@ tool(
4835
5939
  "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.",
4836
5940
  {
4837
5941
  path: Path,
4838
- id: z2.string().describe("mesh attachment id"),
4839
- bones: z2.array(z2.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
4840
- 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"),
4841
- maxInfluences: z2.number().int().min(1).default(2).describe("bones per vertex")
5942
+ id: z3.string().describe("mesh attachment id"),
5943
+ bones: z3.array(z3.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
5944
+ 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"),
5945
+ maxInfluences: z3.number().int().min(1).default(2).describe("bones per vertex")
4842
5946
  },
4843
5947
  async ({ path, id, bones, radius, maxInfluences }) => mutateWith(path, (d, say) => {
4844
5948
  const att = meshOf(d, id);
@@ -4860,12 +5964,12 @@ tool(
4860
5964
  "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.",
4861
5965
  {
4862
5966
  path: Path,
4863
- id: z2.string().describe("mesh attachment id"),
4864
- vertices: z2.array(z2.number()).optional(),
5967
+ id: z3.string().describe("mesh attachment id"),
5968
+ vertices: z3.array(z3.number()).optional(),
4865
5969
  weights: WeightsIn.optional(),
4866
- uvs: z2.array(z2.number()).optional(),
4867
- triangles: z2.array(z2.number().int().nonnegative()).optional(),
4868
- hull: z2.number().int().nonnegative().optional()
5970
+ uvs: z3.array(z3.number()).optional(),
5971
+ triangles: z3.array(z3.number().int().nonnegative()).optional(),
5972
+ hull: z3.number().int().nonnegative().optional()
4869
5973
  },
4870
5974
  async ({ path, id, ...patch }) => mutateWith(path, (d, say) => {
4871
5975
  const { doc, dropped } = setMesh(d, meshOf(d, id).id, patch);
@@ -4876,7 +5980,7 @@ tool(
4876
5980
  tool(
4877
5981
  "rig_set_weights",
4878
5982
  "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.",
4879
- { path: Path, id: z2.string().describe("mesh attachment id"), weights: WeightsIn },
5983
+ { path: Path, id: z3.string().describe("mesh attachment id"), weights: WeightsIn },
4880
5984
  async ({ path, id, weights }) => mutateWith(path, (d, say) => {
4881
5985
  const { doc, dropped } = setWeights(d, meshOf(d, id).id, weights);
4882
5986
  if (dropped.length) say(`dropped deform tracks: ${dropped.join(", ")}`);
@@ -4886,19 +5990,19 @@ tool(
4886
5990
  tool(
4887
5991
  "rig_remove_attachment",
4888
5992
  "Remove an attachment; slots showing it become hidden, keys naming it become null.",
4889
- { path: Path, id: z2.string() },
5993
+ { path: Path, id: z3.string() },
4890
5994
  async ({ path, id }) => mutate(path, (d) => removeAttachment(d, id))
4891
5995
  );
4892
5996
  tool(
4893
5997
  "rig_remove_slot",
4894
5998
  "Remove a slot and its tracks.",
4895
- { path: Path, id: z2.string() },
5999
+ { path: Path, id: z3.string() },
4896
6000
  async ({ path, id }) => mutate(path, (d) => removeSlot(d, id), true)
4897
6001
  );
4898
6002
  tool(
4899
6003
  "rig_set_draw_order",
4900
6004
  "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.",
4901
- { path: Path, order: z2.array(z2.string()).min(1) },
6005
+ { path: Path, order: z3.array(z3.string()).min(1) },
4902
6006
  async ({ path, order }) => mutate(path, (d) => setDrawOrder(d, order))
4903
6007
  );
4904
6008
  function namesId(v, id) {
@@ -4910,7 +6014,7 @@ function namesId(v, id) {
4910
6014
  tool(
4911
6015
  "rig_rename",
4912
6016
  "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.",
4913
- { path: Path, kind: z2.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z2.string(), to: z2.string() },
6017
+ { path: Path, kind: z3.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z3.string(), to: z3.string() },
4914
6018
  async ({ path, kind, id, to }) => mutateWith(path, async (d, say) => {
4915
6019
  if (await exists2(libraryFor(path))) say(`note: ${libraryFor(path)} is not rewritten \u2014 templates that name "${id}" by id still do`);
4916
6020
  const next = rename2(d, kind, id, to);
@@ -4920,25 +6024,27 @@ tool(
4920
6024
  return next;
4921
6025
  }, true)
4922
6026
  );
4923
- var ConstraintBones = z2.array(z2.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
4924
- var ConstraintOrder = z2.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
4925
- var PathMixes = { mixRotate: z2.number().optional(), mixX: z2.number().optional(), mixY: z2.number().optional() };
4926
- var Mixes = { ...PathMixes, mixScaleX: z2.number().optional(), mixScaleY: z2.number().optional(), mixShearY: z2.number().optional() };
6027
+ var ConstraintBones = z3.array(z3.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
6028
+ var ConstraintOrder = z3.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
6029
+ var PathMixes = { mixRotate: z3.number().optional(), mixX: z3.number().optional(), mixY: z3.number().optional() };
6030
+ var Mixes = { ...PathMixes, mixScaleX: z3.number().optional(), mixScaleY: z3.number().optional(), mixShearY: z3.number().optional() };
4927
6031
  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.";
4928
6032
  tool(
4929
6033
  "rig_set_ik",
4930
- "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,
6034
+ "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,
4931
6035
  {
4932
6036
  path: Path,
4933
- id: z2.string(),
6037
+ id: z3.string(),
4934
6038
  bones: ConstraintBones,
4935
- target: z2.string().optional().describe("BONE the chain reaches for"),
4936
- mix: z2.number().optional(),
4937
- softness: z2.number().optional(),
4938
- bendPositive: z2.boolean().optional(),
4939
- compress: z2.boolean().optional(),
4940
- stretch: z2.boolean().optional(),
4941
- uniform: z2.boolean().optional(),
6039
+ target: z3.string().optional().describe("BONE the chain reaches for"),
6040
+ mix: z3.number().optional(),
6041
+ softness: z3.number().optional(),
6042
+ bendPositive: z3.boolean().optional(),
6043
+ iterations: z3.number().int().min(1).max(1e3).optional(),
6044
+ tolerance: z3.number().positive().optional(),
6045
+ compress: z3.boolean().optional(),
6046
+ stretch: z3.boolean().optional(),
6047
+ uniform: z3.boolean().optional(),
4942
6048
  order: ConstraintOrder
4943
6049
  },
4944
6050
  async ({ path, id, ...patch }) => mutate(path, (d) => setConstraint(d, "ik", id, patch))
@@ -4948,17 +6054,17 @@ tool(
4948
6054
  "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,
4949
6055
  {
4950
6056
  path: Path,
4951
- id: z2.string(),
6057
+ id: z3.string(),
4952
6058
  bones: ConstraintBones,
4953
- target: z2.string().optional().describe("BONE whose transform is copied"),
4954
- local: z2.boolean().optional(),
4955
- relative: z2.boolean().optional(),
4956
- rotation: z2.number().optional(),
4957
- x: z2.number().optional(),
4958
- y: z2.number().optional(),
4959
- scaleX: z2.number().optional(),
4960
- scaleY: z2.number().optional(),
4961
- shearY: z2.number().optional(),
6059
+ target: z3.string().optional().describe("BONE whose transform is copied"),
6060
+ local: z3.boolean().optional(),
6061
+ relative: z3.boolean().optional(),
6062
+ rotation: z3.number().optional(),
6063
+ x: z3.number().optional(),
6064
+ y: z3.number().optional(),
6065
+ scaleX: z3.number().optional(),
6066
+ scaleY: z3.number().optional(),
6067
+ shearY: z3.number().optional(),
4962
6068
  ...Mixes,
4963
6069
  order: ConstraintOrder
4964
6070
  },
@@ -4969,15 +6075,15 @@ tool(
4969
6075
  "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,
4970
6076
  {
4971
6077
  path: Path,
4972
- id: z2.string(),
6078
+ id: z3.string(),
4973
6079
  bones: ConstraintBones,
4974
- target: z2.string().optional().describe("SLOT showing a path attachment"),
4975
- positionMode: z2.enum(["fixed", "percent"]).optional(),
4976
- spacingMode: z2.enum(["length", "fixed", "percent", "proportional"]).optional(),
4977
- rotateMode: z2.enum(["tangent", "chain", "chainScale"]).optional(),
4978
- rotation: z2.number().optional(),
4979
- position: z2.number().optional(),
4980
- spacing: z2.number().optional(),
6080
+ target: z3.string().optional().describe("SLOT showing a path attachment"),
6081
+ positionMode: z3.enum(["fixed", "percent"]).optional(),
6082
+ spacingMode: z3.enum(["length", "fixed", "percent", "proportional"]).optional(),
6083
+ rotateMode: z3.enum(["tangent", "chain", "chainScale"]).optional(),
6084
+ rotation: z3.number().optional(),
6085
+ position: z3.number().optional(),
6086
+ spacing: z3.number().optional(),
4981
6087
  ...PathMixes,
4982
6088
  order: ConstraintOrder
4983
6089
  },
@@ -4986,7 +6092,7 @@ tool(
4986
6092
  tool(
4987
6093
  "rig_remove_constraint",
4988
6094
  "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.",
4989
- { path: Path, id: z2.string() },
6095
+ { path: Path, id: z3.string() },
4990
6096
  async ({ path, id }) => mutate(path, (d) => removeConstraint(d, id))
4991
6097
  );
4992
6098
  tool(
@@ -4994,13 +6100,14 @@ tool(
4994
6100
  "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.",
4995
6101
  {
4996
6102
  path: Path,
4997
- bone: z2.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
4998
- target: z2.string().optional().describe("id for the new target bone; default <bone>_ik"),
4999
- mix: z2.number().default(1),
5000
- softness: z2.number().default(0)
6103
+ bone: z3.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
6104
+ bones: z3.array(z3.string()).min(1).optional().describe("explicit parent-to-tip chain for long IK"),
6105
+ target: z3.string().optional().describe("id for the new target bone; default <bone>_ik"),
6106
+ mix: z3.number().default(1),
6107
+ softness: z3.number().default(0)
5001
6108
  },
5002
- async ({ path, bone, target, mix, softness }) => mutateWith(path, (d, say) => {
5003
- const r = addIkChain(d, bone, { target, mix, softness });
6109
+ async ({ path, bone, target, mix, softness, bones }) => mutateWith(path, (d, say) => {
6110
+ const r = addIkChain(d, bone, { target, mix, softness, bones });
5004
6111
  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`);
5005
6112
  return r.doc;
5006
6113
  }, true)
@@ -5010,12 +6117,12 @@ tool(
5010
6117
  "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.",
5011
6118
  {
5012
6119
  path: Path,
5013
- id: z2.string().describe("path attachment id"),
5014
- slot: z2.string(),
5015
- points: z2.array(z2.tuple([z2.number(), z2.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
5016
- vertices: z2.array(z2.number()).optional(),
6120
+ id: z3.string().describe("path attachment id"),
6121
+ slot: z3.string(),
6122
+ points: z3.array(z3.tuple([z3.number(), z3.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
6123
+ vertices: z3.array(z3.number()).optional(),
5017
6124
  weights: WeightsIn.optional(),
5018
- closed: z2.boolean().optional()
6125
+ closed: z3.boolean().optional()
5019
6126
  },
5020
6127
  async ({ path, id, slot, points, vertices, weights, closed }) => mutateWith(path, (d, say) => {
5021
6128
  const { doc: next, dropped } = setPathAttachment(d, id, slot, { points, vertices, weights, closed });
@@ -5030,10 +6137,10 @@ tool(
5030
6137
  "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).",
5031
6138
  {
5032
6139
  path: Path,
5033
- id: z2.string().describe("path attachment id"),
5034
- op: z2.enum(["insert", "remove"]),
5035
- index: z2.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
5036
- 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")
6140
+ id: z3.string().describe("path attachment id"),
6141
+ op: z3.enum(["insert", "remove"]),
6142
+ index: z3.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
6143
+ 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")
5037
6144
  },
5038
6145
  async ({ path, id, op, index, t }) => mutateWith(path, (d, say) => {
5039
6146
  const { doc, dropped } = pathAnchorOp(d, id, op, index, t);
@@ -5047,9 +6154,9 @@ tool(
5047
6154
  "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,
5048
6155
  {
5049
6156
  path: Path,
5050
- preset: z2.string().describe("built-in name or a template name from the library"),
5051
- params: z2.record(z2.unknown()).default({}).describe("partial override of defaults"),
5052
- id: z2.string().optional().describe("animation id for templates (default: template name)"),
6157
+ preset: z3.string().describe("built-in name or a template name from the library"),
6158
+ params: z3.record(z3.unknown()).default({}).describe("partial override of defaults"),
6159
+ id: z3.string().optional().describe("animation id for templates (default: template name)"),
5053
6160
  library: Library
5054
6161
  },
5055
6162
  async ({ path, preset, params, id, library }) => mutate(path, async (d) => {
@@ -5062,7 +6169,7 @@ tool(
5062
6169
  tool(
5063
6170
  "rig_save_as_preset",
5064
6171
  '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.',
5065
- { path: Path, animation: z2.string(), name: z2.string().describe("snake_case template name"), groups: z2.record(z2.array(z2.string())).optional(), library: Library },
6172
+ { path: Path, animation: z3.string(), name: z3.string().describe("snake_case template name"), groups: z3.record(z3.array(z3.string())).optional(), library: Library },
5066
6173
  async ({ path, animation, name, groups, library }) => {
5067
6174
  try {
5068
6175
  const tpl = extractTemplate(await load(path), animation, name, groups);
@@ -5094,7 +6201,7 @@ ${tpls.length ? tpls.map(describeTemplate).join("\n") : " none"}`);
5094
6201
  tool(
5095
6202
  "rig_get_preset",
5096
6203
  "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.",
5097
- { path: Path, preset: z2.string(), library: Library },
6204
+ { path: Path, preset: z3.string(), library: Library },
5098
6205
  async ({ path, preset, library }) => {
5099
6206
  try {
5100
6207
  if (preset in presets) {
@@ -5112,9 +6219,9 @@ tool(
5112
6219
  tool(
5113
6220
  "rig_set_keys",
5114
6221
  "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,
5115
- { path: Path, animation: z2.string(), ...TrackRef, keys: z2.array(KeyIn), merge: z2.boolean().default(false), duration: z2.number().optional(), loop: z2.boolean().optional() },
5116
- async ({ path, animation, target, id, prop, keys, merge, duration, loop }) => mutate(path, (d) => {
5117
- let n = setKeys(d, animation, { target, id, prop }, keys.map(roundKey), duration, { merge });
6222
+ { path: Path, animation: z3.string(), ...TrackRef, keys: z3.array(KeyIn), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
6223
+ async ({ path, animation, target, id, prop, interpolation, keys, merge, duration, loop }) => mutate(path, (d) => {
6224
+ let n = setKeys(d, animation, { target, id, prop, interpolation }, keys.map(roundKey), duration, { merge });
5118
6225
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5119
6226
  return n;
5120
6227
  })
@@ -5122,10 +6229,10 @@ tool(
5122
6229
  tool(
5123
6230
  "rig_set_tracks",
5124
6231
  "Set several tracks of one animation in a single call (same semantics as rig_set_keys for each). " + KEY_DOC,
5125
- { 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() },
6232
+ { 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() },
5126
6233
  async ({ path, animation, tracks, merge, duration, loop }) => mutate(path, (d) => {
5127
6234
  let n = d;
5128
- for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop }, t.keys.map(roundKey), duration, { merge });
6235
+ 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 });
5129
6236
  if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
5130
6237
  return n;
5131
6238
  })
@@ -5135,11 +6242,11 @@ tool(
5135
6242
  "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.",
5136
6243
  {
5137
6244
  path: Path,
5138
- bone: z2.string(),
5139
- x: z2.number(),
5140
- y: z2.number(),
5141
- animation: z2.string().optional(),
5142
- t: z2.number().min(0).optional(),
6245
+ bone: z3.string(),
6246
+ x: z3.number(),
6247
+ y: z3.number(),
6248
+ animation: z3.string().optional(),
6249
+ t: z3.number().min(0).optional(),
5143
6250
  ease: Ease.optional()
5144
6251
  },
5145
6252
  async ({ path, bone, x, y, ...opts }) => mutateWith(path, (d, say) => {
@@ -5154,11 +6261,11 @@ tool(
5154
6261
  "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.",
5155
6262
  {
5156
6263
  path: Path,
5157
- animation: z2.string(),
5158
- from: z2.array(z2.string()).min(1),
5159
- to: z2.array(z2.string()).min(1),
5160
- phase: z2.number().min(0).max(1).default(0),
5161
- mirrorX: z2.boolean().default(false)
6264
+ animation: z3.string(),
6265
+ from: z3.array(z3.string()).min(1),
6266
+ to: z3.array(z3.string()).min(1),
6267
+ phase: z3.number().min(0).max(1).default(0),
6268
+ mirrorX: z3.boolean().default(false)
5162
6269
  },
5163
6270
  async ({ path, animation, from, to, phase, mirrorX }) => mutateWith(path, (d, say) => {
5164
6271
  const { doc, copied, empty, lossy } = copyTracks(d, animation, from, to, { phase, mirrorX });
@@ -5172,7 +6279,7 @@ tool(
5172
6279
  tool(
5173
6280
  "rig_adjust_track",
5174
6281
  "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.",
5175
- { path: Path, animation: z2.string(), ...TrackRef, amplitude: z2.number().optional(), offset: z2.number().optional(), timeScale: z2.number().positive().optional() },
6282
+ { path: Path, animation: z3.string(), ...TrackRef, amplitude: z3.number().optional(), offset: z3.number().optional(), timeScale: z3.number().positive().optional() },
5176
6283
  async ({ path, animation, target, id, prop, ...adj }) => mutate(path, (d) => adjustTrack(d, animation, { target, id, prop }, adj))
5177
6284
  );
5178
6285
  tool(
@@ -5180,47 +6287,108 @@ tool(
5180
6287
  "Animation properties without touching keys: duration (must cover the last key/event), loop, events (full list), rename. Creates the animation if missing.",
5181
6288
  {
5182
6289
  path: Path,
5183
- id: z2.string(),
5184
- duration: z2.number().positive().optional(),
5185
- loop: z2.boolean().optional(),
5186
- events: z2.array(z2.object({ t: z2.number().nonnegative(), name: z2.string(), payload: z2.record(z2.unknown()).optional() })).optional(),
5187
- rename: z2.string().optional()
6290
+ id: z3.string(),
6291
+ duration: z3.number().positive().optional(),
6292
+ loop: z3.boolean().optional(),
6293
+ events: z3.array(z3.object({ t: z3.number().nonnegative(), name: z3.string(), payload: z3.record(z3.unknown()).optional() })).optional(),
6294
+ rename: z3.string().optional()
5188
6295
  },
5189
6296
  async ({ path, id, ...patch }) => mutate(path, (d) => setAnimation(d, id, patch))
5190
6297
  );
5191
6298
  tool(
5192
6299
  "rig_remove_keys",
5193
6300
  "Remove the keys of one track at the given times; an emptied track is dropped. Drops the preset link.",
5194
- { path: Path, animation: z2.string(), ...TrackRef, times: z2.array(z2.number()).min(1) },
6301
+ { path: Path, animation: z3.string(), ...TrackRef, times: z3.array(z3.number()).min(1) },
5195
6302
  async ({ path, animation, target, id, prop, times: times2 }) => mutate(path, (d) => removeKeys(d, animation, { target, id, prop }, times2))
5196
6303
  );
5197
6304
  tool(
5198
6305
  "rig_remove_animation",
5199
6306
  "Delete an animation.",
5200
- { path: Path, id: z2.string() },
6307
+ { path: Path, id: z3.string() },
5201
6308
  async ({ path, id }) => mutate(path, (d) => removeAnimation(d, id), true)
5202
6309
  );
6310
+ tool(
6311
+ "rig_order_constraints",
6312
+ "Sort constraints by what they read and write, including target ancestors and skinned paths. Cycles are refused.",
6313
+ { path: Path },
6314
+ async ({ path }) => mutate(path, orderConstraints)
6315
+ );
6316
+ tool(
6317
+ "rig_gradient_weights",
6318
+ "Skin a mesh with a gradient along ordered chain joints, preserving its setup geometry.",
6319
+ { path: Path, id: z3.string(), bones: z3.array(z3.string()).min(2) },
6320
+ async ({ path, id, bones }) => mutate(path, (d) => gradientWeights(d, id, bones))
6321
+ );
6322
+ tool(
6323
+ "rig_smooth_weights",
6324
+ "Smooth weights over triangle neighbours. Locked vertex indices keep their weights. Deform tracks are dropped if the influence layout changes.",
6325
+ { 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([]) },
6326
+ async ({ path, id, iterations, strength, locked }) => mutate(path, (d) => smoothWeights(d, id, iterations, strength, locked))
6327
+ );
6328
+ tool(
6329
+ "rig_pin_weights",
6330
+ "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.",
6331
+ { 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() },
6332
+ async ({ path, id, bone, vertices, region }) => mutate(path, (d) => pinWeights(d, id, bone, vertices, region))
6333
+ );
6334
+ tool(
6335
+ "rig_attach_to_bone",
6336
+ "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.",
6337
+ { path: Path, animation: z3.string(), bone: z3.string(), target: z3.string(), t: z3.number().nonnegative() },
6338
+ async ({ path, animation, bone, target, t }) => mutate(path, (d) => attachToBone(d, animation, bone, target, t))
6339
+ );
6340
+ tool(
6341
+ "rig_bake_secondary",
6342
+ "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.",
6343
+ { 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) },
6344
+ async ({ path, animation, ...opts }) => mutate(path, (d) => bakeSecondary(d, animation, opts))
6345
+ );
5203
6346
  tool(
5204
6347
  "rig_render_preview",
5205
6348
  "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.",
5206
6349
  {
5207
6350
  path: Path,
5208
- animation: z2.string().optional(),
5209
- fps: z2.number().default(8),
5210
- count: z2.number().int().optional(),
5211
- times: z2.array(z2.number()).optional(),
5212
- overlayBones: z2.boolean().default(false),
5213
- onionSkin: z2.boolean().default(false),
5214
- region: z2.tuple([z2.number(), z2.number(), z2.number(), z2.number()]).optional(),
5215
- scale: z2.number().default(0.5).describe("downscale to save tokens"),
5216
- sheetColumns: z2.number().int().default(4),
5217
- saveTo: z2.string().optional().describe("also write sheet PNG here")
6351
+ animation: z3.string().optional(),
6352
+ fps: z3.number().default(8),
6353
+ count: z3.number().int().optional(),
6354
+ times: z3.array(z3.number()).optional(),
6355
+ transparent: z3.boolean().default(false),
6356
+ background: z3.string().optional(),
6357
+ weightBone: z3.string().optional(),
6358
+ debugFillMasks: z3.record(z3.string()).optional(),
6359
+ framesDir: z3.string().optional(),
6360
+ overlayBones: z3.boolean().default(false),
6361
+ onionSkin: z3.boolean().default(false),
6362
+ region: z3.tuple([z3.number(), z3.number(), z3.number(), z3.number()]).optional(),
6363
+ scale: z3.number().default(0.5).describe("downscale to save tokens"),
6364
+ sheetColumns: z3.number().int().default(4),
6365
+ saveTo: z3.string().optional().describe("also write sheet PNG here")
5218
6366
  },
5219
- async ({ path, saveTo, ...opts }) => {
6367
+ async ({ path, saveTo, framesDir, ...opts }) => {
5220
6368
  try {
5221
- const doc = await load(path);
5222
- const r = await renderPreview(doc, { assetsDir: dirname6(path), ...opts });
5223
- if (saveTo) await writeFile6(saveTo, r.sheet);
6369
+ const raw = await readFile9(path, "utf8");
6370
+ const doc = parse(JSON.parse(raw));
6371
+ const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6372
+ if (saveTo) await writeFile7(saveTo, r.sheet);
6373
+ if (framesDir) {
6374
+ const { mkdir: mkdir8 } = await import("node:fs/promises");
6375
+ await mkdir8(framesDir, { recursive: true });
6376
+ const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6377
+ for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6378
+ const manifest = {
6379
+ version: 1,
6380
+ rigFile: sha256(raw),
6381
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
6382
+ identity: r.identity,
6383
+ animation: opts.animation ?? null,
6384
+ transparent: opts.transparent,
6385
+ scale: opts.scale,
6386
+ times: r.times,
6387
+ frames,
6388
+ render: { background: opts.background, overlayBones: opts.overlayBones, onionSkin: opts.onionSkin, weightBone: opts.weightBone, debugFillMasks: opts.debugFillMasks ?? {}, region: opts.region }
6389
+ };
6390
+ await writeFile7(join9(framesDir, "manifest.json"), JSON.stringify(manifest, null, 2));
6391
+ }
5224
6392
  return { text: `${r.times.length} frames @ ${r.width}x${r.height}, t=[${r.times.join(", ")}]
5225
6393
  ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5226
6394
  } catch (e) {
@@ -5231,14 +6399,55 @@ ${previewCheck(doc, opts.animation)}`, image: r.sheet };
5231
6399
  tool(
5232
6400
  "rig_export",
5233
6401
  "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.",
5234
- { 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) },
6402
+ { 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) },
5235
6403
  async ({ path, outDir, padding, maxSize }) => {
5236
6404
  try {
5237
6405
  const out = resolveFrom(path, outDir);
5238
- const doc = await buildAtlas(await load(path), dirname6(path), out, { padding, maxSize });
5239
- const [w, h] = pngSize(await readFile8(join7(out, "atlas.png")));
5240
- return text(`\u2192 ${join7(out, "rig.json")}
5241
- \u2192 ${join7(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6406
+ const doc = await buildAtlas(await load(path), dirname8(path), out, { padding, maxSize });
6407
+ const [w, h] = pngSize(await readFile9(join9(out, "atlas.png")));
6408
+ return text(`\u2192 ${join9(out, "rig.json")}
6409
+ \u2192 ${join9(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
6410
+ } catch (e) {
6411
+ return fail(e);
6412
+ }
6413
+ }
6414
+ );
6415
+ tool(
6416
+ "rig_export_video",
6417
+ "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.",
6418
+ { 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() },
6419
+ async ({ path, outFile, ...opts }) => {
6420
+ try {
6421
+ const result = await exportVideo(await load(path), { assetsDir: dirname8(path), outFile: resolveFrom(path, outFile), ...opts });
6422
+ return text(`${resolveFrom(path, outFile)}: ${result.frames} frames, ${result.fps} fps, ${result.duration}s`);
6423
+ } catch (e) {
6424
+ return fail(e);
6425
+ }
6426
+ }
6427
+ );
6428
+ tool(
6429
+ "rig_verify_render",
6430
+ "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.",
6431
+ { path: Path, framesDir: z3.string(), animation: z3.string().optional(), times: z3.array(z3.number().nonnegative()).min(1), transparent: z3.boolean().optional() },
6432
+ async ({ path, framesDir, ...expected }) => {
6433
+ try {
6434
+ const here = dirname8(fileURLToPath2(import.meta.url));
6435
+ const source = join9(here, "preview/harness.js"), bundled = join9(here, "harness.js");
6436
+ const manifest = await verifyRenderManifest(path, framesDir, { ...expected, renderer: await exists2(source) ? source : bundled });
6437
+ return text(`verified ${manifest.frames.length}/${expected.times.length} frames; inputs, renderer and frame hashes match`);
6438
+ } catch (e) {
6439
+ return fail(e);
6440
+ }
6441
+ }
6442
+ );
6443
+ tool(
6444
+ "rig_run_gates",
6445
+ "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.",
6446
+ { path: Path, config: GateConfig },
6447
+ async ({ path, config }) => {
6448
+ try {
6449
+ const report = runGates(await load(path), config);
6450
+ return { text: JSON.stringify(report, null, 2), isError: !report.pass };
5242
6451
  } catch (e) {
5243
6452
  return fail(e);
5244
6453
  }
@@ -5249,7 +6458,7 @@ tool(
5249
6458
  var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".map": "application/json", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp" };
5250
6459
  async function startEditorServer(opts) {
5251
6460
  const root3 = resolve2(opts.root);
5252
- const assets = resolve2(opts.assets ?? fileURLToPath2(new URL("../..", import.meta.url)));
6461
+ const assets = resolve2(opts.assets ?? fileURLToPath3(new URL("../..", import.meta.url)));
5253
6462
  const inside = (rel) => {
5254
6463
  const abs = resolve2(root3, rel);
5255
6464
  return abs === root3 || abs.startsWith(root3 + sep) ? abs : void 0;
@@ -5262,7 +6471,7 @@ async function startEditorServer(opts) {
5262
6471
  return "missing";
5263
6472
  }
5264
6473
  };
5265
- const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile9(abs, "utf8")));
6474
+ const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile10(abs, "utf8")));
5266
6475
  const readDocAt = async (abs) => {
5267
6476
  let v = await version(abs);
5268
6477
  for (let i = 0; i < 3; i++) {
@@ -5283,7 +6492,7 @@ async function startEditorServer(opts) {
5283
6492
  const watchers = /* @__PURE__ */ new Map();
5284
6493
  function ensureWatch(abs) {
5285
6494
  if (watchers.has(abs)) return;
5286
- const entry = { w: fsWatch(dirname7(abs), { persistent: false }, (_e, file) => {
6495
+ const entry = { w: fsWatch(dirname9(abs), { persistent: false }, (_e, file) => {
5287
6496
  if (file && file !== basename5(abs)) return;
5288
6497
  schedule();
5289
6498
  }), last: "" };
@@ -5313,16 +6522,16 @@ data: ${JSON.stringify({ version: v })}
5313
6522
  async function listRigs() {
5314
6523
  const out = [];
5315
6524
  const walk = async (dir) => {
5316
- for (const e of await readdir3(dir, { withFileTypes: true })) {
6525
+ for (const e of await readdir5(dir, { withFileTypes: true })) {
5317
6526
  if (e.isDirectory()) {
5318
- if (!SKIP.has(e.name)) await walk(join8(dir, e.name));
6527
+ if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
5319
6528
  continue;
5320
6529
  }
5321
6530
  if (e.name !== "rig.json") continue;
5322
- const abs = join8(dir, e.name);
6531
+ const abs = join10(dir, e.name);
5323
6532
  let name = "?";
5324
6533
  try {
5325
- name = String(JSON.parse(await readFile9(abs, "utf8")).meta?.name ?? "?");
6534
+ name = String(JSON.parse(await readFile10(abs, "utf8")).meta?.name ?? "?");
5326
6535
  } catch {
5327
6536
  }
5328
6537
  out.push({ rig: relative3(root3, abs).split(sep).join("/"), name, mtimeMs: (await stat3(abs)).mtimeMs });
@@ -5332,9 +6541,9 @@ data: ${JSON.stringify({ version: v })}
5332
6541
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
5333
6542
  }
5334
6543
  async function listFiles(rigAbs) {
5335
- const dir = dirname7(rigAbs), out = [];
5336
- for (const e of (await readdir3(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
5337
- const fh = await open(join8(dir, e.name));
6544
+ const dir = dirname9(rigAbs), out = [];
6545
+ for (const e of (await readdir5(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6546
+ const fh = await open(join10(dir, e.name));
5338
6547
  try {
5339
6548
  const head = Buffer.alloc(24);
5340
6549
  const { bytesRead } = await fh.read(head, 0, 24, 0);
@@ -5369,13 +6578,13 @@ data: ${JSON.stringify({ version: v })}
5369
6578
  }
5370
6579
  if (url.pathname.startsWith("/api/")) return await api(url, req, res);
5371
6580
  const STATIC = /* @__PURE__ */ new Set(["/editor.html", "/dist/editor.js", "/dist/editor.js.map", "/dist/editor.css", "/dist/editor.css.map"]);
5372
- const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join8(assets, url.pathname) : void 0;
6581
+ const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join10(assets, url.pathname) : void 0;
5373
6582
  if (!abs) {
5374
6583
  res.writeHead(403);
5375
6584
  return res.end("forbidden");
5376
6585
  }
5377
6586
  try {
5378
- const data = await readFile9(abs);
6587
+ const data = await readFile10(abs);
5379
6588
  res.writeHead(200, { "content-type": TYPES[extname2(abs)] ?? "application/octet-stream" });
5380
6589
  res.end(data);
5381
6590
  } catch {
@@ -5451,8 +6660,8 @@ data: ${JSON.stringify({ version: v })}
5451
6660
  const abs = inside(rig);
5452
6661
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
5453
6662
  if (await stat3(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
5454
- await mkdir5(dirname7(abs), { recursive: true });
5455
- const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname7(abs)), width: width ?? 640, height: height ?? 700 });
6663
+ await mkdir7(dirname9(abs), { recursive: true });
6664
+ const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
5456
6665
  ensureWatch(abs);
5457
6666
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });
5458
6667
  }
@@ -5467,7 +6676,7 @@ data: ${JSON.stringify({ version: v })}
5467
6676
  const name = url.searchParams.get("name") ?? "";
5468
6677
  if (!/^[A-Za-z0-9._-]+\.png$/i.test(name) || name.startsWith(".")) return json(res, 400, { isError: true, text: `ERROR: bad file name "${name}" \u2014 letters, digits, . _ - and a .png extension` });
5469
6678
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("image/png")) return json(res, 415, { isError: true, text: "ERROR: upload needs content-type: image/png" });
5470
- const target = join8(dirname7(abs), name);
6679
+ const target = join10(dirname9(abs), name);
5471
6680
  if (!inside(relative3(root3, target))) return json(res, 403, { isError: true, text: "file outside root" });
5472
6681
  if (await stat3(target).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${name} already exists next to the rig \u2014 rename the file` });
5473
6682
  const data = await bytes(req, UPLOAD_MAX);