@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.
@@ -1,6 +1,6 @@
1
1
  // src/editor/server.ts
2
2
  import { createServer } from "node:http";
3
- import { readFile as readFile10, stat as stat3, readdir as readdir4, mkdir as mkdir6, open } from "node:fs/promises";
3
+ import { readFile as readFile10, stat as stat3, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
4
4
  import { watch as fsWatch } from "node:fs";
5
5
  import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -173,7 +173,9 @@ var PathAttachment = z.object({
173
173
  /** curve lengths for constant-speed positioning */
174
174
  lengths: z.array(z.number()).default([]),
175
175
  closed: z.boolean().default(false),
176
- constantSpeed: z.boolean().default(true)
176
+ constantSpeed: z.boolean().default(true),
177
+ /** Spine-style active clipping span in the posed draw order; end slot is inclusive. */
178
+ clipping: z.object({ end: Id.optional() }).optional()
177
179
  });
178
180
  var Attachment = z.discriminatedUnion("type", [RegionAttachment, MeshAttachment, PathAttachment]);
179
181
  var IkConstraint = z.object({
@@ -242,10 +244,13 @@ var Key = z.object({
242
244
  t: z.number().nonnegative(),
243
245
  v: KeyValue,
244
246
  /** easing INTO the next key */
245
- ease: Ease.default("ease_in_out")
247
+ ease: Ease.default("ease_in_out"),
248
+ /** Optional independent easing for numeric-array components (for example Spine RGBA curves). */
249
+ componentEase: z.array(Ease).min(1).optional()
246
250
  });
247
251
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
248
252
  var Track = z.discriminatedUnion("target", [
253
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
249
254
  z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
250
255
  z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
251
256
  z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
@@ -268,6 +273,25 @@ var Animation = z.object({
268
273
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
269
274
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
270
275
  });
276
+ var RigControl = z.object({
277
+ id: Id,
278
+ label: z.string().optional(),
279
+ min: z.number().finite(),
280
+ max: z.number().finite(),
281
+ default: z.number().finite()
282
+ }).strict();
283
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
284
+ var ControlChannel = z.discriminatedUnion("target", [
285
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
286
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
287
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
288
+ ]);
289
+ var ControlBinding = z.object({
290
+ id: Id,
291
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
292
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
293
+ channels: z.array(ControlChannel).min(1)
294
+ }).strict();
271
295
  var Reference = z.object({
272
296
  src: z.string(),
273
297
  opacity: z.number().min(0).max(1).default(0.5),
@@ -293,7 +317,9 @@ var RigSchema = z.object({
293
317
  ik: z.array(IkConstraint).default([]),
294
318
  transform: z.array(TransformConstraint).default([]),
295
319
  path: z.array(PathConstraint).default([]),
296
- animations: z.array(Animation)
320
+ animations: z.array(Animation),
321
+ controls: z.array(RigControl).optional(),
322
+ controlBindings: z.array(ControlBinding).optional()
297
323
  });
298
324
  var RigDocument = z.preprocess((input) => {
299
325
  const v = input?.version;
@@ -368,6 +394,8 @@ function validate(doc) {
368
394
  dup(doc.ik, "ik");
369
395
  dup(doc.transform, "transform");
370
396
  dup(doc.path, "path");
397
+ dup(doc.controls ?? [], "control");
398
+ dup(doc.controlBindings ?? [], "control binding");
371
399
  const bones = new Set(doc.bones.map((b) => b.id));
372
400
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
373
401
  const slots = new Set(doc.slots.map((s) => s.id));
@@ -405,6 +433,10 @@ function validate(doc) {
405
433
  else if (a.slot !== s.id) errs.push(`slot "${s.id}": attachment "${s.attachment}" belongs to slot "${a.slot}"`);
406
434
  }
407
435
  }
436
+ for (const a of doc.attachments) if (a.type === "path" && a.clipping) {
437
+ if (!a.closed) errs.push(`attachment "${a.id}": clipping path must be closed`);
438
+ if (a.clipping.end && !slots.has(a.clipping.end)) errs.push(`attachment "${a.id}": unknown clipping end slot "${a.clipping.end}"`);
439
+ }
408
440
  const vertexCount2 = (a) => {
409
441
  if (a.type === "region") return 4;
410
442
  if (a.type === "mesh") return a.uvs.length / 2;
@@ -442,20 +474,59 @@ function validate(doc) {
442
474
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
443
475
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
444
476
  }
477
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
478
+ const driven = /* @__PURE__ */ new Set();
479
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
480
+ errs.push(`control "${c.id}": require min < max and default within range`);
481
+ for (const b of doc.controlBindings ?? []) {
482
+ const prefix = `control binding "${b.id}"`;
483
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
484
+ for (const axis of b.axes) {
485
+ const c = controls.get(axis.control);
486
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
487
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
488
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
489
+ }
490
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
491
+ for (const ch of b.channels) {
492
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
493
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
494
+ driven.add(key);
495
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
496
+ if (ch.target === "deform") {
497
+ const att = attachments.get(ch.id);
498
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
499
+ else {
500
+ const n = att.weights ? att.weights.reduce((s, a) => s + a.length * 2, 0) : att.vertices.length;
501
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
502
+ }
503
+ } else {
504
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
505
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
506
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
507
+ }
508
+ }
509
+ }
445
510
  for (const a of doc.animations) {
446
511
  const tracks = /* @__PURE__ */ new Set();
447
512
  for (const t of a.tracks) {
448
513
  const tid = trackId(t);
449
514
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
450
515
  tracks.add(tid);
451
- 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;
516
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
517
+ 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;
452
518
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
453
519
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
454
520
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
455
521
  let prev = -1;
456
522
  for (const k of t.keys) {
523
+ 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`);
457
524
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
458
525
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
526
+ if (t.target === "control") {
527
+ const c = controls.get(t.id);
528
+ 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`);
529
+ }
459
530
  if (t.target === "slot" && t.prop === "attachment") {
460
531
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
461
532
  else if (typeof k.v === "string") {
@@ -496,7 +567,7 @@ function parse(input) {
496
567
 
497
568
  // src/rig-api.ts
498
569
  import { z as z3 } from "zod";
499
- import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
570
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
500
571
  import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
501
572
 
502
573
  // src/rig-io.ts
@@ -544,6 +615,65 @@ async function withRigQueue(path, op) {
544
615
  }
545
616
  }
546
617
 
618
+ // src/rig-controls.ts
619
+ function controlPose(doc, values2) {
620
+ const out = /* @__PURE__ */ new Map();
621
+ for (const [id, value] of Object.entries(values2)) {
622
+ const c = doc.controls?.find((c2) => c2.id === id);
623
+ if (!c) throw new Error(`unknown control "${id}"`);
624
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
625
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
626
+ }
627
+ return out;
628
+ }
629
+ function bracket(keys, value) {
630
+ let i = 0;
631
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
632
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
633
+ }
634
+ function sampleBinding(binding, values2) {
635
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
636
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
637
+ const width = binding.axes[0].keys.length, a = y * width + x;
638
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
639
+ return binding.channels.map((ch) => {
640
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
641
+ const at = (j2) => {
642
+ const top = mix(read(a, j2), read(a + 1, j2), u);
643
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
644
+ };
645
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
646
+ });
647
+ }
648
+ function resolveControls(doc, input) {
649
+ if (!doc.controlBindings?.length) return input;
650
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
651
+ for (const b of doc.controlBindings) {
652
+ const values2 = b.axes.map((axis) => {
653
+ const c = controls.get(axis.control);
654
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
655
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
656
+ return Math.max(c.min, Math.min(c.max, value));
657
+ });
658
+ const sampled2 = sampleBinding(b, values2);
659
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
660
+ }
661
+ return out;
662
+ }
663
+ function controlCases(doc, probes) {
664
+ let cases = [{ label: "default", values: {} }];
665
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
666
+ for (const [id, values2] of Object.entries(probes)) {
667
+ const c = doc.controls?.find((c2) => c2.id === id);
668
+ if (!c) throw new Error(`unknown control "${id}"`);
669
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
670
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
671
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
672
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
673
+ }
674
+ return cases;
675
+ }
676
+
547
677
  // src/rig-anim.ts
548
678
  var DEG2 = Math.PI / 180;
549
679
  var named = {
@@ -618,8 +748,8 @@ function sample(track, t, loop = false) {
618
748
  }
619
749
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
620
750
  if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
621
- const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
622
- return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
751
+ const phase = (t - a.t) / (b.t - a.t), av = a.v, bv = b.v;
752
+ return av.map((v, k) => v + ((bv[k] ?? v) - v) * ease(a.componentEase?.[k] ?? a.ease)(phase));
623
753
  }
624
754
  return a.v;
625
755
  }
@@ -647,6 +777,7 @@ function blendPoses(from, to, w, restOf) {
647
777
  }
648
778
  function restValue(doc, key) {
649
779
  const [target, id, prop] = key.split(":");
780
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
650
781
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
651
782
  if (target === "slot") {
652
783
  const s = doc.slots.find((s2) => s2.id === id);
@@ -710,6 +841,13 @@ function boneWorldOf(bone, s, parent) {
710
841
  if (!parent) return local;
711
842
  if (bone.inherit === "normal") return mul(parent, local);
712
843
  const [tx, ty] = apply(parent, s.x, s.y);
844
+ if (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection") {
845
+ 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);
846
+ const length = Math.hypot(x, y), a = length > 1e-5 ? x / length : x, b = length > 1e-5 ? y / length : y;
847
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
848
+ const basis3 = { a, b, c: -b * sign, d: a * sign, tx: 0, ty: 0 };
849
+ return { ...mul(basis3, affineOf(0, 0, 0, s.scaleX, s.scaleY, s.shearX, s.shearY)), tx, ty };
850
+ }
713
851
  return { ...mul(inheritedParentLinear(bone, parent), { ...local, tx: 0, ty: 0 }), tx, ty };
714
852
  }
715
853
  var orderCache = /* @__PURE__ */ new WeakMap();
@@ -742,18 +880,20 @@ var localOf = (doc, pose, b) => {
742
880
  shearY: num(pose, pk("bone", id, "shearY"), r.shearY)
743
881
  };
744
882
  };
745
- function updateSubtree(skel, root2) {
883
+ function updateSubtree(skel, root2, includeRoot = true) {
746
884
  const doc = skel.doc;
747
885
  const kids = /* @__PURE__ */ new Map();
748
886
  for (const b of doc.bones) if (b.parent) (kids.get(b.parent) ?? kids.set(b.parent, []).get(b.parent)).push(b);
749
- const stack = [root2];
887
+ const stack = includeRoot ? [root2] : (kids.get(root2) ?? []).map((b) => b.id);
750
888
  while (stack.length) {
751
889
  const id = stack.pop(), b = boneOf(doc, id), st = skel.bones.get(id);
752
890
  st.world = boneWorldOf(b, st, b.parent ? skel.bones.get(b.parent).world : void 0);
753
891
  for (const k of kids.get(id) ?? []) stack.push(k.id);
754
892
  }
755
893
  }
756
- function evaluate(doc, pose) {
894
+ function evaluate(doc, pose, modifiers) {
895
+ pose = resolveControls(doc, pose);
896
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
757
897
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
758
898
  for (const b of boneOrder(doc)) {
759
899
  const s = localOf(doc, pose, b);
@@ -987,7 +1127,8 @@ function applyTransformConstraint(skel, c) {
987
1127
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
988
1128
  c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
989
1129
  const target = skel.bones.get(c.target), tw = target.world;
990
- const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
1130
+ const handedness = tw.a * tw.d - tw.b * tw.c < 0 ? -1 : 1;
1131
+ const offsetRotation = c.rotation * DEG2 * (c.local ? 1 : handedness), offsetShearY = c.shearY * DEG2 * (c.local ? 1 : handedness);
991
1132
  for (const id of c.bones) {
992
1133
  const b = boneOf(skel.doc, id), st = skel.bones.get(id);
993
1134
  if (c.local) {
@@ -1149,6 +1290,7 @@ function applyPathConstraint(skel, c) {
1149
1290
  const position = cnum(skel, "path", c.id, "position", c.position), spacing = cnum(skel, "path", c.id, "spacing", c.spacing);
1150
1291
  const tangents = c.rotateMode === "tangent", scale = c.rotateMode === "chainScale";
1151
1292
  const bones = c.bones.map((id) => ({ b: boneOf(skel.doc, id), st: skel.bones.get(id) }));
1293
+ const inputWorlds = bones.map(({ st }) => ({ ...st.world }));
1152
1294
  const spacesCount = tangents ? bones.length : bones.length + 1;
1153
1295
  const spaces = new Array(spacesCount).fill(0), worldLengths = [];
1154
1296
  let sum = 0;
@@ -1183,9 +1325,10 @@ function applyPathConstraint(skel, c) {
1183
1325
  if (tw.a * tw.d - tw.b * tw.c < 0) offsetRotation = -offsetRotation;
1184
1326
  }
1185
1327
  let boneX = samples[0].x, boneY = samples[0].y;
1328
+ const solvedWorlds = /* @__PURE__ */ new Map();
1186
1329
  for (let i = 0; i < bones.length; i++) {
1187
1330
  const { b, st } = bones[i];
1188
- let w = { ...st.world };
1331
+ let w = { ...inputWorlds[i] };
1189
1332
  w.tx += (boneX - w.tx) * mixX;
1190
1333
  w.ty += (boneY - w.ty) * mixY;
1191
1334
  const next = samples[i + 1] ?? { x: 0, y: 0, angle: 0 };
@@ -1215,7 +1358,11 @@ function applyPathConstraint(skel, c) {
1215
1358
  const cos = Math.cos(r), sin = Math.sin(r);
1216
1359
  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 };
1217
1360
  }
1218
- setEffectiveWorld(skel, b, st, w);
1361
+ solvedWorlds.set(b.id, w);
1362
+ }
1363
+ for (const b of boneOrder(skel.doc)) {
1364
+ const world = solvedWorlds.get(b.id);
1365
+ if (world) setEffectiveWorld(skel, b, skel.bones.get(b.id), world);
1219
1366
  }
1220
1367
  }
1221
1368
  function measuredPathLength(points, closed, lengths, constantSpeed) {
@@ -1244,9 +1391,14 @@ function inheritedParentLinear(bone, parent) {
1244
1391
  case "onlyTranslation":
1245
1392
  return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
1246
1393
  case "noRotationOrReflection": {
1247
- const lenX = Math.hypot(parent.a, parent.b) || 1;
1394
+ const lenX = Math.hypot(parent.a, parent.b);
1248
1395
  const det = parent.a * parent.d - parent.b * parent.c;
1249
- return { a: lenX, b: 0, c: 0, d: Math.abs(det) / lenX, tx: 0, ty: 0 };
1396
+ if (lenX * lenX <= 1e-4) {
1397
+ const lenY = Math.hypot(parent.c, parent.d), ux2 = lenY ? parent.c / lenY : 0, uy2 = lenY ? parent.d / lenY : 0;
1398
+ return { a: lenY * ux2 * ux2, b: lenY * ux2 * uy2, c: lenY * ux2 * uy2, d: lenY * uy2 * uy2, tx: 0, ty: 0 };
1399
+ }
1400
+ const ux = parent.a / lenX, uy = parent.b / lenX, sy = Math.abs(det) / lenX;
1401
+ 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 };
1250
1402
  }
1251
1403
  case "noScale":
1252
1404
  case "noScaleOrReflection": {
@@ -1272,7 +1424,16 @@ function setEffectiveWorld(skel, bone, st, world) {
1272
1424
  st.scaleY = Math.hypot(lin.c, lin.d);
1273
1425
  st.shearX = 0;
1274
1426
  st.shearY = shortest(Math.atan2(lin.d, lin.c) / DEG2 - st.rotation - 90);
1275
- updateSubtree(skel, bone.id);
1427
+ if (parent && (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection")) {
1428
+ const direction = mul(invert({ ...parent, tx: 0, ty: 0 }), { ...world, tx: 0, ty: 0 });
1429
+ st.rotation = Math.atan2(direction.b, direction.a) / DEG2;
1430
+ st.scaleX = Math.hypot(world.a, world.b);
1431
+ st.scaleY = Math.hypot(world.c, world.d);
1432
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
1433
+ st.shearY = shortest((Math.atan2(world.d, world.c) - Math.atan2(world.b, world.a)) / DEG2 * sign - 90);
1434
+ }
1435
+ st.world = world;
1436
+ updateSubtree(skel, bone.id, false);
1276
1437
  }
1277
1438
  function attachmentWorldVertices(skel, slotId, att) {
1278
1439
  const deform = skel.pose.get(pk("deform", att.id, "vertices"));
@@ -1979,6 +2140,9 @@ function updateBone(doc, id, patch) {
1979
2140
  const i = doc.bones.findIndex((b) => b.id === id);
1980
2141
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1981
2142
  const cur = doc.bones[i];
2143
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2144
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2145
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1982
2146
  const { parent, ...rest } = patch;
1983
2147
  const next = RigSchema.shape.bones.element.parse({
1984
2148
  ...cur,
@@ -2011,6 +2175,11 @@ function patchDrawOrder(doc, fn) {
2011
2175
  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) })) }) })) };
2012
2176
  }
2013
2177
  function setSlot(doc, slot) {
2178
+ if (slot.alpha !== void 0) {
2179
+ for (const b of doc.controlBindings ?? [])
2180
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2181
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2182
+ }
2014
2183
  const cur = doc.slots.find((s) => s.id === slot.id);
2015
2184
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
2016
2185
  const base = doc.slots.find((s) => s.id === slot.id);
@@ -2023,7 +2192,7 @@ function removeSlot(doc, id) {
2023
2192
  return patchDrawOrder({
2024
2193
  ...doc,
2025
2194
  slots: doc.slots.filter((s) => s.id !== id).map((s) => gone.has(s.clip ?? "") ? { ...s, clip: void 0 } : s),
2026
- attachments: doc.attachments.filter((a) => a.slot !== id),
2195
+ attachments: doc.attachments.filter((a) => a.slot !== id).map((a) => a.type === "path" && a.clipping?.end === id ? { ...a, clipping: {} } : a),
2027
2196
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
2028
2197
  }, (o) => o.filter((s) => s !== id));
2029
2198
  }
@@ -2385,6 +2554,7 @@ function trackSummary(t) {
2385
2554
  function rename2(doc, kind, id, to) {
2386
2555
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2387
2556
  if (to === id) return doc;
2557
+ 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) })) };
2388
2558
  const sub = (v) => v === id ? to : v;
2389
2559
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2390
2560
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2418,7 +2588,7 @@ function rename2(doc, kind, id, to) {
2418
2588
  return {
2419
2589
  ...doc,
2420
2590
  slots: doc.slots.map((s) => ({ ...s, id: sub(s.id) })),
2421
- attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot) })),
2591
+ attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot), ...a.type === "path" && a.clipping?.end ? { clipping: { end: sub(a.clipping.end) } } : {} })),
2422
2592
  path: doc.path.map((c) => ({ ...c, target: sub(c.target) }))
2423
2593
  };
2424
2594
  }
@@ -2466,6 +2636,8 @@ function describeAttachment(doc, att) {
2466
2636
  }
2467
2637
  function describe(doc, opts = {}) {
2468
2638
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2639
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2640
+ 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(", ")}`);
2469
2641
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2470
2642
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2471
2643
  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) {
@@ -5269,14 +5598,88 @@ function previewCheck(doc, animation) {
5269
5598
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
5270
5599
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
5271
5600
  }
5272
- var TrackRef = { interpolation: z3.enum(["segment", "pchip"]).optional().describe("pchip: smooth through numeric keys, preserving extrema; segment: per-key easing"), target: z3.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z3.string(), prop: z3.string() };
5601
+ 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() };
5602
+ tool(
5603
+ "rig_set_control",
5604
+ "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.",
5605
+ { path: Path, control: RigControl },
5606
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5607
+ );
5608
+ tool(
5609
+ "rig_set_control_binding",
5610
+ "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.",
5611
+ { path: Path, binding: ControlBinding },
5612
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5613
+ );
5614
+ tool(
5615
+ "rig_remove_control_binding",
5616
+ "Remove a control binding, restoring its channels to setup/animation values.",
5617
+ { path: Path, id: z3.string() },
5618
+ async ({ path, id }) => mutate(path, (d) => {
5619
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5620
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5621
+ })
5622
+ );
5623
+ tool(
5624
+ "rig_remove_control",
5625
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5626
+ { path: Path, id: z3.string() },
5627
+ async ({ path, id }) => mutate(path, (d) => {
5628
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5629
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5630
+ 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) })) };
5631
+ })
5632
+ );
5633
+ tool(
5634
+ "rig_sweep_controls",
5635
+ "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.",
5636
+ {
5637
+ path: Path,
5638
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5639
+ output: z3.string(),
5640
+ scale: z3.number().positive().max(2).default(0.5),
5641
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5642
+ },
5643
+ async ({ path, probes, output, scale, region }) => {
5644
+ try {
5645
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5646
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5647
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5648
+ const qa = checkControlCases(d, sweep.cases);
5649
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5650
+ await mkdir6(out, { recursive: true });
5651
+ const frames = [];
5652
+ for (let i = 0; i < r.frames.length; i++) {
5653
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5654
+ await writeFile7(join9(out, file), r.frames[i]);
5655
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5656
+ }
5657
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5658
+ const report = {
5659
+ version: 1,
5660
+ sourceRig: sha256(raw),
5661
+ identity: r.identity,
5662
+ scale,
5663
+ region: region ?? null,
5664
+ ...qa,
5665
+ frames,
5666
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5667
+ };
5668
+ await saveJson(join9(out, "report.json"), report);
5669
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5670
+ } catch (e) {
5671
+ return fail(e);
5672
+ }
5673
+ }
5674
+ );
5273
5675
  var KeyIn = z3.object({
5274
5676
  t: z3.number().describe("seconds"),
5275
5677
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5678
+ componentEase: z3.array(Ease).min(1).optional().describe("optional independent easing per numeric-array component"),
5276
5679
  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")
5277
5680
  });
5278
5681
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
5279
- var KEY_DOC = "Keys are {t, v, ease?}. Bone values are ABSOLUTE local-transform values, not offsets (rest x=4 \u2192 key v=0 moves it 4px); deform values are relative (0 = rest). bone: x|y|rotation|scaleX|scaleY|shearX|shearY; slot: alpha|attachment(asset id or null)|z; deform (3\xD73 mesh on the slot's image): bulge|bend|sway|squash (fractions of the image size, |v|\u22640.9/1) or p<row><col>x|y (px, rows/cols 0..2 top-left first). Some values are arrays: deform `vertices` keys are number arrays (2\xB7n offsets, or 2\xB7\u03A3influences for weighted meshes); drawOrder keys are slot-id arrays; slot color keys are [r,g,b,a].";
5682
+ 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].";
5280
5683
  tool(
5281
5684
  "rig_create",
5282
5685
  "Create a new empty rig document with a root bone at bottom-center.",
@@ -5400,19 +5803,20 @@ tool(
5400
5803
  );
5401
5804
  tool(
5402
5805
  "rig_import_spine",
5403
- "Import a Spine project (skeleton .json 3.8/4.x + .atlas + page image) as a new rig: bones, slots, region/mesh/path attachments with weights, IK / transform / path constraints and every animation (deform, draw order, colors), converted to this document's y-down space. The atlas may be plain or base64 text; a KTX2 page is transcoded to PNG next to rig.json. Then use rig_get / rig_render_preview / rig_check as with any rig.",
5806
+ "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.",
5404
5807
  {
5405
5808
  path: Path,
5406
5809
  json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5407
5810
  atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5408
5811
  image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5812
+ skin: z3.string().optional().describe("skin to flatten; default: default skin, or first skin if no default exists; no runtime skin switching"),
5409
5813
  name: z3.string().optional().describe("default: the json file name"),
5410
5814
  margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5411
5815
  notes: z3.string().optional()
5412
5816
  },
5413
- async ({ path, json, atlas, image, name, margin, notes }) => {
5817
+ async ({ path, json, atlas, image, name, skin: skin2, margin, notes }) => {
5414
5818
  try {
5415
- const r = await importSpineProject({ json: resolveFrom(path, json), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, margin, notes });
5819
+ 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 });
5416
5820
  const res = await create(path, parse(r.doc));
5417
5821
  if (r.warnings.length) res.text += `
5418
5822
  warnings:
@@ -6101,8 +6505,8 @@ tool(
6101
6505
  const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6102
6506
  if (saveTo) await writeFile7(saveTo, r.sheet);
6103
6507
  if (framesDir) {
6104
- const { mkdir: mkdir7 } = await import("node:fs/promises");
6105
- await mkdir7(framesDir, { recursive: true });
6508
+ const { mkdir: mkdir8 } = await import("node:fs/promises");
6509
+ await mkdir8(framesDir, { recursive: true });
6106
6510
  const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6107
6511
  for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6108
6512
  const manifest = {
@@ -6252,7 +6656,7 @@ data: ${JSON.stringify({ version: v })}
6252
6656
  async function listRigs() {
6253
6657
  const out = [];
6254
6658
  const walk = async (dir) => {
6255
- for (const e of await readdir4(dir, { withFileTypes: true })) {
6659
+ for (const e of await readdir5(dir, { withFileTypes: true })) {
6256
6660
  if (e.isDirectory()) {
6257
6661
  if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
6258
6662
  continue;
@@ -6272,7 +6676,7 @@ data: ${JSON.stringify({ version: v })}
6272
6676
  }
6273
6677
  async function listFiles(rigAbs) {
6274
6678
  const dir = dirname9(rigAbs), out = [];
6275
- for (const e of (await readdir4(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6679
+ 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))) {
6276
6680
  const fh = await open(join10(dir, e.name));
6277
6681
  try {
6278
6682
  const head = Buffer.alloc(24);
@@ -6390,7 +6794,7 @@ data: ${JSON.stringify({ version: v })}
6390
6794
  const abs = inside(rig);
6391
6795
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6392
6796
  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` });
6393
- await mkdir6(dirname9(abs), { recursive: true });
6797
+ await mkdir7(dirname9(abs), { recursive: true });
6394
6798
  const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
6395
6799
  ensureWatch(abs);
6396
6800
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });