@energy8platform/golem 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/rig-api.ts
2
2
  import { z as z3 } from "zod";
3
- import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
3
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
4
4
  import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
5
5
 
6
6
  // src/rig-io.ts
@@ -216,7 +216,9 @@ var PathAttachment = z.object({
216
216
  /** curve lengths for constant-speed positioning */
217
217
  lengths: z.array(z.number()).default([]),
218
218
  closed: z.boolean().default(false),
219
- constantSpeed: z.boolean().default(true)
219
+ constantSpeed: z.boolean().default(true),
220
+ /** Spine-style active clipping span in the posed draw order; end slot is inclusive. */
221
+ clipping: z.object({ end: Id.optional() }).optional()
220
222
  });
221
223
  var Attachment = z.discriminatedUnion("type", [RegionAttachment, MeshAttachment, PathAttachment]);
222
224
  var IkConstraint = z.object({
@@ -285,10 +287,13 @@ var Key = z.object({
285
287
  t: z.number().nonnegative(),
286
288
  v: KeyValue,
287
289
  /** easing INTO the next key */
288
- ease: Ease.default("ease_in_out")
290
+ ease: Ease.default("ease_in_out"),
291
+ /** Optional independent easing for numeric-array components (for example Spine RGBA curves). */
292
+ componentEase: z.array(Ease).min(1).optional()
289
293
  });
290
294
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
291
295
  var Track = z.discriminatedUnion("target", [
296
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
292
297
  z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
293
298
  z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
294
299
  z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
@@ -311,6 +316,25 @@ var Animation = z.object({
311
316
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
312
317
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
313
318
  });
319
+ var RigControl = z.object({
320
+ id: Id,
321
+ label: z.string().optional(),
322
+ min: z.number().finite(),
323
+ max: z.number().finite(),
324
+ default: z.number().finite()
325
+ }).strict();
326
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
327
+ var ControlChannel = z.discriminatedUnion("target", [
328
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
329
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
330
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
331
+ ]);
332
+ var ControlBinding = z.object({
333
+ id: Id,
334
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
335
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
336
+ channels: z.array(ControlChannel).min(1)
337
+ }).strict();
314
338
  var Reference = z.object({
315
339
  src: z.string(),
316
340
  opacity: z.number().min(0).max(1).default(0.5),
@@ -336,7 +360,9 @@ var RigSchema = z.object({
336
360
  ik: z.array(IkConstraint).default([]),
337
361
  transform: z.array(TransformConstraint).default([]),
338
362
  path: z.array(PathConstraint).default([]),
339
- animations: z.array(Animation)
363
+ animations: z.array(Animation),
364
+ controls: z.array(RigControl).optional(),
365
+ controlBindings: z.array(ControlBinding).optional()
340
366
  });
341
367
  var RigDocument = z.preprocess((input) => {
342
368
  const v = input?.version;
@@ -411,6 +437,8 @@ function validate(doc) {
411
437
  dup(doc.ik, "ik");
412
438
  dup(doc.transform, "transform");
413
439
  dup(doc.path, "path");
440
+ dup(doc.controls ?? [], "control");
441
+ dup(doc.controlBindings ?? [], "control binding");
414
442
  const bones = new Set(doc.bones.map((b) => b.id));
415
443
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
416
444
  const slots = new Set(doc.slots.map((s) => s.id));
@@ -448,6 +476,10 @@ function validate(doc) {
448
476
  else if (a.slot !== s.id) errs.push(`slot "${s.id}": attachment "${s.attachment}" belongs to slot "${a.slot}"`);
449
477
  }
450
478
  }
479
+ for (const a of doc.attachments) if (a.type === "path" && a.clipping) {
480
+ if (!a.closed) errs.push(`attachment "${a.id}": clipping path must be closed`);
481
+ if (a.clipping.end && !slots.has(a.clipping.end)) errs.push(`attachment "${a.id}": unknown clipping end slot "${a.clipping.end}"`);
482
+ }
451
483
  const vertexCount2 = (a) => {
452
484
  if (a.type === "region") return 4;
453
485
  if (a.type === "mesh") return a.uvs.length / 2;
@@ -485,20 +517,59 @@ function validate(doc) {
485
517
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
486
518
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
487
519
  }
520
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
521
+ const driven = /* @__PURE__ */ new Set();
522
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
523
+ errs.push(`control "${c.id}": require min < max and default within range`);
524
+ for (const b of doc.controlBindings ?? []) {
525
+ const prefix = `control binding "${b.id}"`;
526
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
527
+ for (const axis of b.axes) {
528
+ const c = controls.get(axis.control);
529
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
530
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
531
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
532
+ }
533
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
534
+ for (const ch of b.channels) {
535
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
536
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
537
+ driven.add(key);
538
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
539
+ if (ch.target === "deform") {
540
+ const att = attachments.get(ch.id);
541
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
542
+ else {
543
+ const n = att.weights ? att.weights.reduce((s, a) => s + a.length * 2, 0) : att.vertices.length;
544
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
545
+ }
546
+ } else {
547
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
548
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
549
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
550
+ }
551
+ }
552
+ }
488
553
  for (const a of doc.animations) {
489
554
  const tracks = /* @__PURE__ */ new Set();
490
555
  for (const t of a.tracks) {
491
556
  const tid = trackId(t);
492
557
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
493
558
  tracks.add(tid);
494
- 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;
559
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
560
+ 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;
495
561
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
496
562
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
497
563
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
498
564
  let prev = -1;
499
565
  for (const k of t.keys) {
566
+ if (k.componentEase && (!Array.isArray(k.v) || !k.v.every((v) => typeof v === "number") || k.componentEase.length !== k.v.length)) errs.push(`animation "${a.id}" ${tid}: componentEase must match the numeric-array value length`);
500
567
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
501
568
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
569
+ if (t.target === "control") {
570
+ const c = controls.get(t.id);
571
+ 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`);
572
+ }
502
573
  if (t.target === "slot" && t.prop === "attachment") {
503
574
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
504
575
  else if (typeof k.v === "string") {
@@ -537,6 +608,65 @@ function parse(input) {
537
608
  return doc;
538
609
  }
539
610
 
611
+ // src/rig-controls.ts
612
+ function controlPose(doc, values2) {
613
+ const out = /* @__PURE__ */ new Map();
614
+ for (const [id, value] of Object.entries(values2)) {
615
+ const c = doc.controls?.find((c2) => c2.id === id);
616
+ if (!c) throw new Error(`unknown control "${id}"`);
617
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
618
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
619
+ }
620
+ return out;
621
+ }
622
+ function bracket(keys, value) {
623
+ let i = 0;
624
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
625
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
626
+ }
627
+ function sampleBinding(binding, values2) {
628
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
629
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
630
+ const width = binding.axes[0].keys.length, a = y * width + x;
631
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
632
+ return binding.channels.map((ch) => {
633
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
634
+ const at = (j2) => {
635
+ const top = mix(read(a, j2), read(a + 1, j2), u);
636
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
637
+ };
638
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
639
+ });
640
+ }
641
+ function resolveControls(doc, input) {
642
+ if (!doc.controlBindings?.length) return input;
643
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
644
+ for (const b of doc.controlBindings) {
645
+ const values2 = b.axes.map((axis) => {
646
+ const c = controls.get(axis.control);
647
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
648
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
649
+ return Math.max(c.min, Math.min(c.max, value));
650
+ });
651
+ const sampled2 = sampleBinding(b, values2);
652
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
653
+ }
654
+ return out;
655
+ }
656
+ function controlCases(doc, probes) {
657
+ let cases = [{ label: "default", values: {} }];
658
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
659
+ for (const [id, values2] of Object.entries(probes)) {
660
+ const c = doc.controls?.find((c2) => c2.id === id);
661
+ if (!c) throw new Error(`unknown control "${id}"`);
662
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
663
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
664
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
665
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
666
+ }
667
+ return cases;
668
+ }
669
+
540
670
  // src/rig-anim.ts
541
671
  var DEG2 = Math.PI / 180;
542
672
  var named = {
@@ -611,8 +741,8 @@ function sample(track, t, loop = false) {
611
741
  }
612
742
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
613
743
  if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
614
- const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
615
- return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
744
+ const phase = (t - a.t) / (b.t - a.t), av = a.v, bv = b.v;
745
+ return av.map((v, k) => v + ((bv[k] ?? v) - v) * ease(a.componentEase?.[k] ?? a.ease)(phase));
616
746
  }
617
747
  return a.v;
618
748
  }
@@ -640,6 +770,7 @@ function blendPoses(from, to, w, restOf) {
640
770
  }
641
771
  function restValue(doc, key) {
642
772
  const [target, id, prop] = key.split(":");
773
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
643
774
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
644
775
  if (target === "slot") {
645
776
  const s = doc.slots.find((s2) => s2.id === id);
@@ -703,6 +834,13 @@ function boneWorldOf(bone, s, parent) {
703
834
  if (!parent) return local;
704
835
  if (bone.inherit === "normal") return mul(parent, local);
705
836
  const [tx, ty] = apply(parent, s.x, s.y);
837
+ if (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection") {
838
+ const r = s.rotation * DEG2, x = parent.a * Math.cos(r) + parent.c * Math.sin(r), y = parent.b * Math.cos(r) + parent.d * Math.sin(r);
839
+ const length = Math.hypot(x, y), a = length > 1e-5 ? x / length : x, b = length > 1e-5 ? y / length : y;
840
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
841
+ const basis3 = { a, b, c: -b * sign, d: a * sign, tx: 0, ty: 0 };
842
+ return { ...mul(basis3, affineOf(0, 0, 0, s.scaleX, s.scaleY, s.shearX, s.shearY)), tx, ty };
843
+ }
706
844
  return { ...mul(inheritedParentLinear(bone, parent), { ...local, tx: 0, ty: 0 }), tx, ty };
707
845
  }
708
846
  var orderCache = /* @__PURE__ */ new WeakMap();
@@ -735,18 +873,20 @@ var localOf = (doc, pose, b) => {
735
873
  shearY: num(pose, pk("bone", id, "shearY"), r.shearY)
736
874
  };
737
875
  };
738
- function updateSubtree(skel, root2) {
876
+ function updateSubtree(skel, root2, includeRoot = true) {
739
877
  const doc = skel.doc;
740
878
  const kids = /* @__PURE__ */ new Map();
741
879
  for (const b of doc.bones) if (b.parent) (kids.get(b.parent) ?? kids.set(b.parent, []).get(b.parent)).push(b);
742
- const stack = [root2];
880
+ const stack = includeRoot ? [root2] : (kids.get(root2) ?? []).map((b) => b.id);
743
881
  while (stack.length) {
744
882
  const id = stack.pop(), b = boneOf(doc, id), st = skel.bones.get(id);
745
883
  st.world = boneWorldOf(b, st, b.parent ? skel.bones.get(b.parent).world : void 0);
746
884
  for (const k of kids.get(id) ?? []) stack.push(k.id);
747
885
  }
748
886
  }
749
- function evaluate(doc, pose) {
887
+ function evaluate(doc, pose, modifiers) {
888
+ pose = resolveControls(doc, pose);
889
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
750
890
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
751
891
  for (const b of boneOrder(doc)) {
752
892
  const s = localOf(doc, pose, b);
@@ -980,7 +1120,8 @@ function applyTransformConstraint(skel, c) {
980
1120
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
981
1121
  c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
982
1122
  const target = skel.bones.get(c.target), tw = target.world;
983
- const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
1123
+ const handedness = tw.a * tw.d - tw.b * tw.c < 0 ? -1 : 1;
1124
+ const offsetRotation = c.rotation * DEG2 * (c.local ? 1 : handedness), offsetShearY = c.shearY * DEG2 * (c.local ? 1 : handedness);
984
1125
  for (const id of c.bones) {
985
1126
  const b = boneOf(skel.doc, id), st = skel.bones.get(id);
986
1127
  if (c.local) {
@@ -1142,6 +1283,7 @@ function applyPathConstraint(skel, c) {
1142
1283
  const position = cnum(skel, "path", c.id, "position", c.position), spacing = cnum(skel, "path", c.id, "spacing", c.spacing);
1143
1284
  const tangents = c.rotateMode === "tangent", scale = c.rotateMode === "chainScale";
1144
1285
  const bones = c.bones.map((id) => ({ b: boneOf(skel.doc, id), st: skel.bones.get(id) }));
1286
+ const inputWorlds = bones.map(({ st }) => ({ ...st.world }));
1145
1287
  const spacesCount = tangents ? bones.length : bones.length + 1;
1146
1288
  const spaces = new Array(spacesCount).fill(0), worldLengths = [];
1147
1289
  let sum = 0;
@@ -1176,9 +1318,10 @@ function applyPathConstraint(skel, c) {
1176
1318
  if (tw.a * tw.d - tw.b * tw.c < 0) offsetRotation = -offsetRotation;
1177
1319
  }
1178
1320
  let boneX = samples[0].x, boneY = samples[0].y;
1321
+ const solvedWorlds = /* @__PURE__ */ new Map();
1179
1322
  for (let i = 0; i < bones.length; i++) {
1180
1323
  const { b, st } = bones[i];
1181
- let w = { ...st.world };
1324
+ let w = { ...inputWorlds[i] };
1182
1325
  w.tx += (boneX - w.tx) * mixX;
1183
1326
  w.ty += (boneY - w.ty) * mixY;
1184
1327
  const next = samples[i + 1] ?? { x: 0, y: 0, angle: 0 };
@@ -1208,7 +1351,11 @@ function applyPathConstraint(skel, c) {
1208
1351
  const cos = Math.cos(r), sin = Math.sin(r);
1209
1352
  w = { a: cos * a - sin * bb, b: sin * a + cos * bb, c: cos * cc - sin * d, d: sin * cc + cos * d, tx: w.tx, ty: w.ty };
1210
1353
  }
1211
- setEffectiveWorld(skel, b, st, w);
1354
+ solvedWorlds.set(b.id, w);
1355
+ }
1356
+ for (const b of boneOrder(skel.doc)) {
1357
+ const world = solvedWorlds.get(b.id);
1358
+ if (world) setEffectiveWorld(skel, b, skel.bones.get(b.id), world);
1212
1359
  }
1213
1360
  }
1214
1361
  function measuredPathLength(points, closed, lengths, constantSpeed) {
@@ -1237,9 +1384,14 @@ function inheritedParentLinear(bone, parent) {
1237
1384
  case "onlyTranslation":
1238
1385
  return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
1239
1386
  case "noRotationOrReflection": {
1240
- const lenX = Math.hypot(parent.a, parent.b) || 1;
1387
+ const lenX = Math.hypot(parent.a, parent.b);
1241
1388
  const det = parent.a * parent.d - parent.b * parent.c;
1242
- return { a: lenX, b: 0, c: 0, d: Math.abs(det) / lenX, tx: 0, ty: 0 };
1389
+ if (lenX * lenX <= 1e-4) {
1390
+ const lenY = Math.hypot(parent.c, parent.d), ux2 = lenY ? parent.c / lenY : 0, uy2 = lenY ? parent.d / lenY : 0;
1391
+ return { a: lenY * ux2 * ux2, b: lenY * ux2 * uy2, c: lenY * ux2 * uy2, d: lenY * uy2 * uy2, tx: 0, ty: 0 };
1392
+ }
1393
+ const ux = parent.a / lenX, uy = parent.b / lenX, sy = Math.abs(det) / lenX;
1394
+ return { a: lenX * ux * ux + sy * uy * uy, b: (lenX - sy) * ux * uy, c: (lenX - sy) * ux * uy, d: lenX * uy * uy + sy * ux * ux, tx: 0, ty: 0 };
1243
1395
  }
1244
1396
  case "noScale":
1245
1397
  case "noScaleOrReflection": {
@@ -1265,7 +1417,16 @@ function setEffectiveWorld(skel, bone, st, world) {
1265
1417
  st.scaleY = Math.hypot(lin.c, lin.d);
1266
1418
  st.shearX = 0;
1267
1419
  st.shearY = shortest(Math.atan2(lin.d, lin.c) / DEG2 - st.rotation - 90);
1268
- updateSubtree(skel, bone.id);
1420
+ if (parent && (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection")) {
1421
+ const direction = mul(invert({ ...parent, tx: 0, ty: 0 }), { ...world, tx: 0, ty: 0 });
1422
+ st.rotation = Math.atan2(direction.b, direction.a) / DEG2;
1423
+ st.scaleX = Math.hypot(world.a, world.b);
1424
+ st.scaleY = Math.hypot(world.c, world.d);
1425
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
1426
+ st.shearY = shortest((Math.atan2(world.d, world.c) - Math.atan2(world.b, world.a)) / DEG2 * sign - 90);
1427
+ }
1428
+ st.world = world;
1429
+ updateSubtree(skel, bone.id, false);
1269
1430
  }
1270
1431
  function attachmentWorldVertices(skel, slotId, att) {
1271
1432
  const deform = skel.pose.get(pk("deform", att.id, "vertices"));
@@ -1972,6 +2133,9 @@ function updateBone(doc, id, patch) {
1972
2133
  const i = doc.bones.findIndex((b) => b.id === id);
1973
2134
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1974
2135
  const cur = doc.bones[i];
2136
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2137
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2138
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1975
2139
  const { parent, ...rest } = patch;
1976
2140
  const next = RigSchema.shape.bones.element.parse({
1977
2141
  ...cur,
@@ -2004,6 +2168,11 @@ function patchDrawOrder(doc, fn) {
2004
2168
  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) })) }) })) };
2005
2169
  }
2006
2170
  function setSlot(doc, slot) {
2171
+ if (slot.alpha !== void 0) {
2172
+ for (const b of doc.controlBindings ?? [])
2173
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2174
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2175
+ }
2007
2176
  const cur = doc.slots.find((s) => s.id === slot.id);
2008
2177
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
2009
2178
  const base = doc.slots.find((s) => s.id === slot.id);
@@ -2016,7 +2185,7 @@ function removeSlot(doc, id) {
2016
2185
  return patchDrawOrder({
2017
2186
  ...doc,
2018
2187
  slots: doc.slots.filter((s) => s.id !== id).map((s) => gone.has(s.clip ?? "") ? { ...s, clip: void 0 } : s),
2019
- attachments: doc.attachments.filter((a) => a.slot !== id),
2188
+ attachments: doc.attachments.filter((a) => a.slot !== id).map((a) => a.type === "path" && a.clipping?.end === id ? { ...a, clipping: {} } : a),
2020
2189
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
2021
2190
  }, (o) => o.filter((s) => s !== id));
2022
2191
  }
@@ -2378,6 +2547,7 @@ function trackSummary(t) {
2378
2547
  function rename2(doc, kind, id, to) {
2379
2548
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2380
2549
  if (to === id) return doc;
2550
+ 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) })) };
2381
2551
  const sub = (v) => v === id ? to : v;
2382
2552
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2383
2553
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2411,7 +2581,7 @@ function rename2(doc, kind, id, to) {
2411
2581
  return {
2412
2582
  ...doc,
2413
2583
  slots: doc.slots.map((s) => ({ ...s, id: sub(s.id) })),
2414
- attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot) })),
2584
+ attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot), ...a.type === "path" && a.clipping?.end ? { clipping: { end: sub(a.clipping.end) } } : {} })),
2415
2585
  path: doc.path.map((c) => ({ ...c, target: sub(c.target) }))
2416
2586
  };
2417
2587
  }
@@ -2459,6 +2629,8 @@ function describeAttachment(doc, att) {
2459
2629
  }
2460
2630
  function describe(doc, opts = {}) {
2461
2631
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2632
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2633
+ 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(", ")}`);
2462
2634
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2463
2635
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2464
2636
  const place = (s) => {
@@ -3158,8 +3330,8 @@ function motionQuality(doc, anim, opts = {}) {
3158
3330
  if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3159
3331
  const visible = (id) => {
3160
3332
  const slot = doc.slots.find((s) => s.id === id);
3161
- const color = pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3162
- return Number(pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3333
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3334
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3163
3335
  };
3164
3336
  if (!visible(front) || !visible(back)) continue;
3165
3337
  const order = pose.get(pk("drawOrder", "order", "order"));
@@ -3234,6 +3406,7 @@ function checkRig(doc, opts = {}) {
3234
3406
  });
3235
3407
  const W = doc.meta.width, H = doc.meta.height;
3236
3408
  const overflowOf = (pose, slotId, skel) => {
3409
+ pose = skel.pose;
3237
3410
  const pts = slotOutline(doc, pose, slotId, 4, skel);
3238
3411
  if (!pts) return void 0;
3239
3412
  const slot = doc.slots.find((s) => s.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3432,6 +3605,7 @@ function checkRig(doc, opts = {}) {
3432
3605
  }
3433
3606
  }
3434
3607
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3608
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3435
3609
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3436
3610
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3437
3611
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3768,11 +3942,13 @@ function diffDocs(a, b) {
3768
3942
  ["attachments", "attachment"],
3769
3943
  ["ik", "ik"],
3770
3944
  ["transform", "transform"],
3771
- ["path", "path"]
3945
+ ["path", "path"],
3946
+ ["controls", "control"],
3947
+ ["controlBindings", "control binding"]
3772
3948
  ];
3773
3949
  for (const [kind, name] of kinds) {
3774
- const ma = new Map(a[kind].map((x) => [x.id, x]));
3775
- const mb = new Map(b[kind].map((x) => [x.id, x]));
3950
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3951
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3776
3952
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3777
3953
  for (const [id, x] of ma) {
3778
3954
  const y = mb.get(id);
@@ -4018,6 +4194,9 @@ var Ids = class {
4018
4194
  has(name) {
4019
4195
  return this.map.has(name);
4020
4196
  }
4197
+ reserve(id) {
4198
+ this.used.add(id);
4199
+ }
4021
4200
  };
4022
4201
  var spineToDoc = (x, y, originX, originY) => [x + originX, originY - y];
4023
4202
  var r4 = (v) => Math.round(v * 1e6) / 1e6 + 0;
@@ -4026,6 +4205,7 @@ function importSpine(json, atlasText, opts = {}) {
4026
4205
  const warnings = [];
4027
4206
  const atlas = parseAtlas(atlasText);
4028
4207
  const version = String(json.skeleton?.spine ?? "4.0");
4208
+ if (json.physics?.length) warnings.push(`${json.physics.length} Spine physics constraints are not imported; bake their motion before delivery`);
4029
4209
  const major = parseFloat(version);
4030
4210
  const bounds = { x: json.skeleton?.x ?? 0, y: json.skeleton?.y ?? 0, w: json.skeleton?.width ?? 0, h: json.skeleton?.height ?? 0 };
4031
4211
  const margin = opts.margin ?? 0;
@@ -4033,6 +4213,7 @@ function importSpine(json, atlasText, opts = {}) {
4033
4213
  const toDoc = (x, y) => spineToDoc(x, y, originX, originY);
4034
4214
  const boneIds = new Ids("b_"), slotIds = new Ids("s_"), assetIds = new Ids("a_"), attIds = new Ids("att_"), ikIds = new Ids("ik_"), tcIds = new Ids("tc_"), pcIds = new Ids("pc_"), animIds = new Ids("anim_");
4035
4215
  const ROOT = "origin";
4216
+ boneIds.reserve(ROOT);
4036
4217
  const attachmentIdMap = /* @__PURE__ */ new Map();
4037
4218
  const attachmentId = (slot, name) => {
4038
4219
  const key = `${slot} ${name}`;
@@ -4057,13 +4238,17 @@ function importSpine(json, atlasText, opts = {}) {
4057
4238
  });
4058
4239
  }
4059
4240
  const skins = Array.isArray(json.skins) ? json.skins : Object.entries(json.skins ?? {}).map(([name, attachments2]) => ({ name, attachments: attachments2 }));
4241
+ const selectedSkin = opts.skin ? skins.find((s) => s.name === opts.skin) : skins.find((s) => s.name === "default") ?? skins[0];
4242
+ if (opts.skin && !selectedSkin) throw new Error(`Spine skin "${opts.skin}" not found; available: ${skins.map((s) => s.name).join(", ")}`);
4243
+ const lookupSkins = [selectedSkin, skins.find((s) => s.name === "default")].filter((s, i, list) => s && list.indexOf(s) === i);
4244
+ if (skins.length > 1) warnings.push(`Spine skin "${selectedSkin?.name}" selected; setup and attachment timelines are flattened to this skin with default fallback (no runtime skin switching)`);
4060
4245
  const frameSuffix = (seq, index) => {
4061
- const n = String((seq.start ?? 0) + index);
4246
+ const n = String((seq.start ?? 1) + index);
4062
4247
  return "0".repeat(Math.max(0, (seq.digits ?? 0) - n.length)) + n;
4063
4248
  };
4064
4249
  const sequences = /* @__PURE__ */ new Map();
4065
4250
  const skinnedName = /* @__PURE__ */ new Map();
4066
- for (const skin2 of skins) {
4251
+ for (const skin2 of lookupSkins) {
4067
4252
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4068
4253
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
4069
4254
  for (const [name, a] of Object.entries(atts)) {
@@ -4072,7 +4257,10 @@ function importSpine(json, atlasText, opts = {}) {
4072
4257
  if (!skinnedName.has(key)) skinnedName.set(key, prefix + name + (seq ? frameSuffix(seq, seq.setupIndex ?? 0) : ""));
4073
4258
  }
4074
4259
  }
4075
- const attachmentRef = (slot, name) => attachmentId(slot, skinnedName.get(`${slot} ${name}`) ?? name);
4260
+ const attachmentRef = (slot, name) => {
4261
+ const resolved = skinnedName.get(`${slot} ${name}`);
4262
+ return resolved === void 0 ? null : attachmentId(slot, resolved);
4263
+ };
4076
4264
  const spineSlots = json.slots ?? [];
4077
4265
  const slotIndexByName = new Map(spineSlots.map((s, i) => [s.name, i]));
4078
4266
  const blendOf = (b) => b === "additive" ? "add" : b === "multiply" ? "multiply" : b === "screen" ? "screen" : "normal";
@@ -4080,11 +4268,12 @@ function importSpine(json, atlasText, opts = {}) {
4080
4268
  id: slotIds.get(s.name),
4081
4269
  bone: boneIds.get(s.bone),
4082
4270
  z: i,
4083
- ...s.attachment ? { attachment: attachmentRef(s.name, s.attachment) } : {},
4271
+ ...s.attachment && attachmentRef(s.name, s.attachment) ? { attachment: attachmentRef(s.name, s.attachment) } : {},
4084
4272
  blend: blendOf(s.blend),
4085
4273
  color: hex8(s.color),
4086
4274
  alpha: 1
4087
4275
  }));
4276
+ if (spineSlots.some((s) => s.dark)) warnings.push("Spine setup dark two-color tint is not supported");
4088
4277
  const pageImage = (page2) => typeof opts.pageImage === "function" ? opts.pageImage(page2) : opts.pageImage ?? page2;
4089
4278
  const assets = /* @__PURE__ */ new Map();
4090
4279
  const regionByName = new Map(atlas.regions.map((r) => [r.name, r]));
@@ -4110,6 +4299,7 @@ function importSpine(json, atlasText, opts = {}) {
4110
4299
  return id;
4111
4300
  };
4112
4301
  const attachments = [];
4302
+ const clipDeforms = /* @__PURE__ */ new Map();
4113
4303
  const readVertices = (a, vertexCount2) => {
4114
4304
  const v = a.vertices ?? [];
4115
4305
  if (v.length === vertexCount2 * 2) {
@@ -4130,6 +4320,22 @@ function importSpine(json, atlasText, opts = {}) {
4130
4320
  }
4131
4321
  return { weights };
4132
4322
  };
4323
+ const meshGeometry = (skinName, slot, name, visiting = /* @__PURE__ */ new Set()) => {
4324
+ const key = JSON.stringify([skinName, slot, name]);
4325
+ if (visiting.has(key)) {
4326
+ warnings.push(`linked mesh cycle at "${skinName}/${slot}/${name}" skipped`);
4327
+ return void 0;
4328
+ }
4329
+ const raw = skins.find((s) => s.name === skinName)?.attachments?.[slot]?.[name];
4330
+ if (!raw) {
4331
+ warnings.push(`linked mesh parent "${skinName}/${slot}/${name}" not found`);
4332
+ return void 0;
4333
+ }
4334
+ if (!raw.parent) return raw;
4335
+ visiting.add(key);
4336
+ return meshGeometry(raw.skin ?? "default", slot, raw.parent, visiting);
4337
+ };
4338
+ const inheritedDeform = /* @__PURE__ */ new Map();
4133
4339
  for (const skin2 of skins) {
4134
4340
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4135
4341
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
@@ -4141,7 +4347,7 @@ function importSpine(json, atlasText, opts = {}) {
4141
4347
  for (const [name, a] of Object.entries(atts)) {
4142
4348
  if (a.sequence) {
4143
4349
  sequences.set(`${slotName} ${prefix}${name}`, a.sequence);
4144
- const base = a.path ?? name;
4350
+ const base = a.path ?? a.name ?? name;
4145
4351
  for (let i = 0; i < (a.sequence.count ?? 0); i++) {
4146
4352
  const suffix = frameSuffix(a.sequence, i), asset = assetFor(base + suffix);
4147
4353
  if (!asset) continue;
@@ -4164,7 +4370,7 @@ function importSpine(json, atlasText, opts = {}) {
4164
4370
  }
4165
4371
  const type = a.type ?? "region", id = attachmentId(slotName, prefix + name);
4166
4372
  if (type === "region") {
4167
- const asset = assetFor(a.path ?? name);
4373
+ const asset = assetFor(a.path ?? a.name ?? name);
4168
4374
  if (!asset) continue;
4169
4375
  attachments.push({
4170
4376
  id,
@@ -4180,21 +4386,48 @@ function importSpine(json, atlasText, opts = {}) {
4180
4386
  ...a.height ? { height: a.height } : {}
4181
4387
  });
4182
4388
  } else if (type === "mesh" || type === "linkedmesh") {
4183
- if (type === "linkedmesh") {
4184
- warnings.push(`linked mesh "${slotName}/${name}" skipped`);
4185
- continue;
4186
- }
4187
- const asset = assetFor(a.path ?? name);
4389
+ const geometry = a.parent ? meshGeometry(skin2.name, slotName, name) : a;
4390
+ if (!geometry) continue;
4391
+ const asset = assetFor(a.path ?? a.name ?? name);
4188
4392
  if (!asset) continue;
4189
- const n = (a.uvs?.length ?? 0) / 2;
4190
- attachments.push({ id, slot, type: "mesh", asset, uvs: a.uvs, triangles: a.triangles, ...readVertices(a, n), hull: a.hull ?? 0, ...a.width ? { width: a.width } : {}, ...a.height ? { height: a.height } : {} });
4393
+ const n = (geometry.uvs?.length ?? 0) / 2;
4394
+ attachments.push({ id, slot, type: "mesh", asset, uvs: geometry.uvs, triangles: geometry.triangles, ...readVertices(geometry, n), hull: geometry.hull ?? 0, ...geometry.width ? { width: geometry.width } : {}, ...geometry.height ? { height: geometry.height } : {} });
4395
+ if (a.parent && (a.timelines ?? a.deform ?? true)) {
4396
+ const parentSkin = a.skin ?? "default";
4397
+ inheritedDeform.set(id, attachmentId(slotName, (parentSkin === "default" ? "" : `${parentSkin}_`) + a.parent));
4398
+ }
4191
4399
  } else if (type === "path") {
4192
4400
  attachments.push({ id, slot, type: "path", ...readVertices(a, a.vertexCount ?? 0), lengths: a.lengths ?? [], closed: !!a.closed, constantSpeed: a.constantSpeed ?? true });
4401
+ } else if (type === "clipping") {
4402
+ const data = readVertices(a, a.vertexCount), n = a.vertexCount;
4403
+ const recipes = Array.from({ length: n }, (_, i) => [
4404
+ [[(i + n - 1) % n, 1 / 3], [i, 2 / 3]],
4405
+ [[i, 1]],
4406
+ [[i, 2 / 3], [(i + 1) % n, 1 / 3]]
4407
+ ]).flat();
4408
+ let geometry;
4409
+ if (data.weights) {
4410
+ const starts = [];
4411
+ let offset = 0;
4412
+ for (const list of data.weights) {
4413
+ starts.push(offset);
4414
+ offset += list.length * 2;
4415
+ }
4416
+ const mapping = recipes.flatMap((recipe) => recipe.flatMap(([index]) => data.weights[index].map((_, k) => starts[index] + k * 2)));
4417
+ geometry = { weights: recipes.map((recipe) => recipe.flatMap(([index, factor]) => data.weights[index].map((inf) => ({ ...inf, w: inf.w * factor })))) };
4418
+ clipDeforms.set(id, (offsets) => mapping.flatMap((index) => [offsets[index] ?? 0, offsets[index + 1] ?? 0]));
4419
+ } else {
4420
+ const expand = (values2) => recipes.flatMap((recipe) => [0, 1].map((axis) => recipe.reduce((sum, [index, factor]) => sum + (values2[index * 2 + axis] ?? 0) * factor, 0)));
4421
+ geometry = { vertices: expand(data.vertices) };
4422
+ clipDeforms.set(id, expand);
4423
+ }
4424
+ attachments.push({ id, slot, type: "path", ...geometry, closed: true, clipping: { ...a.end ? { end: slotIds.get(a.end) } : {} } });
4193
4425
  } else warnings.push(`attachment "${slotName}/${name}" of type ${type} skipped`);
4194
4426
  }
4195
4427
  }
4196
4428
  }
4197
- const ik = (json.ik ?? []).map((c) => ({
4429
+ const activeConstraint = (kind, c) => !c.skin || lookupSkins.some((s) => s[kind]?.includes(c.name));
4430
+ const ik = (json.ik ?? []).filter((c) => activeConstraint("ik", c)).map((c) => ({
4198
4431
  id: ikIds.get(c.name),
4199
4432
  order: c.order ?? 0,
4200
4433
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4207,7 +4440,7 @@ function importSpine(json, atlasText, opts = {}) {
4207
4440
  stretch: !!c.stretch,
4208
4441
  uniform: !!c.uniform
4209
4442
  }));
4210
- const transform = (json.transform ?? []).map((c) => ({
4443
+ const transform = (json.transform ?? []).filter((c) => activeConstraint("transform", c)).map((c) => ({
4211
4444
  id: tcIds.get(c.name),
4212
4445
  order: c.order ?? 0,
4213
4446
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4227,7 +4460,7 @@ function importSpine(json, atlasText, opts = {}) {
4227
4460
  mixScaleY: c.mixScaleY ?? c.mixScaleX ?? c.scaleMix ?? 1,
4228
4461
  mixShearY: c.mixShearY ?? c.shearMix ?? 1
4229
4462
  }));
4230
- const path = (json.path ?? []).map((c) => ({
4463
+ const path = (json.path ?? []).filter((c) => activeConstraint("path", c)).map((c) => ({
4231
4464
  id: pcIds.get(c.name),
4232
4465
  order: c.order ?? 0,
4233
4466
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4262,7 +4495,21 @@ function importSpine(json, atlasText, opts = {}) {
4262
4495
  for (let i = 0; i < frames.length; i++) {
4263
4496
  const f = frames[i], next = frames[i + 1], t = f.time ?? 0;
4264
4497
  const ease2 = next ? easeOf(f.curve, comp, t, next.time ?? 0, f[field] ?? def, next[field] ?? def) : "linear";
4265
- keys.push({ t: r4(t), v: r4(abs(f[field] ?? def)), ease: ease2 });
4498
+ const curve = major >= 4 && Array.isArray(f.curve) ? f.curve.slice(comp * 4, comp * 4 + 4) : [];
4499
+ const value = f[field] ?? def;
4500
+ if (next && value === (next[field] ?? def) && curve.length === 4 && (curve[1] !== value || curve[3] !== value)) {
4501
+ const cubic = (p0, p1, p2, p3, u) => (1 - u) ** 3 * p0 + 3 * (1 - u) ** 2 * u * p1 + 3 * (1 - u) * u * u * p2 + u ** 3 * p3;
4502
+ const inner = Array.from({ length: 9 }, (_, k) => {
4503
+ const u = (k + 1) / 10;
4504
+ return { t: r4(cubic(t, curve[0], curve[2], next.time ?? 0, u)), v: r4(abs(cubic(value, curve[1], curve[3], value, u))), ease: "linear" };
4505
+ });
4506
+ if (inner.every((k, i2) => k.t > (i2 ? inner[i2 - 1].t : t) && k.t < (next.time ?? 0))) {
4507
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: "linear" }, ...inner);
4508
+ continue;
4509
+ }
4510
+ warnings.push(`equal-endpoint ${field} curve at ${t}s has non-monotonic control times and could not be lowered`);
4511
+ }
4512
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: ease2 });
4266
4513
  }
4267
4514
  return keys;
4268
4515
  };
@@ -4273,6 +4520,10 @@ function importSpine(json, atlasText, opts = {}) {
4273
4520
  const seen = (frames) => {
4274
4521
  for (const f of frames) duration = Math.max(duration, f.time ?? 0);
4275
4522
  };
4523
+ if (a.physics) {
4524
+ warnings.push(`${animName}: Spine physics timelines are not imported`);
4525
+ for (const timelines of Object.values(a.physics)) for (const frames of Object.values(timelines)) seen(frames);
4526
+ }
4276
4527
  for (const [boneName, tl] of Object.entries(a.bones ?? {})) {
4277
4528
  const sb = spineBones[boneIndexByName.get(boneName)];
4278
4529
  if (!sb) {
@@ -4313,19 +4564,22 @@ function importSpine(json, atlasText, opts = {}) {
4313
4564
  if (kind === "attachment") {
4314
4565
  attachmentFrames.set(slotName, frames);
4315
4566
  tracks.push(track("slot", id, "attachment", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.name ? attachmentRef(slotName, f.name) : null, ease: "step" }))));
4316
- } else if (kind === "rgba" || kind === "color") {
4567
+ } else if (["rgba", "color", "rgb", "rgba2", "rgb2"].includes(kind)) {
4568
+ const setupAlpha = parseInt(hex8(spineSlots[slotIndexByName.get(slotName)].color).slice(7), 16) / 255;
4569
+ const rgbOnly = kind === "rgb" || kind === "rgb2";
4317
4570
  const rgbaOf = (f) => {
4318
- const c = hex8(f.color);
4319
- return [0, 2, 4, 6].map((k) => parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4571
+ const c = hex8(f.color ?? f.light);
4572
+ return [0, 2, 4, 6].map((k, i) => i === 3 && rgbOnly ? setupAlpha : parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4320
4573
  };
4574
+ if (kind.endsWith("2")) warnings.push(`${animName}/${slotName}: ${kind} light color imported; dark two-color tint is not supported`);
4321
4575
  tracks.push(track("slot", id, "color", frames.map((f, i) => {
4322
4576
  const rgba = rgbaOf(f), next = frames[i + 1];
4323
- return { t: r4(f.time ?? 0), v: rgba.map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, rgba[0], rgbaOf(next)[0]) : "linear" };
4324
- })));
4325
- } else if (kind === "rgb") {
4326
- tracks.push(track("slot", id, "color", frames.map((f) => {
4327
- const c = hex8((f.color ?? "ffffff") + "ff");
4328
- return { t: r4(f.time ?? 0), v: [0, 2, 4, 6].map((k) => r4(parseInt(c.slice(1 + k, 3 + k), 16) / 255)), ease: "linear" };
4577
+ return {
4578
+ t: r4(f.time ?? 0),
4579
+ v: rgba.map(r4),
4580
+ ease: "linear",
4581
+ ...next ? { componentEase: rgba.map((v, k) => rgbOnly && k === 3 ? "linear" : easeOf(f.curve, k, f.time ?? 0, next.time ?? 0, v, rgbaOf(next)[k])) } : {}
4582
+ };
4329
4583
  })));
4330
4584
  } else if (kind === "alpha") tracks.push(track("slot", id, "alpha", numeric(frames, "value", 1, (v) => v, 0)));
4331
4585
  else warnings.push(`${animName}/${slotName}: ${kind} timeline skipped`);
@@ -4364,15 +4618,30 @@ function importSpine(json, atlasText, opts = {}) {
4364
4618
  warnings.push(`${animName}: deform of unknown attachment ${slotName}/${attName}`);
4365
4619
  continue;
4366
4620
  }
4367
- const length = att.weights ? 2 * att.weights.reduce((s, l) => s + l.length, 0) : att.vertices?.length ?? 0;
4621
+ const rawClip = skins.find((s) => s.name === skinName)?.attachments?.[slotName]?.[attName];
4622
+ const clipData = clipDeforms.has(id) ? readVertices(rawClip, rawClip.vertexCount) : void 0;
4623
+ const data = clipData ?? att;
4624
+ const length = data.weights ? 2 * data.weights.reduce((s, l) => s + l.length, 0) : data.vertices?.length ?? 0;
4368
4625
  tracks.push(track("deform", id, "vertices", frames.map((f, i) => {
4369
4626
  const full = new Array(length).fill(0);
4370
4627
  const off = f.offset ?? 0, vs = f.vertices ?? [];
4371
4628
  for (let k = 0; k < vs.length; k++) full[off + k] = (k + off) % 2 === 1 ? -vs[k] : vs[k];
4372
4629
  const next = frames[i + 1];
4373
- return { t: r4(f.time ?? 0), v: full.map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, 0, 1) : "linear" };
4630
+ return { t: r4(f.time ?? 0), v: (clipDeforms.get(id)?.(full) ?? full).map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, 0, 1) : "linear" };
4374
4631
  })));
4375
4632
  }
4633
+ for (let pass = 0; pass < inheritedDeform.size; pass++) {
4634
+ let changed = false;
4635
+ for (const [child, parent] of inheritedDeform) {
4636
+ if (tracks.some((t) => t.target === "deform" && t.id === child)) continue;
4637
+ const source = tracks.find((t) => t.target === "deform" && t.id === parent);
4638
+ if (source) {
4639
+ tracks.push({ ...source, id: child, keys: structuredClone(source.keys) });
4640
+ changed = true;
4641
+ }
4642
+ }
4643
+ if (!changed) break;
4644
+ }
4376
4645
  const drawOrderFrames = a.drawOrder ?? a.draworder;
4377
4646
  if (Array.isArray(drawOrderFrames)) {
4378
4647
  seen(drawOrderFrames);
@@ -4395,20 +4664,25 @@ function importSpine(json, atlasText, opts = {}) {
4395
4664
  for (const [name, frames] of Object.entries(a.ik ?? {})) {
4396
4665
  seen(frames);
4397
4666
  const id = ikIds.get(name);
4667
+ if (!ik.some((c) => c.id === id)) continue;
4398
4668
  tracks.push(track("ik", id, "mix", numeric(frames, "mix", 1, (v) => v, 0)));
4399
4669
  tracks.push(track("ik", id, "softness", numeric(frames, "softness", 0, (v) => v, 1)));
4400
4670
  tracks.push(track("ik", id, "bendPositive", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.bendPositive ?? true ? 0 : 1, ease: "step" }))));
4401
4671
  tracks.push(track("ik", id, "compress", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.compress ? 1 : 0, ease: "step" }))));
4402
4672
  tracks.push(track("ik", id, "stretch", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.stretch ? 1 : 0, ease: "step" }))));
4403
4673
  }
4404
- const mixTrack = (target, id, frames, prop, old, comp) => track(target, id, prop, frames.map((f, i) => ({
4405
- t: r4(f.time ?? 0),
4406
- v: r4(f[prop] ?? f[old] ?? 1),
4407
- ease: frames[i + 1] ? easeOf(f.curve, comp, f.time ?? 0, frames[i + 1].time ?? 0, f[prop] ?? f[old] ?? 1, frames[i + 1][prop] ?? frames[i + 1][old] ?? 1) : "linear"
4408
- })));
4674
+ const mixTrack = (target, id, frames, prop, old, comp) => {
4675
+ const value = (f) => f[prop] ?? (prop === "mixY" ? f.mixX : prop === "mixScaleY" ? f.mixScaleX : void 0) ?? f[old] ?? 1;
4676
+ return track(target, id, prop, frames.map((f, i) => ({
4677
+ t: r4(f.time ?? 0),
4678
+ v: r4(value(f)),
4679
+ ease: frames[i + 1] ? easeOf(f.curve, comp, f.time ?? 0, frames[i + 1].time ?? 0, value(f), value(frames[i + 1])) : "linear"
4680
+ })));
4681
+ };
4409
4682
  for (const [name, frames] of Object.entries(a.transform ?? {})) {
4410
4683
  seen(frames);
4411
4684
  const id = tcIds.get(name);
4685
+ if (!transform.some((c) => c.id === id)) continue;
4412
4686
  const props = [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2], ["mixScaleX", "scaleMix", 3], ["mixScaleY", "scaleMix", 4], ["mixShearY", "shearMix", 5]];
4413
4687
  for (const [prop, old, comp] of props) tracks.push(mixTrack("transform", id, frames, prop, old, comp));
4414
4688
  }
@@ -4416,6 +4690,7 @@ function importSpine(json, atlasText, opts = {}) {
4416
4690
  const id = pcIds.get(name);
4417
4691
  for (const [kind, frames] of Object.entries(tl)) {
4418
4692
  seen(frames);
4693
+ if (!path.some((c) => c.id === id)) continue;
4419
4694
  if (kind === "position" || kind === "spacing") tracks.push(track("path", id, kind, numeric(frames, "value", 0, (v) => v, 0)));
4420
4695
  else if (kind === "mix") for (const [prop, old, comp] of [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2]])
4421
4696
  tracks.push(mixTrack("path", id, frames, prop, old, comp));
@@ -4578,21 +4853,27 @@ async function importSpineProject(opts) {
4578
4853
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4579
4854
  const atlasText = await readFile7(atlasPath, "utf8");
4580
4855
  const { pages } = parseAtlas(atlasText);
4581
- if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
4582
- const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4583
- const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4856
+ if (!pages.length) throw new Error("atlas has no pages");
4857
+ if (opts.image && pages.length !== 1) throw new Error("image override requires a single-page atlas; multi-page atlases resolve each named page beside the atlas");
4584
4858
  const rigDir = dirname6(opts.rigPath);
4585
- let pngPath = imagePath;
4586
- if (extname(imagePath).toLowerCase() === ".ktx2") {
4587
- pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4588
- if (!await exists(pngPath)) {
4589
- const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4590
- await writeFile5(pngPath, png);
4859
+ const pagePaths = /* @__PURE__ */ new Map();
4860
+ for (const page2 of pages) {
4861
+ const pageStem = join8(dirname6(atlasPath), page2.name.replace(/\.[a-z0-9]+$/i, ""));
4862
+ const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), page2.name), pageStem + ".png", pageStem + ".ktx2", ...pages.length === 1 ? [stem + ".png", stem + ".ktx2"] : []], `atlas page image ${page2.name}`);
4863
+ let pngPath = imagePath;
4864
+ if (extname(imagePath).toLowerCase() === ".ktx2") {
4865
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4866
+ if (!await exists(pngPath)) {
4867
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4868
+ await writeFile5(pngPath, png);
4869
+ }
4591
4870
  }
4871
+ pagePaths.set(page2.name, pngPath);
4592
4872
  }
4593
- const pageImage = relative(rigDir, pngPath).split("\\").join("/");
4873
+ const pageImage = (page2) => relative(rigDir, pagePaths.get(page2)).split("\\").join("/");
4594
4874
  const name = opts.name ?? basename4(stem);
4595
- return { ...importSpine(json, atlasText, { name, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: pngPath };
4875
+ const imagePaths = [...pagePaths.values()];
4876
+ return { ...importSpine(json, atlasText, { name, skin: opts.skin, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: imagePaths[0], imagePaths };
4596
4877
  }
4597
4878
 
4598
4879
  // src/rig-templates.ts
@@ -4609,7 +4890,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4609
4890
  const tracks = [];
4610
4891
  const skipped = [];
4611
4892
  for (const t of anim.tracks) {
4612
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4893
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4613
4894
  skipped.push(trackId5(t));
4614
4895
  continue;
4615
4896
  }
@@ -4984,8 +5265,8 @@ function runGates(doc, config) {
4984
5265
  let active2 = 0, bad = 0;
4985
5266
  for (const t of ts) {
4986
5267
  const pose = poseOf(animation, t), s = evaluate(doc, pose);
4987
- const color = pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
4988
- const alpha = Number(pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
5268
+ const color = s.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5269
+ const alpha = Number(s.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
4989
5270
  if (!s.attachment.get(slot.id) || alpha <= 0.01) continue;
4990
5271
  active2++;
4991
5272
  const v = bone(s, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
@@ -5125,13 +5406,14 @@ function attachToBone(doc, animation, bone, target, t) {
5125
5406
  if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5126
5407
  const [x, y] = apply(invert(to), from.tx, from.ty);
5127
5408
  const id = `attach_${bone}_${target}`;
5409
+ const handedness = to.a * to.d - to.b * to.c < 0 ? -1 : 1;
5128
5410
  const offset = {
5129
5411
  x,
5130
5412
  y,
5131
- rotation: wrap2(rotationOf(from) - rotationOf(to)),
5413
+ rotation: wrap2(rotationOf(from) - rotationOf(to)) * handedness,
5132
5414
  scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5133
5415
  scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5134
- shearY: wrap2((Math.atan2(from.d, from.c) - Math.atan2(from.b, from.a) - Math.atan2(to.d, to.c) + Math.atan2(to.b, to.a)) * 180 / Math.PI)
5416
+ 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) * handedness
5135
5417
  };
5136
5418
  const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5137
5419
  let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
@@ -5207,6 +5489,53 @@ function bakeSecondary(doc, animation, opts) {
5207
5489
  return next;
5208
5490
  }
5209
5491
 
5492
+ // src/rig-control-qa.ts
5493
+ function snapshot(doc, values2) {
5494
+ const skel = evaluate(doc, controlPose(doc, values2));
5495
+ return doc.slots.flatMap((slot) => {
5496
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5497
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5498
+ return points ? [{ id: slot.id, alpha, points }] : [];
5499
+ });
5500
+ }
5501
+ function checkControlCases(doc, cases) {
5502
+ const errors = [];
5503
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5504
+ cases.forEach((c, index) => {
5505
+ const skel = evaluate(doc, controlPose(doc, c.values));
5506
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5507
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5508
+ for (const att of doc.attachments) if (att.type === "mesh") {
5509
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5510
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5511
+ 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))
5512
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5513
+ }
5514
+ });
5515
+ const neutral = JSON.stringify(snapshot(doc, {}));
5516
+ const effects = (doc.controls ?? []).map((c) => ({
5517
+ id: c.id,
5518
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5519
+ }));
5520
+ return {
5521
+ passed: errors.length === 0,
5522
+ errors,
5523
+ effects,
5524
+ 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."
5525
+ };
5526
+ }
5527
+ function makeControlSweep(doc, probes) {
5528
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5529
+ const animation = {
5530
+ id: "control_sweep",
5531
+ duration: Math.max(1, cases.length - 1),
5532
+ loop: false,
5533
+ events: [],
5534
+ 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" })) }))
5535
+ };
5536
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5537
+ }
5538
+
5210
5539
  // src/rig-api.ts
5211
5540
  var tools = [];
5212
5541
  function tool(name, description, input, run) {
@@ -5270,14 +5599,88 @@ function previewCheck(doc, animation) {
5270
5599
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
5271
5600
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
5272
5601
  }
5273
- var TrackRef = { interpolation: z3.enum(["segment", "pchip"]).optional().describe("pchip: smooth through numeric keys, preserving extrema; segment: per-key easing"), target: z3.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z3.string(), prop: z3.string() };
5602
+ 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() };
5603
+ tool(
5604
+ "rig_set_control",
5605
+ "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.",
5606
+ { path: Path, control: RigControl },
5607
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5608
+ );
5609
+ tool(
5610
+ "rig_set_control_binding",
5611
+ "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.",
5612
+ { path: Path, binding: ControlBinding },
5613
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5614
+ );
5615
+ tool(
5616
+ "rig_remove_control_binding",
5617
+ "Remove a control binding, restoring its channels to setup/animation values.",
5618
+ { path: Path, id: z3.string() },
5619
+ async ({ path, id }) => mutate(path, (d) => {
5620
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5621
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5622
+ })
5623
+ );
5624
+ tool(
5625
+ "rig_remove_control",
5626
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5627
+ { path: Path, id: z3.string() },
5628
+ async ({ path, id }) => mutate(path, (d) => {
5629
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5630
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5631
+ 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) })) };
5632
+ })
5633
+ );
5634
+ tool(
5635
+ "rig_sweep_controls",
5636
+ "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.",
5637
+ {
5638
+ path: Path,
5639
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5640
+ output: z3.string(),
5641
+ scale: z3.number().positive().max(2).default(0.5),
5642
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5643
+ },
5644
+ async ({ path, probes, output, scale, region }) => {
5645
+ try {
5646
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5647
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5648
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5649
+ const qa = checkControlCases(d, sweep.cases);
5650
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5651
+ await mkdir6(out, { recursive: true });
5652
+ const frames = [];
5653
+ for (let i = 0; i < r.frames.length; i++) {
5654
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5655
+ await writeFile7(join9(out, file), r.frames[i]);
5656
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5657
+ }
5658
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5659
+ const report = {
5660
+ version: 1,
5661
+ sourceRig: sha256(raw),
5662
+ identity: r.identity,
5663
+ scale,
5664
+ region: region ?? null,
5665
+ ...qa,
5666
+ frames,
5667
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5668
+ };
5669
+ await saveJson(join9(out, "report.json"), report);
5670
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5671
+ } catch (e) {
5672
+ return fail(e);
5673
+ }
5674
+ }
5675
+ );
5274
5676
  var KeyIn = z3.object({
5275
5677
  t: z3.number().describe("seconds"),
5276
5678
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5679
+ componentEase: z3.array(Ease).min(1).optional().describe("optional independent easing per numeric-array component"),
5277
5680
  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")
5278
5681
  });
5279
5682
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
5280
- 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].";
5683
+ var KEY_DOC = "Keys are {t, v, ease?, componentEase?}; componentEase selects independent easing for numeric-array components. 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].";
5281
5684
  tool(
5282
5685
  "rig_create",
5283
5686
  "Create a new empty rig document with a root bone at bottom-center.",
@@ -5401,19 +5804,20 @@ tool(
5401
5804
  );
5402
5805
  tool(
5403
5806
  "rig_import_spine",
5404
- "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.",
5807
+ "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; named pages resolve beside the atlas, and KTX2 pages transcode to PNG next to rig.json. Read warnings for unsupported physics and dark tint. Then use rig_get / rig_render_preview / rig_check as with any rig.",
5405
5808
  {
5406
5809
  path: Path,
5407
5810
  json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5408
5811
  atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5409
5812
  image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5813
+ skin: z3.string().optional().describe("skin to flatten; default: default skin, or first skin if no default exists; no runtime skin switching"),
5410
5814
  name: z3.string().optional().describe("default: the json file name"),
5411
5815
  margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5412
5816
  notes: z3.string().optional()
5413
5817
  },
5414
- async ({ path, json, atlas, image, name, margin, notes }) => {
5818
+ async ({ path, json, atlas, image, name, skin: skin2, margin, notes }) => {
5415
5819
  try {
5416
- const r = await importSpineProject({ json: resolveFrom(path, json), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, margin, notes });
5820
+ const r = await importSpineProject({ json: resolveFrom(path, json), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, skin: skin2, margin, notes });
5417
5821
  const res = await create(path, parse(r.doc));
5418
5822
  if (r.warnings.length) res.text += `
5419
5823
  warnings:
@@ -6102,8 +6506,8 @@ tool(
6102
6506
  const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6103
6507
  if (saveTo) await writeFile7(saveTo, r.sheet);
6104
6508
  if (framesDir) {
6105
- const { mkdir: mkdir6 } = await import("node:fs/promises");
6106
- await mkdir6(framesDir, { recursive: true });
6509
+ const { mkdir: mkdir7 } = await import("node:fs/promises");
6510
+ await mkdir7(framesDir, { recursive: true });
6107
6511
  const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6108
6512
  for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6109
6513
  const manifest = {