@energy8platform/golem 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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";
@@ -249,6 +249,7 @@ var Key = z.object({
249
249
  });
250
250
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
251
251
  var Track = z.discriminatedUnion("target", [
252
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
252
253
  z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
253
254
  z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
254
255
  z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
@@ -271,6 +272,25 @@ var Animation = z.object({
271
272
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
272
273
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
273
274
  });
275
+ var RigControl = z.object({
276
+ id: Id,
277
+ label: z.string().optional(),
278
+ min: z.number().finite(),
279
+ max: z.number().finite(),
280
+ default: z.number().finite()
281
+ }).strict();
282
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
283
+ var ControlChannel = z.discriminatedUnion("target", [
284
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
285
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
286
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
287
+ ]);
288
+ var ControlBinding = z.object({
289
+ id: Id,
290
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
291
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
292
+ channels: z.array(ControlChannel).min(1)
293
+ }).strict();
274
294
  var Reference = z.object({
275
295
  src: z.string(),
276
296
  opacity: z.number().min(0).max(1).default(0.5),
@@ -296,7 +316,9 @@ var RigSchema = z.object({
296
316
  ik: z.array(IkConstraint).default([]),
297
317
  transform: z.array(TransformConstraint).default([]),
298
318
  path: z.array(PathConstraint).default([]),
299
- animations: z.array(Animation)
319
+ animations: z.array(Animation),
320
+ controls: z.array(RigControl).optional(),
321
+ controlBindings: z.array(ControlBinding).optional()
300
322
  });
301
323
  var RigDocument = z.preprocess((input) => {
302
324
  const v = input?.version;
@@ -371,6 +393,8 @@ function validate(doc) {
371
393
  dup(doc.ik, "ik");
372
394
  dup(doc.transform, "transform");
373
395
  dup(doc.path, "path");
396
+ dup(doc.controls ?? [], "control");
397
+ dup(doc.controlBindings ?? [], "control binding");
374
398
  const bones = new Set(doc.bones.map((b) => b.id));
375
399
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
376
400
  const slots = new Set(doc.slots.map((s2) => s2.id));
@@ -445,13 +469,47 @@ function validate(doc) {
445
469
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
446
470
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
447
471
  }
472
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
473
+ const driven = /* @__PURE__ */ new Set();
474
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
475
+ errs.push(`control "${c.id}": require min < max and default within range`);
476
+ for (const b of doc.controlBindings ?? []) {
477
+ const prefix = `control binding "${b.id}"`;
478
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
479
+ for (const axis of b.axes) {
480
+ const c = controls.get(axis.control);
481
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
482
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
483
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
484
+ }
485
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
486
+ for (const ch of b.channels) {
487
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
488
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
489
+ driven.add(key);
490
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
491
+ if (ch.target === "deform") {
492
+ const att = attachments.get(ch.id);
493
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
494
+ else {
495
+ const n = att.weights ? att.weights.reduce((s2, a) => s2 + a.length * 2, 0) : att.vertices.length;
496
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
497
+ }
498
+ } else {
499
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
500
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
501
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
502
+ }
503
+ }
504
+ }
448
505
  for (const a of doc.animations) {
449
506
  const tracks = /* @__PURE__ */ new Set();
450
507
  for (const t of a.tracks) {
451
508
  const tid = trackId(t);
452
509
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
453
510
  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;
511
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
512
+ const known = t.target === "bone" ? bones.has(t.id) : t.target === "slot" ? slots.has(t.id) : t.target === "deform" ? attachments.has(t.id) : t.target === "ik" ? iks.has(t.id) : t.target === "transform" ? transforms.has(t.id) : t.target === "path" ? paths.has(t.id) : t.target === "control" ? controls.has(t.id) : true;
455
513
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
456
514
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
457
515
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
@@ -459,6 +517,10 @@ function validate(doc) {
459
517
  for (const k of t.keys) {
460
518
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
461
519
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
520
+ if (t.target === "control") {
521
+ const c = controls.get(t.id);
522
+ if (typeof k.v !== "number" || !Number.isFinite(k.v) || c && (k.v < c.min || k.v > c.max)) errs.push(`animation "${a.id}" ${tid}: control key outside its finite range`);
523
+ }
462
524
  if (t.target === "slot" && t.prop === "attachment") {
463
525
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
464
526
  else if (typeof k.v === "string") {
@@ -499,7 +561,7 @@ function parse(input) {
499
561
 
500
562
  // src/rig-api.ts
501
563
  import { z as z3 } from "zod";
502
- import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
564
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
503
565
  import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
504
566
 
505
567
  // src/rig-io.ts
@@ -547,6 +609,65 @@ async function withRigQueue(path, op) {
547
609
  }
548
610
  }
549
611
 
612
+ // src/rig-controls.ts
613
+ function controlPose(doc, values2) {
614
+ const out = /* @__PURE__ */ new Map();
615
+ for (const [id, value] of Object.entries(values2)) {
616
+ const c = doc.controls?.find((c2) => c2.id === id);
617
+ if (!c) throw new Error(`unknown control "${id}"`);
618
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
619
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
620
+ }
621
+ return out;
622
+ }
623
+ function bracket(keys, value) {
624
+ let i = 0;
625
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
626
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
627
+ }
628
+ function sampleBinding(binding, values2) {
629
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
630
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
631
+ const width = binding.axes[0].keys.length, a = y * width + x;
632
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
633
+ return binding.channels.map((ch) => {
634
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
635
+ const at = (j2) => {
636
+ const top = mix(read(a, j2), read(a + 1, j2), u);
637
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
638
+ };
639
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
640
+ });
641
+ }
642
+ function resolveControls(doc, input) {
643
+ if (!doc.controlBindings?.length) return input;
644
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
645
+ for (const b of doc.controlBindings) {
646
+ const values2 = b.axes.map((axis) => {
647
+ const c = controls.get(axis.control);
648
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
649
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
650
+ return Math.max(c.min, Math.min(c.max, value));
651
+ });
652
+ const sampled2 = sampleBinding(b, values2);
653
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
654
+ }
655
+ return out;
656
+ }
657
+ function controlCases(doc, probes) {
658
+ let cases = [{ label: "default", values: {} }];
659
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
660
+ for (const [id, values2] of Object.entries(probes)) {
661
+ const c = doc.controls?.find((c2) => c2.id === id);
662
+ if (!c) throw new Error(`unknown control "${id}"`);
663
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
664
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
665
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
666
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
667
+ }
668
+ return cases;
669
+ }
670
+
550
671
  // src/rig-anim.ts
551
672
  var DEG2 = Math.PI / 180;
552
673
  var named = {
@@ -650,6 +771,7 @@ function blendPoses(from, to, w, restOf) {
650
771
  }
651
772
  function restValue(doc, key) {
652
773
  const [target, id, prop] = key.split(":");
774
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
653
775
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
654
776
  if (target === "slot") {
655
777
  const s2 = doc.slots.find((s3) => s3.id === id);
@@ -756,7 +878,9 @@ function updateSubtree(skel, root3) {
756
878
  for (const k of kids.get(id) ?? []) stack.push(k.id);
757
879
  }
758
880
  }
759
- function evaluate(doc, pose) {
881
+ function evaluate(doc, pose, modifiers) {
882
+ pose = resolveControls(doc, pose);
883
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
760
884
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
761
885
  for (const b of boneOrder(doc)) {
762
886
  const s2 = localOf(doc, pose, b);
@@ -1982,6 +2106,9 @@ function updateBone(doc, id, patch) {
1982
2106
  const i = doc.bones.findIndex((b) => b.id === id);
1983
2107
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1984
2108
  const cur = doc.bones[i];
2109
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2110
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2111
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1985
2112
  const { parent, ...rest } = patch;
1986
2113
  const next = RigSchema.shape.bones.element.parse({
1987
2114
  ...cur,
@@ -2014,6 +2141,11 @@ function patchDrawOrder(doc, fn) {
2014
2141
  return { ...doc, animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.map((t) => t.target !== "drawOrder" ? t : { ...t, keys: t.keys.map((k) => ({ ...k, v: fn(k.v) })) }) })) };
2015
2142
  }
2016
2143
  function setSlot(doc, slot) {
2144
+ if (slot.alpha !== void 0) {
2145
+ for (const b of doc.controlBindings ?? [])
2146
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2147
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2148
+ }
2017
2149
  const cur = doc.slots.find((s2) => s2.id === slot.id);
2018
2150
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
2019
2151
  const base = doc.slots.find((s2) => s2.id === slot.id);
@@ -2388,6 +2520,7 @@ function trackSummary(t) {
2388
2520
  function rename2(doc, kind, id, to) {
2389
2521
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2390
2522
  if (to === id) return doc;
2523
+ if (doc.controlBindings) doc = { ...doc, controlBindings: doc.controlBindings.map((b) => ({ ...b, channels: b.channels.map((ch) => ch.id === id && (kind === ch.target || kind === "attachment" && ch.target === "deform") ? { ...ch, id: to } : ch) })) };
2391
2524
  const sub = (v) => v === id ? to : v;
2392
2525
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2393
2526
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2469,6 +2602,8 @@ function describeAttachment(doc, att) {
2469
2602
  }
2470
2603
  function describe(doc, opts = {}) {
2471
2604
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2605
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2606
+ for (const b of doc.controlBindings ?? []) lines.push(`keyforms ${b.id}: ${b.axes.map((a) => `${a.control}[${a.keys.join(",")}]`).join(" \xD7 ")} \u2192 ${b.channels.map((c) => `${c.target}.${c.id}.${c.prop}`).join(", ")}`);
2472
2607
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2473
2608
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2474
2609
  const place = (s2) => {
@@ -3168,8 +3303,8 @@ function motionQuality(doc, anim, opts = {}) {
3168
3303
  if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3169
3304
  const visible = (id) => {
3170
3305
  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;
3306
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3307
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3173
3308
  };
3174
3309
  if (!visible(front) || !visible(back)) continue;
3175
3310
  const order = pose.get(pk("drawOrder", "order", "order"));
@@ -3244,6 +3379,7 @@ function checkRig(doc, opts = {}) {
3244
3379
  });
3245
3380
  const W = doc.meta.width, H = doc.meta.height;
3246
3381
  const overflowOf = (pose, slotId, skel) => {
3382
+ pose = skel.pose;
3247
3383
  const pts = slotOutline(doc, pose, slotId, 4, skel);
3248
3384
  if (!pts) return void 0;
3249
3385
  const slot = doc.slots.find((s2) => s2.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3442,6 +3578,7 @@ function checkRig(doc, opts = {}) {
3442
3578
  }
3443
3579
  }
3444
3580
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3581
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3445
3582
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3446
3583
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3447
3584
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3778,11 +3915,13 @@ function diffDocs(a, b) {
3778
3915
  ["attachments", "attachment"],
3779
3916
  ["ik", "ik"],
3780
3917
  ["transform", "transform"],
3781
- ["path", "path"]
3918
+ ["path", "path"],
3919
+ ["controls", "control"],
3920
+ ["controlBindings", "control binding"]
3782
3921
  ];
3783
3922
  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]));
3923
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3924
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3786
3925
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3787
3926
  for (const [id, x] of ma) {
3788
3927
  const y = mb.get(id);
@@ -4619,7 +4758,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4619
4758
  const tracks = [];
4620
4759
  const skipped = [];
4621
4760
  for (const t of anim.tracks) {
4622
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4761
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4623
4762
  skipped.push(trackId5(t));
4624
4763
  continue;
4625
4764
  }
@@ -4994,8 +5133,8 @@ function runGates(doc, config) {
4994
5133
  let active2 = 0, bad = 0;
4995
5134
  for (const t of ts) {
4996
5135
  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];
5136
+ const color = s2.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5137
+ const alpha = Number(s2.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
4999
5138
  if (!s2.attachment.get(slot.id) || alpha <= 0.01) continue;
5000
5139
  active2++;
5001
5140
  const v = bone(s2, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
@@ -5217,6 +5356,53 @@ function bakeSecondary(doc, animation, opts) {
5217
5356
  return next;
5218
5357
  }
5219
5358
 
5359
+ // src/rig-control-qa.ts
5360
+ function snapshot(doc, values2) {
5361
+ const skel = evaluate(doc, controlPose(doc, values2));
5362
+ return doc.slots.flatMap((slot) => {
5363
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5364
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5365
+ return points ? [{ id: slot.id, alpha, points }] : [];
5366
+ });
5367
+ }
5368
+ function checkControlCases(doc, cases) {
5369
+ const errors = [];
5370
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5371
+ cases.forEach((c, index) => {
5372
+ const skel = evaluate(doc, controlPose(doc, c.values));
5373
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5374
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5375
+ for (const att of doc.attachments) if (att.type === "mesh") {
5376
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5377
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5378
+ else if (foldedTriangles(before, after, att.triangles).length || att.triangles.some((_, i) => i % 3 === 0 && Math.abs(triangleArea(before, ...att.triangles.slice(i, i + 3))) > 1e-8 && Math.abs(triangleArea(after, ...att.triangles.slice(i, i + 3))) < 1e-8))
5379
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5380
+ }
5381
+ });
5382
+ const neutral = JSON.stringify(snapshot(doc, {}));
5383
+ const effects = (doc.controls ?? []).map((c) => ({
5384
+ id: c.id,
5385
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5386
+ }));
5387
+ return {
5388
+ passed: errors.length === 0,
5389
+ errors,
5390
+ effects,
5391
+ scope: "Finite transforms and mesh orientation at the listed poses; individual control geometry/opacity effects. Images require visual review; this is not an artwork acceptance result."
5392
+ };
5393
+ }
5394
+ function makeControlSweep(doc, probes) {
5395
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5396
+ const animation = {
5397
+ id: "control_sweep",
5398
+ duration: Math.max(1, cases.length - 1),
5399
+ loop: false,
5400
+ events: [],
5401
+ tracks: (doc.controls ?? []).map((c) => ({ target: "control", id: c.id, prop: "value", keys: cases.map((p, i) => ({ t: i, v: p.values[c.id], ease: "step" })) }))
5402
+ };
5403
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5404
+ }
5405
+
5220
5406
  // src/rig-api.ts
5221
5407
  var tools = [];
5222
5408
  function tool(name, description, input, run) {
@@ -5280,7 +5466,80 @@ function previewCheck(doc, animation) {
5280
5466
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
5281
5467
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
5282
5468
  }
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() };
5469
+ var TrackRef = { interpolation: z3.enum(["segment", "pchip"]).optional().describe("pchip: smooth through numeric keys, preserving extrema; segment: per-key easing"), target: z3.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder", "control"]), id: z3.string(), prop: z3.string() };
5470
+ tool(
5471
+ "rig_set_control",
5472
+ "Create or replace a semantic control with a finite min/max/default range. Animate target=control, prop=value. Changing a range requires matching binding grids.",
5473
+ { path: Path, control: RigControl },
5474
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5475
+ );
5476
+ tool(
5477
+ "rig_set_control_binding",
5478
+ "Create or replace a 1D/2D grid of absolute keyforms. First axis varies fastest. Channels support bone transforms, slot alpha and mesh/path vertex offsets. Each channel has one owner; animate controls rather than owned channels.",
5479
+ { path: Path, binding: ControlBinding },
5480
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5481
+ );
5482
+ tool(
5483
+ "rig_remove_control_binding",
5484
+ "Remove a control binding, restoring its channels to setup/animation values.",
5485
+ { path: Path, id: z3.string() },
5486
+ async ({ path, id }) => mutate(path, (d) => {
5487
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5488
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5489
+ })
5490
+ );
5491
+ tool(
5492
+ "rig_remove_control",
5493
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5494
+ { path: Path, id: z3.string() },
5495
+ async ({ path, id }) => mutate(path, (d) => {
5496
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5497
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5498
+ return { ...d, controls: d.controls.filter((c) => c.id !== id), animations: d.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => t.target !== "control" || t.id !== id) })) };
5499
+ })
5500
+ );
5501
+ tool(
5502
+ "rig_sweep_controls",
5503
+ "Render an explicit control grid (max 256 poses) through PixiJS, with PNGs, contact sheet and report tying cases to source/texture/renderer hashes. Geometry checks are not visual acceptance. Use a new output directory outside the packed rig.",
5504
+ {
5505
+ path: Path,
5506
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5507
+ output: z3.string(),
5508
+ scale: z3.number().positive().max(2).default(0.5),
5509
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5510
+ },
5511
+ async ({ path, probes, output, scale, region }) => {
5512
+ try {
5513
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5514
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5515
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5516
+ const qa = checkControlCases(d, sweep.cases);
5517
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5518
+ await mkdir6(out, { recursive: true });
5519
+ const frames = [];
5520
+ for (let i = 0; i < r.frames.length; i++) {
5521
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5522
+ await writeFile7(join9(out, file), r.frames[i]);
5523
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5524
+ }
5525
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5526
+ const report = {
5527
+ version: 1,
5528
+ sourceRig: sha256(raw),
5529
+ identity: r.identity,
5530
+ scale,
5531
+ region: region ?? null,
5532
+ ...qa,
5533
+ frames,
5534
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5535
+ };
5536
+ await saveJson(join9(out, "report.json"), report);
5537
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5538
+ } catch (e) {
5539
+ return fail(e);
5540
+ }
5541
+ }
5542
+ );
5284
5543
  var KeyIn = z3.object({
5285
5544
  t: z3.number().describe("seconds"),
5286
5545
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
@@ -6112,8 +6371,8 @@ tool(
6112
6371
  const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6113
6372
  if (saveTo) await writeFile7(saveTo, r.sheet);
6114
6373
  if (framesDir) {
6115
- const { mkdir: mkdir7 } = await import("node:fs/promises");
6116
- await mkdir7(framesDir, { recursive: true });
6374
+ const { mkdir: mkdir8 } = await import("node:fs/promises");
6375
+ await mkdir8(framesDir, { recursive: true });
6117
6376
  const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6118
6377
  for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6119
6378
  const manifest = {
@@ -6263,7 +6522,7 @@ data: ${JSON.stringify({ version: v })}
6263
6522
  async function listRigs() {
6264
6523
  const out = [];
6265
6524
  const walk = async (dir) => {
6266
- for (const e of await readdir4(dir, { withFileTypes: true })) {
6525
+ for (const e of await readdir5(dir, { withFileTypes: true })) {
6267
6526
  if (e.isDirectory()) {
6268
6527
  if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
6269
6528
  continue;
@@ -6283,7 +6542,7 @@ data: ${JSON.stringify({ version: v })}
6283
6542
  }
6284
6543
  async function listFiles(rigAbs) {
6285
6544
  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))) {
6545
+ for (const e of (await readdir5(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6287
6546
  const fh = await open(join10(dir, e.name));
6288
6547
  try {
6289
6548
  const head = Buffer.alloc(24);
@@ -6401,7 +6660,7 @@ data: ${JSON.stringify({ version: v })}
6401
6660
  const abs = inside(rig);
6402
6661
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6403
6662
  if (await stat3(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
6404
- await mkdir6(dirname9(abs), { recursive: true });
6663
+ await mkdir7(dirname9(abs), { recursive: true });
6405
6664
  const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
6406
6665
  ensureWatch(abs);
6407
6666
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });