@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/cli.js CHANGED
@@ -3,7 +3,7 @@ import { resolve as resolve3 } from "node:path";
3
3
 
4
4
  // src/editor/server.ts
5
5
  import { createServer } from "node:http";
6
- import { readFile as readFile10, stat as stat3, readdir as readdir4, mkdir as mkdir6, open } from "node:fs/promises";
6
+ import { readFile as readFile10, stat as stat3, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
7
7
  import { watch as fsWatch } from "node:fs";
8
8
  import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
9
9
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -176,7 +176,9 @@ var PathAttachment = z.object({
176
176
  /** curve lengths for constant-speed positioning */
177
177
  lengths: z.array(z.number()).default([]),
178
178
  closed: z.boolean().default(false),
179
- constantSpeed: z.boolean().default(true)
179
+ constantSpeed: z.boolean().default(true),
180
+ /** Spine-style active clipping span in the posed draw order; end slot is inclusive. */
181
+ clipping: z.object({ end: Id.optional() }).optional()
180
182
  });
181
183
  var Attachment = z.discriminatedUnion("type", [RegionAttachment, MeshAttachment, PathAttachment]);
182
184
  var IkConstraint = z.object({
@@ -245,10 +247,13 @@ var Key = z.object({
245
247
  t: z.number().nonnegative(),
246
248
  v: KeyValue,
247
249
  /** easing INTO the next key */
248
- ease: Ease.default("ease_in_out")
250
+ ease: Ease.default("ease_in_out"),
251
+ /** Optional independent easing for numeric-array components (for example Spine RGBA curves). */
252
+ componentEase: z.array(Ease).min(1).optional()
249
253
  });
250
254
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
251
255
  var Track = z.discriminatedUnion("target", [
256
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
252
257
  z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
253
258
  z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
254
259
  z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
@@ -271,6 +276,25 @@ var Animation = z.object({
271
276
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
272
277
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
273
278
  });
279
+ var RigControl = z.object({
280
+ id: Id,
281
+ label: z.string().optional(),
282
+ min: z.number().finite(),
283
+ max: z.number().finite(),
284
+ default: z.number().finite()
285
+ }).strict();
286
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
287
+ var ControlChannel = z.discriminatedUnion("target", [
288
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
289
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
290
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
291
+ ]);
292
+ var ControlBinding = z.object({
293
+ id: Id,
294
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
295
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
296
+ channels: z.array(ControlChannel).min(1)
297
+ }).strict();
274
298
  var Reference = z.object({
275
299
  src: z.string(),
276
300
  opacity: z.number().min(0).max(1).default(0.5),
@@ -296,7 +320,9 @@ var RigSchema = z.object({
296
320
  ik: z.array(IkConstraint).default([]),
297
321
  transform: z.array(TransformConstraint).default([]),
298
322
  path: z.array(PathConstraint).default([]),
299
- animations: z.array(Animation)
323
+ animations: z.array(Animation),
324
+ controls: z.array(RigControl).optional(),
325
+ controlBindings: z.array(ControlBinding).optional()
300
326
  });
301
327
  var RigDocument = z.preprocess((input) => {
302
328
  const v = input?.version;
@@ -371,6 +397,8 @@ function validate(doc) {
371
397
  dup(doc.ik, "ik");
372
398
  dup(doc.transform, "transform");
373
399
  dup(doc.path, "path");
400
+ dup(doc.controls ?? [], "control");
401
+ dup(doc.controlBindings ?? [], "control binding");
374
402
  const bones = new Set(doc.bones.map((b) => b.id));
375
403
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
376
404
  const slots = new Set(doc.slots.map((s2) => s2.id));
@@ -408,6 +436,10 @@ function validate(doc) {
408
436
  else if (a.slot !== s2.id) errs.push(`slot "${s2.id}": attachment "${s2.attachment}" belongs to slot "${a.slot}"`);
409
437
  }
410
438
  }
439
+ for (const a of doc.attachments) if (a.type === "path" && a.clipping) {
440
+ if (!a.closed) errs.push(`attachment "${a.id}": clipping path must be closed`);
441
+ if (a.clipping.end && !slots.has(a.clipping.end)) errs.push(`attachment "${a.id}": unknown clipping end slot "${a.clipping.end}"`);
442
+ }
411
443
  const vertexCount2 = (a) => {
412
444
  if (a.type === "region") return 4;
413
445
  if (a.type === "mesh") return a.uvs.length / 2;
@@ -445,20 +477,59 @@ function validate(doc) {
445
477
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
446
478
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
447
479
  }
480
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
481
+ const driven = /* @__PURE__ */ new Set();
482
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
483
+ errs.push(`control "${c.id}": require min < max and default within range`);
484
+ for (const b of doc.controlBindings ?? []) {
485
+ const prefix = `control binding "${b.id}"`;
486
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
487
+ for (const axis of b.axes) {
488
+ const c = controls.get(axis.control);
489
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
490
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
491
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
492
+ }
493
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
494
+ for (const ch of b.channels) {
495
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
496
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
497
+ driven.add(key);
498
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
499
+ if (ch.target === "deform") {
500
+ const att = attachments.get(ch.id);
501
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
502
+ else {
503
+ const n = att.weights ? att.weights.reduce((s2, a) => s2 + a.length * 2, 0) : att.vertices.length;
504
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
505
+ }
506
+ } else {
507
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
508
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
509
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
510
+ }
511
+ }
512
+ }
448
513
  for (const a of doc.animations) {
449
514
  const tracks = /* @__PURE__ */ new Set();
450
515
  for (const t of a.tracks) {
451
516
  const tid = trackId(t);
452
517
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
453
518
  tracks.add(tid);
454
- 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;
519
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
520
+ 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;
455
521
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
456
522
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
457
523
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
458
524
  let prev = -1;
459
525
  for (const k of t.keys) {
526
+ 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`);
460
527
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
461
528
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
529
+ if (t.target === "control") {
530
+ const c = controls.get(t.id);
531
+ 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`);
532
+ }
462
533
  if (t.target === "slot" && t.prop === "attachment") {
463
534
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
464
535
  else if (typeof k.v === "string") {
@@ -499,7 +570,7 @@ function parse(input) {
499
570
 
500
571
  // src/rig-api.ts
501
572
  import { z as z3 } from "zod";
502
- import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
573
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
503
574
  import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
504
575
 
505
576
  // src/rig-io.ts
@@ -547,6 +618,65 @@ async function withRigQueue(path, op) {
547
618
  }
548
619
  }
549
620
 
621
+ // src/rig-controls.ts
622
+ function controlPose(doc, values2) {
623
+ const out = /* @__PURE__ */ new Map();
624
+ for (const [id, value] of Object.entries(values2)) {
625
+ const c = doc.controls?.find((c2) => c2.id === id);
626
+ if (!c) throw new Error(`unknown control "${id}"`);
627
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
628
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
629
+ }
630
+ return out;
631
+ }
632
+ function bracket(keys, value) {
633
+ let i = 0;
634
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
635
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
636
+ }
637
+ function sampleBinding(binding, values2) {
638
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
639
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
640
+ const width = binding.axes[0].keys.length, a = y * width + x;
641
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
642
+ return binding.channels.map((ch) => {
643
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
644
+ const at = (j2) => {
645
+ const top = mix(read(a, j2), read(a + 1, j2), u);
646
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
647
+ };
648
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
649
+ });
650
+ }
651
+ function resolveControls(doc, input) {
652
+ if (!doc.controlBindings?.length) return input;
653
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
654
+ for (const b of doc.controlBindings) {
655
+ const values2 = b.axes.map((axis) => {
656
+ const c = controls.get(axis.control);
657
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
658
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
659
+ return Math.max(c.min, Math.min(c.max, value));
660
+ });
661
+ const sampled2 = sampleBinding(b, values2);
662
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
663
+ }
664
+ return out;
665
+ }
666
+ function controlCases(doc, probes) {
667
+ let cases = [{ label: "default", values: {} }];
668
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
669
+ for (const [id, values2] of Object.entries(probes)) {
670
+ const c = doc.controls?.find((c2) => c2.id === id);
671
+ if (!c) throw new Error(`unknown control "${id}"`);
672
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
673
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
674
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
675
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
676
+ }
677
+ return cases;
678
+ }
679
+
550
680
  // src/rig-anim.ts
551
681
  var DEG2 = Math.PI / 180;
552
682
  var named = {
@@ -621,8 +751,8 @@ function sample(track, t, loop = false) {
621
751
  }
622
752
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
623
753
  if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
624
- const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
625
- return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
754
+ const phase = (t - a.t) / (b.t - a.t), av = a.v, bv = b.v;
755
+ return av.map((v, k) => v + ((bv[k] ?? v) - v) * ease(a.componentEase?.[k] ?? a.ease)(phase));
626
756
  }
627
757
  return a.v;
628
758
  }
@@ -650,6 +780,7 @@ function blendPoses(from, to, w, restOf) {
650
780
  }
651
781
  function restValue(doc, key) {
652
782
  const [target, id, prop] = key.split(":");
783
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
653
784
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
654
785
  if (target === "slot") {
655
786
  const s2 = doc.slots.find((s3) => s3.id === id);
@@ -713,6 +844,13 @@ function boneWorldOf(bone, s2, parent) {
713
844
  if (!parent) return local;
714
845
  if (bone.inherit === "normal") return mul(parent, local);
715
846
  const [tx, ty] = apply(parent, s2.x, s2.y);
847
+ if (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection") {
848
+ const r = s2.rotation * DEG2, x = parent.a * Math.cos(r) + parent.c * Math.sin(r), y = parent.b * Math.cos(r) + parent.d * Math.sin(r);
849
+ const length = Math.hypot(x, y), a = length > 1e-5 ? x / length : x, b = length > 1e-5 ? y / length : y;
850
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
851
+ const basis3 = { a, b, c: -b * sign, d: a * sign, tx: 0, ty: 0 };
852
+ return { ...mul(basis3, affineOf(0, 0, 0, s2.scaleX, s2.scaleY, s2.shearX, s2.shearY)), tx, ty };
853
+ }
716
854
  return { ...mul(inheritedParentLinear(bone, parent), { ...local, tx: 0, ty: 0 }), tx, ty };
717
855
  }
718
856
  var orderCache = /* @__PURE__ */ new WeakMap();
@@ -745,18 +883,20 @@ var localOf = (doc, pose, b) => {
745
883
  shearY: num(pose, pk("bone", id, "shearY"), r.shearY)
746
884
  };
747
885
  };
748
- function updateSubtree(skel, root3) {
886
+ function updateSubtree(skel, root3, includeRoot = true) {
749
887
  const doc = skel.doc;
750
888
  const kids = /* @__PURE__ */ new Map();
751
889
  for (const b of doc.bones) if (b.parent) (kids.get(b.parent) ?? kids.set(b.parent, []).get(b.parent)).push(b);
752
- const stack = [root3];
890
+ const stack = includeRoot ? [root3] : (kids.get(root3) ?? []).map((b) => b.id);
753
891
  while (stack.length) {
754
892
  const id = stack.pop(), b = boneOf(doc, id), st = skel.bones.get(id);
755
893
  st.world = boneWorldOf(b, st, b.parent ? skel.bones.get(b.parent).world : void 0);
756
894
  for (const k of kids.get(id) ?? []) stack.push(k.id);
757
895
  }
758
896
  }
759
- function evaluate(doc, pose) {
897
+ function evaluate(doc, pose, modifiers) {
898
+ pose = resolveControls(doc, pose);
899
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
760
900
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
761
901
  for (const b of boneOrder(doc)) {
762
902
  const s2 = localOf(doc, pose, b);
@@ -990,7 +1130,8 @@ function applyTransformConstraint(skel, c) {
990
1130
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
991
1131
  c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
992
1132
  const target = skel.bones.get(c.target), tw = target.world;
993
- const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
1133
+ const handedness = tw.a * tw.d - tw.b * tw.c < 0 ? -1 : 1;
1134
+ const offsetRotation = c.rotation * DEG2 * (c.local ? 1 : handedness), offsetShearY = c.shearY * DEG2 * (c.local ? 1 : handedness);
994
1135
  for (const id of c.bones) {
995
1136
  const b = boneOf(skel.doc, id), st = skel.bones.get(id);
996
1137
  if (c.local) {
@@ -1152,6 +1293,7 @@ function applyPathConstraint(skel, c) {
1152
1293
  const position = cnum(skel, "path", c.id, "position", c.position), spacing = cnum(skel, "path", c.id, "spacing", c.spacing);
1153
1294
  const tangents = c.rotateMode === "tangent", scale = c.rotateMode === "chainScale";
1154
1295
  const bones = c.bones.map((id) => ({ b: boneOf(skel.doc, id), st: skel.bones.get(id) }));
1296
+ const inputWorlds = bones.map(({ st }) => ({ ...st.world }));
1155
1297
  const spacesCount = tangents ? bones.length : bones.length + 1;
1156
1298
  const spaces = new Array(spacesCount).fill(0), worldLengths = [];
1157
1299
  let sum = 0;
@@ -1186,9 +1328,10 @@ function applyPathConstraint(skel, c) {
1186
1328
  if (tw.a * tw.d - tw.b * tw.c < 0) offsetRotation = -offsetRotation;
1187
1329
  }
1188
1330
  let boneX = samples[0].x, boneY = samples[0].y;
1331
+ const solvedWorlds = /* @__PURE__ */ new Map();
1189
1332
  for (let i = 0; i < bones.length; i++) {
1190
1333
  const { b, st } = bones[i];
1191
- let w = { ...st.world };
1334
+ let w = { ...inputWorlds[i] };
1192
1335
  w.tx += (boneX - w.tx) * mixX;
1193
1336
  w.ty += (boneY - w.ty) * mixY;
1194
1337
  const next = samples[i + 1] ?? { x: 0, y: 0, angle: 0 };
@@ -1218,7 +1361,11 @@ function applyPathConstraint(skel, c) {
1218
1361
  const cos = Math.cos(r), sin = Math.sin(r);
1219
1362
  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 };
1220
1363
  }
1221
- setEffectiveWorld(skel, b, st, w);
1364
+ solvedWorlds.set(b.id, w);
1365
+ }
1366
+ for (const b of boneOrder(skel.doc)) {
1367
+ const world = solvedWorlds.get(b.id);
1368
+ if (world) setEffectiveWorld(skel, b, skel.bones.get(b.id), world);
1222
1369
  }
1223
1370
  }
1224
1371
  function measuredPathLength(points, closed, lengths, constantSpeed) {
@@ -1247,9 +1394,14 @@ function inheritedParentLinear(bone, parent) {
1247
1394
  case "onlyTranslation":
1248
1395
  return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
1249
1396
  case "noRotationOrReflection": {
1250
- const lenX = Math.hypot(parent.a, parent.b) || 1;
1397
+ const lenX = Math.hypot(parent.a, parent.b);
1251
1398
  const det = parent.a * parent.d - parent.b * parent.c;
1252
- return { a: lenX, b: 0, c: 0, d: Math.abs(det) / lenX, tx: 0, ty: 0 };
1399
+ if (lenX * lenX <= 1e-4) {
1400
+ const lenY = Math.hypot(parent.c, parent.d), ux2 = lenY ? parent.c / lenY : 0, uy2 = lenY ? parent.d / lenY : 0;
1401
+ return { a: lenY * ux2 * ux2, b: lenY * ux2 * uy2, c: lenY * ux2 * uy2, d: lenY * uy2 * uy2, tx: 0, ty: 0 };
1402
+ }
1403
+ const ux = parent.a / lenX, uy = parent.b / lenX, sy = Math.abs(det) / lenX;
1404
+ 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 };
1253
1405
  }
1254
1406
  case "noScale":
1255
1407
  case "noScaleOrReflection": {
@@ -1275,7 +1427,16 @@ function setEffectiveWorld(skel, bone, st, world) {
1275
1427
  st.scaleY = Math.hypot(lin.c, lin.d);
1276
1428
  st.shearX = 0;
1277
1429
  st.shearY = shortest(Math.atan2(lin.d, lin.c) / DEG2 - st.rotation - 90);
1278
- updateSubtree(skel, bone.id);
1430
+ if (parent && (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection")) {
1431
+ const direction = mul(invert({ ...parent, tx: 0, ty: 0 }), { ...world, tx: 0, ty: 0 });
1432
+ st.rotation = Math.atan2(direction.b, direction.a) / DEG2;
1433
+ st.scaleX = Math.hypot(world.a, world.b);
1434
+ st.scaleY = Math.hypot(world.c, world.d);
1435
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
1436
+ st.shearY = shortest((Math.atan2(world.d, world.c) - Math.atan2(world.b, world.a)) / DEG2 * sign - 90);
1437
+ }
1438
+ st.world = world;
1439
+ updateSubtree(skel, bone.id, false);
1279
1440
  }
1280
1441
  function attachmentWorldVertices(skel, slotId, att) {
1281
1442
  const deform = skel.pose.get(pk("deform", att.id, "vertices"));
@@ -1982,6 +2143,9 @@ function updateBone(doc, id, patch) {
1982
2143
  const i = doc.bones.findIndex((b) => b.id === id);
1983
2144
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1984
2145
  const cur = doc.bones[i];
2146
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2147
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2148
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1985
2149
  const { parent, ...rest } = patch;
1986
2150
  const next = RigSchema.shape.bones.element.parse({
1987
2151
  ...cur,
@@ -2014,6 +2178,11 @@ function patchDrawOrder(doc, fn) {
2014
2178
  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) })) }) })) };
2015
2179
  }
2016
2180
  function setSlot(doc, slot) {
2181
+ if (slot.alpha !== void 0) {
2182
+ for (const b of doc.controlBindings ?? [])
2183
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2184
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2185
+ }
2017
2186
  const cur = doc.slots.find((s2) => s2.id === slot.id);
2018
2187
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
2019
2188
  const base = doc.slots.find((s2) => s2.id === slot.id);
@@ -2026,7 +2195,7 @@ function removeSlot(doc, id) {
2026
2195
  return patchDrawOrder({
2027
2196
  ...doc,
2028
2197
  slots: doc.slots.filter((s2) => s2.id !== id).map((s2) => gone.has(s2.clip ?? "") ? { ...s2, clip: void 0 } : s2),
2029
- attachments: doc.attachments.filter((a) => a.slot !== id),
2198
+ attachments: doc.attachments.filter((a) => a.slot !== id).map((a) => a.type === "path" && a.clipping?.end === id ? { ...a, clipping: {} } : a),
2030
2199
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
2031
2200
  }, (o) => o.filter((s2) => s2 !== id));
2032
2201
  }
@@ -2388,6 +2557,7 @@ function trackSummary(t) {
2388
2557
  function rename2(doc, kind, id, to) {
2389
2558
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2390
2559
  if (to === id) return doc;
2560
+ 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) })) };
2391
2561
  const sub = (v) => v === id ? to : v;
2392
2562
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2393
2563
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2421,7 +2591,7 @@ function rename2(doc, kind, id, to) {
2421
2591
  return {
2422
2592
  ...doc,
2423
2593
  slots: doc.slots.map((s2) => ({ ...s2, id: sub(s2.id) })),
2424
- attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot) })),
2594
+ attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot), ...a.type === "path" && a.clipping?.end ? { clipping: { end: sub(a.clipping.end) } } : {} })),
2425
2595
  path: doc.path.map((c) => ({ ...c, target: sub(c.target) }))
2426
2596
  };
2427
2597
  }
@@ -2469,6 +2639,8 @@ function describeAttachment(doc, att) {
2469
2639
  }
2470
2640
  function describe(doc, opts = {}) {
2471
2641
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2642
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2643
+ 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(", ")}`);
2472
2644
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2473
2645
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2474
2646
  const place = (s2) => {
@@ -3168,8 +3340,8 @@ function motionQuality(doc, anim, opts = {}) {
3168
3340
  if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3169
3341
  const visible = (id) => {
3170
3342
  const slot = doc.slots.find((s2) => s2.id === id);
3171
- const color = pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3172
- return Number(pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3343
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3344
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3173
3345
  };
3174
3346
  if (!visible(front) || !visible(back)) continue;
3175
3347
  const order = pose.get(pk("drawOrder", "order", "order"));
@@ -3244,6 +3416,7 @@ function checkRig(doc, opts = {}) {
3244
3416
  });
3245
3417
  const W = doc.meta.width, H = doc.meta.height;
3246
3418
  const overflowOf = (pose, slotId, skel) => {
3419
+ pose = skel.pose;
3247
3420
  const pts = slotOutline(doc, pose, slotId, 4, skel);
3248
3421
  if (!pts) return void 0;
3249
3422
  const slot = doc.slots.find((s2) => s2.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3442,6 +3615,7 @@ function checkRig(doc, opts = {}) {
3442
3615
  }
3443
3616
  }
3444
3617
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3618
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3445
3619
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3446
3620
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3447
3621
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3778,11 +3952,13 @@ function diffDocs(a, b) {
3778
3952
  ["attachments", "attachment"],
3779
3953
  ["ik", "ik"],
3780
3954
  ["transform", "transform"],
3781
- ["path", "path"]
3955
+ ["path", "path"],
3956
+ ["controls", "control"],
3957
+ ["controlBindings", "control binding"]
3782
3958
  ];
3783
3959
  for (const [kind, name] of kinds) {
3784
- const ma = new Map(a[kind].map((x) => [x.id, x]));
3785
- const mb = new Map(b[kind].map((x) => [x.id, x]));
3960
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3961
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3786
3962
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3787
3963
  for (const [id, x] of ma) {
3788
3964
  const y = mb.get(id);
@@ -4028,6 +4204,9 @@ var Ids = class {
4028
4204
  has(name) {
4029
4205
  return this.map.has(name);
4030
4206
  }
4207
+ reserve(id) {
4208
+ this.used.add(id);
4209
+ }
4031
4210
  };
4032
4211
  var spineToDoc = (x, y, originX, originY) => [x + originX, originY - y];
4033
4212
  var r4 = (v) => Math.round(v * 1e6) / 1e6 + 0;
@@ -4036,6 +4215,7 @@ function importSpine(json, atlasText, opts = {}) {
4036
4215
  const warnings = [];
4037
4216
  const atlas = parseAtlas(atlasText);
4038
4217
  const version = String(json.skeleton?.spine ?? "4.0");
4218
+ if (json.physics?.length) warnings.push(`${json.physics.length} Spine physics constraints are not imported; bake their motion before delivery`);
4039
4219
  const major = parseFloat(version);
4040
4220
  const bounds = { x: json.skeleton?.x ?? 0, y: json.skeleton?.y ?? 0, w: json.skeleton?.width ?? 0, h: json.skeleton?.height ?? 0 };
4041
4221
  const margin = opts.margin ?? 0;
@@ -4043,6 +4223,7 @@ function importSpine(json, atlasText, opts = {}) {
4043
4223
  const toDoc = (x, y) => spineToDoc(x, y, originX, originY);
4044
4224
  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_");
4045
4225
  const ROOT = "origin";
4226
+ boneIds.reserve(ROOT);
4046
4227
  const attachmentIdMap = /* @__PURE__ */ new Map();
4047
4228
  const attachmentId = (slot, name) => {
4048
4229
  const key = `${slot} ${name}`;
@@ -4067,13 +4248,17 @@ function importSpine(json, atlasText, opts = {}) {
4067
4248
  });
4068
4249
  }
4069
4250
  const skins = Array.isArray(json.skins) ? json.skins : Object.entries(json.skins ?? {}).map(([name, attachments2]) => ({ name, attachments: attachments2 }));
4251
+ const selectedSkin = opts.skin ? skins.find((s2) => s2.name === opts.skin) : skins.find((s2) => s2.name === "default") ?? skins[0];
4252
+ if (opts.skin && !selectedSkin) throw new Error(`Spine skin "${opts.skin}" not found; available: ${skins.map((s2) => s2.name).join(", ")}`);
4253
+ const lookupSkins = [selectedSkin, skins.find((s2) => s2.name === "default")].filter((s2, i, list) => s2 && list.indexOf(s2) === i);
4254
+ 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)`);
4070
4255
  const frameSuffix = (seq, index) => {
4071
- const n = String((seq.start ?? 0) + index);
4256
+ const n = String((seq.start ?? 1) + index);
4072
4257
  return "0".repeat(Math.max(0, (seq.digits ?? 0) - n.length)) + n;
4073
4258
  };
4074
4259
  const sequences = /* @__PURE__ */ new Map();
4075
4260
  const skinnedName = /* @__PURE__ */ new Map();
4076
- for (const skin2 of skins) {
4261
+ for (const skin2 of lookupSkins) {
4077
4262
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4078
4263
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
4079
4264
  for (const [name, a] of Object.entries(atts)) {
@@ -4082,7 +4267,10 @@ function importSpine(json, atlasText, opts = {}) {
4082
4267
  if (!skinnedName.has(key)) skinnedName.set(key, prefix + name + (seq ? frameSuffix(seq, seq.setupIndex ?? 0) : ""));
4083
4268
  }
4084
4269
  }
4085
- const attachmentRef = (slot, name) => attachmentId(slot, skinnedName.get(`${slot} ${name}`) ?? name);
4270
+ const attachmentRef = (slot, name) => {
4271
+ const resolved = skinnedName.get(`${slot} ${name}`);
4272
+ return resolved === void 0 ? null : attachmentId(slot, resolved);
4273
+ };
4086
4274
  const spineSlots = json.slots ?? [];
4087
4275
  const slotIndexByName = new Map(spineSlots.map((s2, i) => [s2.name, i]));
4088
4276
  const blendOf = (b) => b === "additive" ? "add" : b === "multiply" ? "multiply" : b === "screen" ? "screen" : "normal";
@@ -4090,11 +4278,12 @@ function importSpine(json, atlasText, opts = {}) {
4090
4278
  id: slotIds.get(s2.name),
4091
4279
  bone: boneIds.get(s2.bone),
4092
4280
  z: i,
4093
- ...s2.attachment ? { attachment: attachmentRef(s2.name, s2.attachment) } : {},
4281
+ ...s2.attachment && attachmentRef(s2.name, s2.attachment) ? { attachment: attachmentRef(s2.name, s2.attachment) } : {},
4094
4282
  blend: blendOf(s2.blend),
4095
4283
  color: hex8(s2.color),
4096
4284
  alpha: 1
4097
4285
  }));
4286
+ if (spineSlots.some((s2) => s2.dark)) warnings.push("Spine setup dark two-color tint is not supported");
4098
4287
  const pageImage = (page2) => typeof opts.pageImage === "function" ? opts.pageImage(page2) : opts.pageImage ?? page2;
4099
4288
  const assets = /* @__PURE__ */ new Map();
4100
4289
  const regionByName = new Map(atlas.regions.map((r) => [r.name, r]));
@@ -4120,6 +4309,7 @@ function importSpine(json, atlasText, opts = {}) {
4120
4309
  return id;
4121
4310
  };
4122
4311
  const attachments = [];
4312
+ const clipDeforms = /* @__PURE__ */ new Map();
4123
4313
  const readVertices = (a, vertexCount2) => {
4124
4314
  const v = a.vertices ?? [];
4125
4315
  if (v.length === vertexCount2 * 2) {
@@ -4140,6 +4330,22 @@ function importSpine(json, atlasText, opts = {}) {
4140
4330
  }
4141
4331
  return { weights };
4142
4332
  };
4333
+ const meshGeometry = (skinName, slot, name, visiting = /* @__PURE__ */ new Set()) => {
4334
+ const key = JSON.stringify([skinName, slot, name]);
4335
+ if (visiting.has(key)) {
4336
+ warnings.push(`linked mesh cycle at "${skinName}/${slot}/${name}" skipped`);
4337
+ return void 0;
4338
+ }
4339
+ const raw = skins.find((s2) => s2.name === skinName)?.attachments?.[slot]?.[name];
4340
+ if (!raw) {
4341
+ warnings.push(`linked mesh parent "${skinName}/${slot}/${name}" not found`);
4342
+ return void 0;
4343
+ }
4344
+ if (!raw.parent) return raw;
4345
+ visiting.add(key);
4346
+ return meshGeometry(raw.skin ?? "default", slot, raw.parent, visiting);
4347
+ };
4348
+ const inheritedDeform = /* @__PURE__ */ new Map();
4143
4349
  for (const skin2 of skins) {
4144
4350
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4145
4351
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
@@ -4151,7 +4357,7 @@ function importSpine(json, atlasText, opts = {}) {
4151
4357
  for (const [name, a] of Object.entries(atts)) {
4152
4358
  if (a.sequence) {
4153
4359
  sequences.set(`${slotName} ${prefix}${name}`, a.sequence);
4154
- const base = a.path ?? name;
4360
+ const base = a.path ?? a.name ?? name;
4155
4361
  for (let i = 0; i < (a.sequence.count ?? 0); i++) {
4156
4362
  const suffix = frameSuffix(a.sequence, i), asset = assetFor(base + suffix);
4157
4363
  if (!asset) continue;
@@ -4174,7 +4380,7 @@ function importSpine(json, atlasText, opts = {}) {
4174
4380
  }
4175
4381
  const type = a.type ?? "region", id = attachmentId(slotName, prefix + name);
4176
4382
  if (type === "region") {
4177
- const asset = assetFor(a.path ?? name);
4383
+ const asset = assetFor(a.path ?? a.name ?? name);
4178
4384
  if (!asset) continue;
4179
4385
  attachments.push({
4180
4386
  id,
@@ -4190,21 +4396,48 @@ function importSpine(json, atlasText, opts = {}) {
4190
4396
  ...a.height ? { height: a.height } : {}
4191
4397
  });
4192
4398
  } else if (type === "mesh" || type === "linkedmesh") {
4193
- if (type === "linkedmesh") {
4194
- warnings.push(`linked mesh "${slotName}/${name}" skipped`);
4195
- continue;
4196
- }
4197
- const asset = assetFor(a.path ?? name);
4399
+ const geometry = a.parent ? meshGeometry(skin2.name, slotName, name) : a;
4400
+ if (!geometry) continue;
4401
+ const asset = assetFor(a.path ?? a.name ?? name);
4198
4402
  if (!asset) continue;
4199
- const n = (a.uvs?.length ?? 0) / 2;
4200
- 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 } : {} });
4403
+ const n = (geometry.uvs?.length ?? 0) / 2;
4404
+ 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 } : {} });
4405
+ if (a.parent && (a.timelines ?? a.deform ?? true)) {
4406
+ const parentSkin = a.skin ?? "default";
4407
+ inheritedDeform.set(id, attachmentId(slotName, (parentSkin === "default" ? "" : `${parentSkin}_`) + a.parent));
4408
+ }
4201
4409
  } else if (type === "path") {
4202
4410
  attachments.push({ id, slot, type: "path", ...readVertices(a, a.vertexCount ?? 0), lengths: a.lengths ?? [], closed: !!a.closed, constantSpeed: a.constantSpeed ?? true });
4411
+ } else if (type === "clipping") {
4412
+ const data = readVertices(a, a.vertexCount), n = a.vertexCount;
4413
+ const recipes = Array.from({ length: n }, (_, i) => [
4414
+ [[(i + n - 1) % n, 1 / 3], [i, 2 / 3]],
4415
+ [[i, 1]],
4416
+ [[i, 2 / 3], [(i + 1) % n, 1 / 3]]
4417
+ ]).flat();
4418
+ let geometry;
4419
+ if (data.weights) {
4420
+ const starts = [];
4421
+ let offset = 0;
4422
+ for (const list of data.weights) {
4423
+ starts.push(offset);
4424
+ offset += list.length * 2;
4425
+ }
4426
+ const mapping = recipes.flatMap((recipe) => recipe.flatMap(([index]) => data.weights[index].map((_, k) => starts[index] + k * 2)));
4427
+ geometry = { weights: recipes.map((recipe) => recipe.flatMap(([index, factor]) => data.weights[index].map((inf) => ({ ...inf, w: inf.w * factor })))) };
4428
+ clipDeforms.set(id, (offsets) => mapping.flatMap((index) => [offsets[index] ?? 0, offsets[index + 1] ?? 0]));
4429
+ } else {
4430
+ const expand = (values2) => recipes.flatMap((recipe) => [0, 1].map((axis) => recipe.reduce((sum, [index, factor]) => sum + (values2[index * 2 + axis] ?? 0) * factor, 0)));
4431
+ geometry = { vertices: expand(data.vertices) };
4432
+ clipDeforms.set(id, expand);
4433
+ }
4434
+ attachments.push({ id, slot, type: "path", ...geometry, closed: true, clipping: { ...a.end ? { end: slotIds.get(a.end) } : {} } });
4203
4435
  } else warnings.push(`attachment "${slotName}/${name}" of type ${type} skipped`);
4204
4436
  }
4205
4437
  }
4206
4438
  }
4207
- const ik = (json.ik ?? []).map((c) => ({
4439
+ const activeConstraint = (kind, c) => !c.skin || lookupSkins.some((s2) => s2[kind]?.includes(c.name));
4440
+ const ik = (json.ik ?? []).filter((c) => activeConstraint("ik", c)).map((c) => ({
4208
4441
  id: ikIds.get(c.name),
4209
4442
  order: c.order ?? 0,
4210
4443
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4217,7 +4450,7 @@ function importSpine(json, atlasText, opts = {}) {
4217
4450
  stretch: !!c.stretch,
4218
4451
  uniform: !!c.uniform
4219
4452
  }));
4220
- const transform = (json.transform ?? []).map((c) => ({
4453
+ const transform = (json.transform ?? []).filter((c) => activeConstraint("transform", c)).map((c) => ({
4221
4454
  id: tcIds.get(c.name),
4222
4455
  order: c.order ?? 0,
4223
4456
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4237,7 +4470,7 @@ function importSpine(json, atlasText, opts = {}) {
4237
4470
  mixScaleY: c.mixScaleY ?? c.mixScaleX ?? c.scaleMix ?? 1,
4238
4471
  mixShearY: c.mixShearY ?? c.shearMix ?? 1
4239
4472
  }));
4240
- const path = (json.path ?? []).map((c) => ({
4473
+ const path = (json.path ?? []).filter((c) => activeConstraint("path", c)).map((c) => ({
4241
4474
  id: pcIds.get(c.name),
4242
4475
  order: c.order ?? 0,
4243
4476
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4272,7 +4505,21 @@ function importSpine(json, atlasText, opts = {}) {
4272
4505
  for (let i = 0; i < frames.length; i++) {
4273
4506
  const f = frames[i], next = frames[i + 1], t = f.time ?? 0;
4274
4507
  const ease2 = next ? easeOf(f.curve, comp, t, next.time ?? 0, f[field] ?? def, next[field] ?? def) : "linear";
4275
- keys.push({ t: r4(t), v: r4(abs(f[field] ?? def)), ease: ease2 });
4508
+ const curve = major >= 4 && Array.isArray(f.curve) ? f.curve.slice(comp * 4, comp * 4 + 4) : [];
4509
+ const value = f[field] ?? def;
4510
+ if (next && value === (next[field] ?? def) && curve.length === 4 && (curve[1] !== value || curve[3] !== value)) {
4511
+ 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;
4512
+ const inner = Array.from({ length: 9 }, (_, k) => {
4513
+ const u = (k + 1) / 10;
4514
+ 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" };
4515
+ });
4516
+ if (inner.every((k, i2) => k.t > (i2 ? inner[i2 - 1].t : t) && k.t < (next.time ?? 0))) {
4517
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: "linear" }, ...inner);
4518
+ continue;
4519
+ }
4520
+ warnings.push(`equal-endpoint ${field} curve at ${t}s has non-monotonic control times and could not be lowered`);
4521
+ }
4522
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: ease2 });
4276
4523
  }
4277
4524
  return keys;
4278
4525
  };
@@ -4283,6 +4530,10 @@ function importSpine(json, atlasText, opts = {}) {
4283
4530
  const seen = (frames) => {
4284
4531
  for (const f of frames) duration = Math.max(duration, f.time ?? 0);
4285
4532
  };
4533
+ if (a.physics) {
4534
+ warnings.push(`${animName}: Spine physics timelines are not imported`);
4535
+ for (const timelines of Object.values(a.physics)) for (const frames of Object.values(timelines)) seen(frames);
4536
+ }
4286
4537
  for (const [boneName, tl] of Object.entries(a.bones ?? {})) {
4287
4538
  const sb = spineBones[boneIndexByName.get(boneName)];
4288
4539
  if (!sb) {
@@ -4323,19 +4574,22 @@ function importSpine(json, atlasText, opts = {}) {
4323
4574
  if (kind === "attachment") {
4324
4575
  attachmentFrames.set(slotName, frames);
4325
4576
  tracks.push(track("slot", id, "attachment", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.name ? attachmentRef(slotName, f.name) : null, ease: "step" }))));
4326
- } else if (kind === "rgba" || kind === "color") {
4577
+ } else if (["rgba", "color", "rgb", "rgba2", "rgb2"].includes(kind)) {
4578
+ const setupAlpha = parseInt(hex8(spineSlots[slotIndexByName.get(slotName)].color).slice(7), 16) / 255;
4579
+ const rgbOnly = kind === "rgb" || kind === "rgb2";
4327
4580
  const rgbaOf = (f) => {
4328
- const c = hex8(f.color);
4329
- return [0, 2, 4, 6].map((k) => parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4581
+ const c = hex8(f.color ?? f.light);
4582
+ return [0, 2, 4, 6].map((k, i) => i === 3 && rgbOnly ? setupAlpha : parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4330
4583
  };
4584
+ if (kind.endsWith("2")) warnings.push(`${animName}/${slotName}: ${kind} light color imported; dark two-color tint is not supported`);
4331
4585
  tracks.push(track("slot", id, "color", frames.map((f, i) => {
4332
4586
  const rgba = rgbaOf(f), next = frames[i + 1];
4333
- 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" };
4334
- })));
4335
- } else if (kind === "rgb") {
4336
- tracks.push(track("slot", id, "color", frames.map((f) => {
4337
- const c = hex8((f.color ?? "ffffff") + "ff");
4338
- 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" };
4587
+ return {
4588
+ t: r4(f.time ?? 0),
4589
+ v: rgba.map(r4),
4590
+ ease: "linear",
4591
+ ...next ? { componentEase: rgba.map((v, k) => rgbOnly && k === 3 ? "linear" : easeOf(f.curve, k, f.time ?? 0, next.time ?? 0, v, rgbaOf(next)[k])) } : {}
4592
+ };
4339
4593
  })));
4340
4594
  } else if (kind === "alpha") tracks.push(track("slot", id, "alpha", numeric(frames, "value", 1, (v) => v, 0)));
4341
4595
  else warnings.push(`${animName}/${slotName}: ${kind} timeline skipped`);
@@ -4374,15 +4628,30 @@ function importSpine(json, atlasText, opts = {}) {
4374
4628
  warnings.push(`${animName}: deform of unknown attachment ${slotName}/${attName}`);
4375
4629
  continue;
4376
4630
  }
4377
- const length = att.weights ? 2 * att.weights.reduce((s2, l) => s2 + l.length, 0) : att.vertices?.length ?? 0;
4631
+ const rawClip = skins.find((s2) => s2.name === skinName)?.attachments?.[slotName]?.[attName];
4632
+ const clipData = clipDeforms.has(id) ? readVertices(rawClip, rawClip.vertexCount) : void 0;
4633
+ const data = clipData ?? att;
4634
+ const length = data.weights ? 2 * data.weights.reduce((s2, l) => s2 + l.length, 0) : data.vertices?.length ?? 0;
4378
4635
  tracks.push(track("deform", id, "vertices", frames.map((f, i) => {
4379
4636
  const full = new Array(length).fill(0);
4380
4637
  const off = f.offset ?? 0, vs = f.vertices ?? [];
4381
4638
  for (let k = 0; k < vs.length; k++) full[off + k] = (k + off) % 2 === 1 ? -vs[k] : vs[k];
4382
4639
  const next = frames[i + 1];
4383
- 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" };
4640
+ 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" };
4384
4641
  })));
4385
4642
  }
4643
+ for (let pass = 0; pass < inheritedDeform.size; pass++) {
4644
+ let changed = false;
4645
+ for (const [child, parent] of inheritedDeform) {
4646
+ if (tracks.some((t) => t.target === "deform" && t.id === child)) continue;
4647
+ const source = tracks.find((t) => t.target === "deform" && t.id === parent);
4648
+ if (source) {
4649
+ tracks.push({ ...source, id: child, keys: structuredClone(source.keys) });
4650
+ changed = true;
4651
+ }
4652
+ }
4653
+ if (!changed) break;
4654
+ }
4386
4655
  const drawOrderFrames = a.drawOrder ?? a.draworder;
4387
4656
  if (Array.isArray(drawOrderFrames)) {
4388
4657
  seen(drawOrderFrames);
@@ -4405,20 +4674,25 @@ function importSpine(json, atlasText, opts = {}) {
4405
4674
  for (const [name, frames] of Object.entries(a.ik ?? {})) {
4406
4675
  seen(frames);
4407
4676
  const id = ikIds.get(name);
4677
+ if (!ik.some((c) => c.id === id)) continue;
4408
4678
  tracks.push(track("ik", id, "mix", numeric(frames, "mix", 1, (v) => v, 0)));
4409
4679
  tracks.push(track("ik", id, "softness", numeric(frames, "softness", 0, (v) => v, 1)));
4410
4680
  tracks.push(track("ik", id, "bendPositive", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.bendPositive ?? true ? 0 : 1, ease: "step" }))));
4411
4681
  tracks.push(track("ik", id, "compress", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.compress ? 1 : 0, ease: "step" }))));
4412
4682
  tracks.push(track("ik", id, "stretch", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.stretch ? 1 : 0, ease: "step" }))));
4413
4683
  }
4414
- const mixTrack = (target, id, frames, prop, old, comp) => track(target, id, prop, frames.map((f, i) => ({
4415
- t: r4(f.time ?? 0),
4416
- v: r4(f[prop] ?? f[old] ?? 1),
4417
- 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"
4418
- })));
4684
+ const mixTrack = (target, id, frames, prop, old, comp) => {
4685
+ const value = (f) => f[prop] ?? (prop === "mixY" ? f.mixX : prop === "mixScaleY" ? f.mixScaleX : void 0) ?? f[old] ?? 1;
4686
+ return track(target, id, prop, frames.map((f, i) => ({
4687
+ t: r4(f.time ?? 0),
4688
+ v: r4(value(f)),
4689
+ ease: frames[i + 1] ? easeOf(f.curve, comp, f.time ?? 0, frames[i + 1].time ?? 0, value(f), value(frames[i + 1])) : "linear"
4690
+ })));
4691
+ };
4419
4692
  for (const [name, frames] of Object.entries(a.transform ?? {})) {
4420
4693
  seen(frames);
4421
4694
  const id = tcIds.get(name);
4695
+ if (!transform.some((c) => c.id === id)) continue;
4422
4696
  const props = [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2], ["mixScaleX", "scaleMix", 3], ["mixScaleY", "scaleMix", 4], ["mixShearY", "shearMix", 5]];
4423
4697
  for (const [prop, old, comp] of props) tracks.push(mixTrack("transform", id, frames, prop, old, comp));
4424
4698
  }
@@ -4426,6 +4700,7 @@ function importSpine(json, atlasText, opts = {}) {
4426
4700
  const id = pcIds.get(name);
4427
4701
  for (const [kind, frames] of Object.entries(tl)) {
4428
4702
  seen(frames);
4703
+ if (!path.some((c) => c.id === id)) continue;
4429
4704
  if (kind === "position" || kind === "spacing") tracks.push(track("path", id, kind, numeric(frames, "value", 0, (v) => v, 0)));
4430
4705
  else if (kind === "mix") for (const [prop, old, comp] of [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2]])
4431
4706
  tracks.push(mixTrack("path", id, frames, prop, old, comp));
@@ -4588,21 +4863,27 @@ async function importSpineProject(opts) {
4588
4863
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4589
4864
  const atlasText = await readFile7(atlasPath, "utf8");
4590
4865
  const { pages } = parseAtlas(atlasText);
4591
- if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
4592
- const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4593
- const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4866
+ if (!pages.length) throw new Error("atlas has no pages");
4867
+ 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");
4594
4868
  const rigDir = dirname6(opts.rigPath);
4595
- let pngPath = imagePath;
4596
- if (extname(imagePath).toLowerCase() === ".ktx2") {
4597
- pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4598
- if (!await exists(pngPath)) {
4599
- const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4600
- await writeFile5(pngPath, png);
4869
+ const pagePaths = /* @__PURE__ */ new Map();
4870
+ for (const page2 of pages) {
4871
+ const pageStem = join8(dirname6(atlasPath), page2.name.replace(/\.[a-z0-9]+$/i, ""));
4872
+ 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}`);
4873
+ let pngPath = imagePath;
4874
+ if (extname(imagePath).toLowerCase() === ".ktx2") {
4875
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4876
+ if (!await exists(pngPath)) {
4877
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4878
+ await writeFile5(pngPath, png);
4879
+ }
4601
4880
  }
4881
+ pagePaths.set(page2.name, pngPath);
4602
4882
  }
4603
- const pageImage = relative(rigDir, pngPath).split("\\").join("/");
4883
+ const pageImage = (page2) => relative(rigDir, pagePaths.get(page2)).split("\\").join("/");
4604
4884
  const name = opts.name ?? basename4(stem);
4605
- return { ...importSpine(json, atlasText, { name, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: pngPath };
4885
+ const imagePaths = [...pagePaths.values()];
4886
+ return { ...importSpine(json, atlasText, { name, skin: opts.skin, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: imagePaths[0], imagePaths };
4606
4887
  }
4607
4888
 
4608
4889
  // src/rig-templates.ts
@@ -4619,7 +4900,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4619
4900
  const tracks = [];
4620
4901
  const skipped = [];
4621
4902
  for (const t of anim.tracks) {
4622
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4903
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4623
4904
  skipped.push(trackId5(t));
4624
4905
  continue;
4625
4906
  }
@@ -4994,8 +5275,8 @@ function runGates(doc, config) {
4994
5275
  let active2 = 0, bad = 0;
4995
5276
  for (const t of ts) {
4996
5277
  const pose = poseOf(animation, t), s2 = evaluate(doc, pose);
4997
- const color = pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
4998
- const alpha = Number(pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
5278
+ const color = s2.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5279
+ const alpha = Number(s2.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
4999
5280
  if (!s2.attachment.get(slot.id) || alpha <= 0.01) continue;
5000
5281
  active2++;
5001
5282
  const v = bone(s2, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
@@ -5135,13 +5416,14 @@ function attachToBone(doc, animation, bone, target, t) {
5135
5416
  if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5136
5417
  const [x, y] = apply(invert(to), from.tx, from.ty);
5137
5418
  const id = `attach_${bone}_${target}`;
5419
+ const handedness = to.a * to.d - to.b * to.c < 0 ? -1 : 1;
5138
5420
  const offset = {
5139
5421
  x,
5140
5422
  y,
5141
- rotation: wrap2(rotationOf(from) - rotationOf(to)),
5423
+ rotation: wrap2(rotationOf(from) - rotationOf(to)) * handedness,
5142
5424
  scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5143
5425
  scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5144
- 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)
5426
+ 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
5145
5427
  };
5146
5428
  const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5147
5429
  let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
@@ -5217,6 +5499,53 @@ function bakeSecondary(doc, animation, opts) {
5217
5499
  return next;
5218
5500
  }
5219
5501
 
5502
+ // src/rig-control-qa.ts
5503
+ function snapshot(doc, values2) {
5504
+ const skel = evaluate(doc, controlPose(doc, values2));
5505
+ return doc.slots.flatMap((slot) => {
5506
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5507
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5508
+ return points ? [{ id: slot.id, alpha, points }] : [];
5509
+ });
5510
+ }
5511
+ function checkControlCases(doc, cases) {
5512
+ const errors = [];
5513
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5514
+ cases.forEach((c, index) => {
5515
+ const skel = evaluate(doc, controlPose(doc, c.values));
5516
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5517
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5518
+ for (const att of doc.attachments) if (att.type === "mesh") {
5519
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5520
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5521
+ 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))
5522
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5523
+ }
5524
+ });
5525
+ const neutral = JSON.stringify(snapshot(doc, {}));
5526
+ const effects = (doc.controls ?? []).map((c) => ({
5527
+ id: c.id,
5528
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5529
+ }));
5530
+ return {
5531
+ passed: errors.length === 0,
5532
+ errors,
5533
+ effects,
5534
+ 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."
5535
+ };
5536
+ }
5537
+ function makeControlSweep(doc, probes) {
5538
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5539
+ const animation = {
5540
+ id: "control_sweep",
5541
+ duration: Math.max(1, cases.length - 1),
5542
+ loop: false,
5543
+ events: [],
5544
+ 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" })) }))
5545
+ };
5546
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5547
+ }
5548
+
5220
5549
  // src/rig-api.ts
5221
5550
  var tools = [];
5222
5551
  function tool(name, description, input, run) {
@@ -5280,14 +5609,88 @@ function previewCheck(doc, animation) {
5280
5609
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
5281
5610
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
5282
5611
  }
5283
- 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() };
5612
+ 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() };
5613
+ tool(
5614
+ "rig_set_control",
5615
+ "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.",
5616
+ { path: Path, control: RigControl },
5617
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5618
+ );
5619
+ tool(
5620
+ "rig_set_control_binding",
5621
+ "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.",
5622
+ { path: Path, binding: ControlBinding },
5623
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5624
+ );
5625
+ tool(
5626
+ "rig_remove_control_binding",
5627
+ "Remove a control binding, restoring its channels to setup/animation values.",
5628
+ { path: Path, id: z3.string() },
5629
+ async ({ path, id }) => mutate(path, (d) => {
5630
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5631
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5632
+ })
5633
+ );
5634
+ tool(
5635
+ "rig_remove_control",
5636
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5637
+ { path: Path, id: z3.string() },
5638
+ async ({ path, id }) => mutate(path, (d) => {
5639
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5640
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5641
+ 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) })) };
5642
+ })
5643
+ );
5644
+ tool(
5645
+ "rig_sweep_controls",
5646
+ "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.",
5647
+ {
5648
+ path: Path,
5649
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5650
+ output: z3.string(),
5651
+ scale: z3.number().positive().max(2).default(0.5),
5652
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5653
+ },
5654
+ async ({ path, probes, output, scale, region }) => {
5655
+ try {
5656
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5657
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5658
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5659
+ const qa = checkControlCases(d, sweep.cases);
5660
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5661
+ await mkdir6(out, { recursive: true });
5662
+ const frames = [];
5663
+ for (let i = 0; i < r.frames.length; i++) {
5664
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5665
+ await writeFile7(join9(out, file), r.frames[i]);
5666
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5667
+ }
5668
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5669
+ const report = {
5670
+ version: 1,
5671
+ sourceRig: sha256(raw),
5672
+ identity: r.identity,
5673
+ scale,
5674
+ region: region ?? null,
5675
+ ...qa,
5676
+ frames,
5677
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5678
+ };
5679
+ await saveJson(join9(out, "report.json"), report);
5680
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5681
+ } catch (e) {
5682
+ return fail(e);
5683
+ }
5684
+ }
5685
+ );
5284
5686
  var KeyIn = z3.object({
5285
5687
  t: z3.number().describe("seconds"),
5286
5688
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5689
+ componentEase: z3.array(Ease).min(1).optional().describe("optional independent easing per numeric-array component"),
5287
5690
  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")
5288
5691
  });
5289
5692
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
5290
- 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].";
5693
+ 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].";
5291
5694
  tool(
5292
5695
  "rig_create",
5293
5696
  "Create a new empty rig document with a root bone at bottom-center.",
@@ -5411,19 +5814,20 @@ tool(
5411
5814
  );
5412
5815
  tool(
5413
5816
  "rig_import_spine",
5414
- "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.",
5817
+ "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.",
5415
5818
  {
5416
5819
  path: Path,
5417
5820
  json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5418
5821
  atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5419
5822
  image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5823
+ skin: z3.string().optional().describe("skin to flatten; default: default skin, or first skin if no default exists; no runtime skin switching"),
5420
5824
  name: z3.string().optional().describe("default: the json file name"),
5421
5825
  margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5422
5826
  notes: z3.string().optional()
5423
5827
  },
5424
- async ({ path, json, atlas, image, name, margin, notes }) => {
5828
+ async ({ path, json, atlas, image, name, skin: skin2, margin, notes }) => {
5425
5829
  try {
5426
- const r = await importSpineProject({ json: resolveFrom(path, json), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, margin, notes });
5830
+ 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 });
5427
5831
  const res = await create(path, parse(r.doc));
5428
5832
  if (r.warnings.length) res.text += `
5429
5833
  warnings:
@@ -6112,8 +6516,8 @@ tool(
6112
6516
  const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6113
6517
  if (saveTo) await writeFile7(saveTo, r.sheet);
6114
6518
  if (framesDir) {
6115
- const { mkdir: mkdir7 } = await import("node:fs/promises");
6116
- await mkdir7(framesDir, { recursive: true });
6519
+ const { mkdir: mkdir8 } = await import("node:fs/promises");
6520
+ await mkdir8(framesDir, { recursive: true });
6117
6521
  const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6118
6522
  for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6119
6523
  const manifest = {
@@ -6263,7 +6667,7 @@ data: ${JSON.stringify({ version: v })}
6263
6667
  async function listRigs() {
6264
6668
  const out = [];
6265
6669
  const walk = async (dir) => {
6266
- for (const e of await readdir4(dir, { withFileTypes: true })) {
6670
+ for (const e of await readdir5(dir, { withFileTypes: true })) {
6267
6671
  if (e.isDirectory()) {
6268
6672
  if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
6269
6673
  continue;
@@ -6283,7 +6687,7 @@ data: ${JSON.stringify({ version: v })}
6283
6687
  }
6284
6688
  async function listFiles(rigAbs) {
6285
6689
  const dir = dirname9(rigAbs), out = [];
6286
- 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))) {
6690
+ 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))) {
6287
6691
  const fh = await open(join10(dir, e.name));
6288
6692
  try {
6289
6693
  const head = Buffer.alloc(24);
@@ -6401,7 +6805,7 @@ data: ${JSON.stringify({ version: v })}
6401
6805
  const abs = inside(rig);
6402
6806
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6403
6807
  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` });
6404
- await mkdir6(dirname9(abs), { recursive: true });
6808
+ await mkdir7(dirname9(abs), { recursive: true });
6405
6809
  const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
6406
6810
  ensureWatch(abs);
6407
6811
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });