@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.
@@ -1,6 +1,6 @@
1
1
  // src/editor/server.ts
2
2
  import { createServer } from "node:http";
3
- import { readFile as readFile10, stat as stat3, readdir as readdir4, mkdir as mkdir6, open } from "node:fs/promises";
3
+ import { readFile as readFile10, stat as stat3, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
4
4
  import { watch as fsWatch } from "node:fs";
5
5
  import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -246,6 +246,7 @@ var Key = z.object({
246
246
  });
247
247
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
248
248
  var Track = z.discriminatedUnion("target", [
249
+ z.object({ target: z.literal("control"), id: Id, prop: z.literal("value"), ...Interpolation, keys: z.array(Key).min(1) }),
249
250
  z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
250
251
  z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
251
252
  z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
@@ -268,6 +269,25 @@ var Animation = z.object({
268
269
  /** if generated from a preset — keep params so the agent can re-tune instead of re-keying */
269
270
  preset: z.object({ name: z.string(), params: z.record(z.unknown()) }).optional()
270
271
  });
272
+ var RigControl = z.object({
273
+ id: Id,
274
+ label: z.string().optional(),
275
+ min: z.number().finite(),
276
+ max: z.number().finite(),
277
+ default: z.number().finite()
278
+ }).strict();
279
+ var ControlValues = z.array(z.union([z.number().finite(), z.array(z.number().finite()).min(1)])).min(2);
280
+ var ControlChannel = z.discriminatedUnion("target", [
281
+ z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, values: ControlValues }).strict(),
282
+ z.object({ target: z.literal("slot"), id: Id, prop: z.literal("alpha"), values: ControlValues }).strict(),
283
+ z.object({ target: z.literal("deform"), id: Id, prop: z.literal("vertices"), values: ControlValues }).strict()
284
+ ]);
285
+ var ControlBinding = z.object({
286
+ id: Id,
287
+ axes: z.array(z.object({ control: Id, keys: z.array(z.number().finite()).min(2).max(32) }).strict()).min(1).max(2),
288
+ /** Flattened grid, first axis varies fastest. Linear / bilinear interpolation. */
289
+ channels: z.array(ControlChannel).min(1)
290
+ }).strict();
271
291
  var Reference = z.object({
272
292
  src: z.string(),
273
293
  opacity: z.number().min(0).max(1).default(0.5),
@@ -293,7 +313,9 @@ var RigSchema = z.object({
293
313
  ik: z.array(IkConstraint).default([]),
294
314
  transform: z.array(TransformConstraint).default([]),
295
315
  path: z.array(PathConstraint).default([]),
296
- animations: z.array(Animation)
316
+ animations: z.array(Animation),
317
+ controls: z.array(RigControl).optional(),
318
+ controlBindings: z.array(ControlBinding).optional()
297
319
  });
298
320
  var RigDocument = z.preprocess((input) => {
299
321
  const v = input?.version;
@@ -368,6 +390,8 @@ function validate(doc) {
368
390
  dup(doc.ik, "ik");
369
391
  dup(doc.transform, "transform");
370
392
  dup(doc.path, "path");
393
+ dup(doc.controls ?? [], "control");
394
+ dup(doc.controlBindings ?? [], "control binding");
371
395
  const bones = new Set(doc.bones.map((b) => b.id));
372
396
  const assets = new Map(doc.assets.map((a) => [a.id, a]));
373
397
  const slots = new Set(doc.slots.map((s) => s.id));
@@ -442,13 +466,47 @@ function validate(doc) {
442
466
  for (const b of c.bones) if (!bones.has(b)) errs.push(`path "${c.id}": unknown bone "${b}"`);
443
467
  if (!slots.has(c.target)) errs.push(`path "${c.id}": unknown target slot "${c.target}"`);
444
468
  }
469
+ const controls = new Map((doc.controls ?? []).map((c) => [c.id, c]));
470
+ const driven = /* @__PURE__ */ new Set();
471
+ for (const c of controls.values()) if (!(c.min < c.max && c.default >= c.min && c.default <= c.max))
472
+ errs.push(`control "${c.id}": require min < max and default within range`);
473
+ for (const b of doc.controlBindings ?? []) {
474
+ const prefix = `control binding "${b.id}"`;
475
+ if (new Set(b.axes.map((a) => a.control)).size !== b.axes.length) errs.push(`${prefix}: axes must use different controls`);
476
+ for (const axis of b.axes) {
477
+ const c = controls.get(axis.control);
478
+ if (!c) errs.push(`${prefix}: unknown control "${axis.control}"`);
479
+ else if (axis.keys[0] !== c.min || axis.keys.at(-1) !== c.max) errs.push(`${prefix}: keys must span the control's full range`);
480
+ if (axis.keys.some((v, i) => i > 0 && v <= axis.keys[i - 1])) errs.push(`${prefix}: keys must be strictly increasing`);
481
+ }
482
+ const count = b.axes.reduce((n, a) => n * a.keys.length, 1);
483
+ for (const ch of b.channels) {
484
+ const key = `${ch.target}.${ch.id}.${ch.prop}`;
485
+ if (driven.has(key)) errs.push(`${prefix}: channel ${key} already owned by a binding`);
486
+ driven.add(key);
487
+ if (ch.values.length !== count) errs.push(`${prefix}: ${key} needs ${count} grid values`);
488
+ if (ch.target === "deform") {
489
+ const att = attachments.get(ch.id);
490
+ if (!att || att.type === "region") errs.push(`${prefix}: vertices need a mesh or path attachment`);
491
+ else {
492
+ const n = att.weights ? att.weights.reduce((s, a) => s + a.length * 2, 0) : att.vertices.length;
493
+ if (ch.values.some((v) => !Array.isArray(v) || v.length !== n)) errs.push(`${prefix}: ${key} needs arrays of ${n} offsets`);
494
+ }
495
+ } else {
496
+ if (!(ch.target === "bone" ? bones : slots).has(ch.id)) errs.push(`${prefix}: unknown ${ch.target} "${ch.id}"`);
497
+ if (ch.values.some((v) => typeof v !== "number")) errs.push(`${prefix}: ${key} needs numeric values`);
498
+ if (ch.target === "slot" && ch.values.some((v) => typeof v === "number" && (v < 0 || v > 1))) errs.push(`${prefix}: alpha must be in 0..1`);
499
+ }
500
+ }
501
+ }
445
502
  for (const a of doc.animations) {
446
503
  const tracks = /* @__PURE__ */ new Set();
447
504
  for (const t of a.tracks) {
448
505
  const tid = trackId(t);
449
506
  if (tracks.has(tid)) errs.push(`animation "${a.id}": duplicate track ${tid}`);
450
507
  tracks.add(tid);
451
- const known = t.target === "bone" ? bones.has(t.id) : t.target === "slot" ? slots.has(t.id) : t.target === "deform" ? attachments.has(t.id) : t.target === "ik" ? iks.has(t.id) : t.target === "transform" ? transforms.has(t.id) : t.target === "path" ? paths.has(t.id) : true;
508
+ if (driven.has(tid)) errs.push(`animation "${a.id}": ${tid} is controlled by a binding; animate its control instead`);
509
+ const known = t.target === "bone" ? bones.has(t.id) : t.target === "slot" ? slots.has(t.id) : t.target === "deform" ? attachments.has(t.id) : t.target === "ik" ? iks.has(t.id) : t.target === "transform" ? transforms.has(t.id) : t.target === "path" ? paths.has(t.id) : t.target === "control" ? controls.has(t.id) : true;
452
510
  if (!known) errs.push(`animation "${a.id}": unknown ${t.target} "${t.id}"`);
453
511
  const bound = t.target === "deform" ? DEFORM_BOUNDS[t.prop] : void 0;
454
512
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
@@ -456,6 +514,10 @@ function validate(doc) {
456
514
  for (const k of t.keys) {
457
515
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
458
516
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
517
+ if (t.target === "control") {
518
+ const c = controls.get(t.id);
519
+ 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`);
520
+ }
459
521
  if (t.target === "slot" && t.prop === "attachment") {
460
522
  if (k.v !== null && typeof k.v !== "string") errs.push(`animation "${a.id}" ${tid}: attachment key must be an attachment id or null`);
461
523
  else if (typeof k.v === "string") {
@@ -496,7 +558,7 @@ function parse(input) {
496
558
 
497
559
  // src/rig-api.ts
498
560
  import { z as z3 } from "zod";
499
- import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
561
+ import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
500
562
  import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
501
563
 
502
564
  // src/rig-io.ts
@@ -544,6 +606,65 @@ async function withRigQueue(path, op) {
544
606
  }
545
607
  }
546
608
 
609
+ // src/rig-controls.ts
610
+ function controlPose(doc, values2) {
611
+ const out = /* @__PURE__ */ new Map();
612
+ for (const [id, value] of Object.entries(values2)) {
613
+ const c = doc.controls?.find((c2) => c2.id === id);
614
+ if (!c) throw new Error(`unknown control "${id}"`);
615
+ if (!Number.isFinite(value)) throw new Error(`control "${id}" must be finite`);
616
+ out.set(`control:${id}:value`, Math.max(c.min, Math.min(c.max, value)));
617
+ }
618
+ return out;
619
+ }
620
+ function bracket(keys, value) {
621
+ let i = 0;
622
+ while (i < keys.length - 2 && value > keys[i + 1]) i++;
623
+ return [i, Math.max(0, Math.min(1, (value - keys[i]) / (keys[i + 1] - keys[i])))];
624
+ }
625
+ function sampleBinding(binding, values2) {
626
+ const [x, u] = bracket(binding.axes[0].keys, values2[0]);
627
+ const [y, v] = binding.axes[1] ? bracket(binding.axes[1].keys, values2[1]) : [0, 0];
628
+ const width = binding.axes[0].keys.length, a = y * width + x;
629
+ const mix = (a2, b, u2) => a2 + (b - a2) * u2;
630
+ return binding.channels.map((ch) => {
631
+ const read = (i, j2) => j2 === void 0 ? ch.values[i] : ch.values[i][j2];
632
+ const at = (j2) => {
633
+ const top = mix(read(a, j2), read(a + 1, j2), u);
634
+ return binding.axes[1] ? mix(top, mix(read(a + width, j2), read(a + width + 1, j2), u), v) : top;
635
+ };
636
+ return Array.isArray(ch.values[0]) ? ch.values[0].map((_, j2) => at(j2)) : at();
637
+ });
638
+ }
639
+ function resolveControls(doc, input) {
640
+ if (!doc.controlBindings?.length) return input;
641
+ const out = new Map(input), controls = new Map(doc.controls?.map((c) => [c.id, c]));
642
+ for (const b of doc.controlBindings) {
643
+ const values2 = b.axes.map((axis) => {
644
+ const c = controls.get(axis.control);
645
+ const value = input.get(`control:${c.id}:value`) ?? c.default;
646
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`control "${c.id}" must be finite`);
647
+ return Math.max(c.min, Math.min(c.max, value));
648
+ });
649
+ const sampled2 = sampleBinding(b, values2);
650
+ b.channels.forEach((ch, i) => out.set(`${ch.target}:${ch.id}:${ch.prop}`, sampled2[i]));
651
+ }
652
+ return out;
653
+ }
654
+ function controlCases(doc, probes) {
655
+ let cases = [{ label: "default", values: {} }];
656
+ if (!Object.keys(probes).length) throw new Error("select controls and sample values for the sweep");
657
+ for (const [id, values2] of Object.entries(probes)) {
658
+ const c = doc.controls?.find((c2) => c2.id === id);
659
+ if (!c) throw new Error(`unknown control "${id}"`);
660
+ if (!values2.length || values2.some((v) => !Number.isFinite(v) || v < c.min || v > c.max) || new Set(values2).size !== values2.length)
661
+ throw new Error(`control "${id}": probes must be unique finite values inside its range`);
662
+ if (cases.length * values2.length > 256) throw new Error("control sweep exceeds 256 poses; choose a smaller grid");
663
+ cases = cases.flatMap((p) => values2.map((v) => ({ label: p.label === "default" ? `${id}=${v}` : `${p.label}, ${id}=${v}`, values: { ...p.values, [id]: v } })));
664
+ }
665
+ return cases;
666
+ }
667
+
547
668
  // src/rig-anim.ts
548
669
  var DEG2 = Math.PI / 180;
549
670
  var named = {
@@ -647,6 +768,7 @@ function blendPoses(from, to, w, restOf) {
647
768
  }
648
769
  function restValue(doc, key) {
649
770
  const [target, id, prop] = key.split(":");
771
+ if (target === "control") return doc.controls?.find((c) => c.id === id)?.default ?? 0;
650
772
  if (target === "bone") return doc.bones.find((b) => b.id === id).transform[prop];
651
773
  if (target === "slot") {
652
774
  const s = doc.slots.find((s2) => s2.id === id);
@@ -753,7 +875,9 @@ function updateSubtree(skel, root2) {
753
875
  for (const k of kids.get(id) ?? []) stack.push(k.id);
754
876
  }
755
877
  }
756
- function evaluate(doc, pose) {
878
+ function evaluate(doc, pose, modifiers) {
879
+ pose = resolveControls(doc, pose);
880
+ if (modifiers) for (const modify of modifiers) pose = modify(new Map(pose));
757
881
  const skel = { doc, pose, bones: /* @__PURE__ */ new Map(), attachment: /* @__PURE__ */ new Map() };
758
882
  for (const b of boneOrder(doc)) {
759
883
  const s = localOf(doc, pose, b);
@@ -1979,6 +2103,9 @@ function updateBone(doc, id, patch) {
1979
2103
  const i = doc.bones.findIndex((b) => b.id === id);
1980
2104
  if (i < 0) throw new Error(`unknown bone "${id}"`);
1981
2105
  const cur = doc.bones[i];
2106
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels)
2107
+ if (ch.target === "bone" && ch.id === id && patch.transform?.[ch.prop] !== void 0)
2108
+ throw new Error(`bone "${id}" ${ch.prop} is controlled by "${b.id}"; edit its keyforms`);
1982
2109
  const { parent, ...rest } = patch;
1983
2110
  const next = RigSchema.shape.bones.element.parse({
1984
2111
  ...cur,
@@ -2011,6 +2138,11 @@ function patchDrawOrder(doc, fn) {
2011
2138
  return { ...doc, animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.map((t) => t.target !== "drawOrder" ? t : { ...t, keys: t.keys.map((k) => ({ ...k, v: fn(k.v) })) }) })) };
2012
2139
  }
2013
2140
  function setSlot(doc, slot) {
2141
+ if (slot.alpha !== void 0) {
2142
+ for (const b of doc.controlBindings ?? [])
2143
+ if (b.channels.some((ch) => ch.target === "slot" && ch.id === slot.id && ch.prop === "alpha"))
2144
+ throw new Error(`slot "${slot.id}" alpha is controlled by "${b.id}"; edit its keyforms`);
2145
+ }
2014
2146
  const cur = doc.slots.find((s) => s.id === slot.id);
2015
2147
  if (cur && slot.bone && slot.bone !== cur.bone) doc = rebindSlot(doc, slot.id, slot.bone).doc;
2016
2148
  const base = doc.slots.find((s) => s.id === slot.id);
@@ -2385,6 +2517,7 @@ function trackSummary(t) {
2385
2517
  function rename2(doc, kind, id, to) {
2386
2518
  if (!ID_RE.test(to)) throw new Error(`"${to}" is not a snake_case id`);
2387
2519
  if (to === id) return doc;
2520
+ if (doc.controlBindings) doc = { ...doc, controlBindings: doc.controlBindings.map((b) => ({ ...b, channels: b.channels.map((ch) => ch.id === id && (kind === ch.target || kind === "attachment" && ch.target === "deform") ? { ...ch, id: to } : ch) })) };
2388
2521
  const sub = (v) => v === id ? to : v;
2389
2522
  const subKeys = (t, pred, f) => pred ? { ...t, keys: t.keys.map((k) => ({ ...k, v: f(k.v) })) } : t;
2390
2523
  const retrack = (target) => (t) => t.target === target && t.id === id ? { ...t, id: to } : t;
@@ -2466,6 +2599,8 @@ function describeAttachment(doc, att) {
2466
2599
  }
2467
2600
  function describe(doc, opts = {}) {
2468
2601
  const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
2602
+ for (const c of doc.controls ?? []) lines.push(`control ${c.id}: [${c.min}..${c.max}], default ${c.default}${c.label ? ` (${c.label})` : ""}`);
2603
+ for (const b of doc.controlBindings ?? []) lines.push(`keyforms ${b.id}: ${b.axes.map((a) => `${a.control}[${a.keys.join(",")}]`).join(" \xD7 ")} \u2192 ${b.channels.map((c) => `${c.target}.${c.id}.${c.prop}`).join(", ")}`);
2469
2604
  if (doc.meta.notes) lines.push(`notes: ${doc.meta.notes}`);
2470
2605
  const kids = (id) => doc.bones.filter((b) => b.parent === id);
2471
2606
  const place = (s) => {
@@ -3158,8 +3293,8 @@ function motionQuality(doc, anim, opts = {}) {
3158
3293
  if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
3159
3294
  const visible = (id) => {
3160
3295
  const slot = doc.slots.find((s) => s.id === id);
3161
- const color = pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3162
- return Number(pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3296
+ const color = skel.pose.get(pk("slot", id, "color")) ?? colorToArray(slot.color);
3297
+ return Number(skel.pose.get(pk("slot", id, "alpha")) ?? slot.alpha) * color[3] > 0;
3163
3298
  };
3164
3299
  if (!visible(front) || !visible(back)) continue;
3165
3300
  const order = pose.get(pk("drawOrder", "order", "order"));
@@ -3234,6 +3369,7 @@ function checkRig(doc, opts = {}) {
3234
3369
  });
3235
3370
  const W = doc.meta.width, H = doc.meta.height;
3236
3371
  const overflowOf = (pose, slotId, skel) => {
3372
+ pose = skel.pose;
3237
3373
  const pts = slotOutline(doc, pose, slotId, 4, skel);
3238
3374
  if (!pts) return void 0;
3239
3375
  const slot = doc.slots.find((s) => s.id === slotId), alphaV = pose.get(`slot:${slotId}:alpha`);
@@ -3432,6 +3568,7 @@ function checkRig(doc, opts = {}) {
3432
3568
  }
3433
3569
  }
3434
3570
  const vertexKeyed = new Set(a.tracks.filter((t) => t.target === "deform" && t.prop === "vertices").map((t) => t.id));
3571
+ for (const b of doc.controlBindings ?? []) for (const ch of b.channels) if (ch.target === "deform") vertexKeyed.add(ch.id);
3435
3572
  const deformMeshes = meshes.filter((att) => vertexKeyed.has(att.id));
3436
3573
  const bonesMeshes = meshes.filter((att) => !vertexKeyed.has(att.id));
3437
3574
  const meshFolds = /* @__PURE__ */ new Map();
@@ -3768,11 +3905,13 @@ function diffDocs(a, b) {
3768
3905
  ["attachments", "attachment"],
3769
3906
  ["ik", "ik"],
3770
3907
  ["transform", "transform"],
3771
- ["path", "path"]
3908
+ ["path", "path"],
3909
+ ["controls", "control"],
3910
+ ["controlBindings", "control binding"]
3772
3911
  ];
3773
3912
  for (const [kind, name] of kinds) {
3774
- const ma = new Map(a[kind].map((x) => [x.id, x]));
3775
- const mb = new Map(b[kind].map((x) => [x.id, x]));
3913
+ const ma = new Map((a[kind] ?? []).map((x) => [x.id, x]));
3914
+ const mb = new Map((b[kind] ?? []).map((x) => [x.id, x]));
3776
3915
  for (const [id, x] of mb) if (!ma.has(id)) out.push(`+ ${name} ${id} ${j(x)}`);
3777
3916
  for (const [id, x] of ma) {
3778
3917
  const y = mb.get(id);
@@ -4609,7 +4748,7 @@ function extractTemplate(doc, animId, name, groups = {}) {
4609
4748
  const tracks = [];
4610
4749
  const skipped = [];
4611
4750
  for (const t of anim.tracks) {
4612
- if (t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4751
+ if (t.target === "control" || t.target === "drawOrder" || t.keys.some((k) => Array.isArray(k.v))) {
4613
4752
  skipped.push(trackId5(t));
4614
4753
  continue;
4615
4754
  }
@@ -4984,8 +5123,8 @@ function runGates(doc, config) {
4984
5123
  let active2 = 0, bad = 0;
4985
5124
  for (const t of ts) {
4986
5125
  const pose = poseOf(animation, t), s = evaluate(doc, pose);
4987
- const color = pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
4988
- const alpha = Number(pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
5126
+ const color = s.pose.get(pk("slot", slot.id, "color")) ?? colorToArray(slot.color);
5127
+ const alpha = Number(s.pose.get(pk("slot", slot.id, "alpha")) ?? slot.alpha) * color[3];
4989
5128
  if (!s.attachment.get(slot.id) || alpha <= 0.01) continue;
4990
5129
  active2++;
4991
5130
  const v = bone(s, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
@@ -5207,6 +5346,53 @@ function bakeSecondary(doc, animation, opts) {
5207
5346
  return next;
5208
5347
  }
5209
5348
 
5349
+ // src/rig-control-qa.ts
5350
+ function snapshot(doc, values2) {
5351
+ const skel = evaluate(doc, controlPose(doc, values2));
5352
+ return doc.slots.flatMap((slot) => {
5353
+ const alpha = Number(skel.pose.get(`slot:${slot.id}:alpha`) ?? slot.alpha);
5354
+ const points = slotOutline(doc, skel.pose, slot.id, 1, skel);
5355
+ return points ? [{ id: slot.id, alpha, points }] : [];
5356
+ });
5357
+ }
5358
+ function checkControlCases(doc, cases) {
5359
+ const errors = [];
5360
+ const rest = evaluate(doc, /* @__PURE__ */ new Map());
5361
+ cases.forEach((c, index) => {
5362
+ const skel = evaluate(doc, controlPose(doc, c.values));
5363
+ for (const [id, b] of skel.bones) if (Object.values(b.world).some((v) => !Number.isFinite(v)))
5364
+ errors.push({ case: index, subject: id, message: "non-finite bone transform" });
5365
+ for (const att of doc.attachments) if (att.type === "mesh") {
5366
+ const before = attachmentWorldVertices(rest, att.slot, att), after = attachmentWorldVertices(skel, att.slot, att);
5367
+ if (after.some((v) => !Number.isFinite(v))) errors.push({ case: index, subject: att.id, message: "non-finite mesh vertices" });
5368
+ 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))
5369
+ errors.push({ case: index, subject: att.id, message: "mesh triangle reversed or collapsed" });
5370
+ }
5371
+ });
5372
+ const neutral = JSON.stringify(snapshot(doc, {}));
5373
+ const effects = (doc.controls ?? []).map((c) => ({
5374
+ id: c.id,
5375
+ geometryOrOpacityChanged: [c.min, c.max].some((v) => JSON.stringify(snapshot(doc, { [c.id]: v })) !== neutral)
5376
+ }));
5377
+ return {
5378
+ passed: errors.length === 0,
5379
+ errors,
5380
+ effects,
5381
+ 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."
5382
+ };
5383
+ }
5384
+ function makeControlSweep(doc, probes) {
5385
+ const cases = controlCases(doc, probes).map((c) => ({ ...c, values: { ...Object.fromEntries((doc.controls ?? []).map((p) => [p.id, p.default])), ...c.values } }));
5386
+ const animation = {
5387
+ id: "control_sweep",
5388
+ duration: Math.max(1, cases.length - 1),
5389
+ loop: false,
5390
+ events: [],
5391
+ 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" })) }))
5392
+ };
5393
+ return { cases, doc: parse({ ...doc, animations: [animation] }), animation: animation.id, times: cases.map((_, i) => i) };
5394
+ }
5395
+
5210
5396
  // src/rig-api.ts
5211
5397
  var tools = [];
5212
5398
  function tool(name, description, input, run) {
@@ -5269,7 +5455,80 @@ function previewCheck(doc, animation) {
5269
5455
  const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
5270
5456
  return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
5271
5457
  }
5272
- var TrackRef = { interpolation: z3.enum(["segment", "pchip"]).optional().describe("pchip: smooth through numeric keys, preserving extrema; segment: per-key easing"), target: z3.enum(["bone", "slot", "deform", "ik", "transform", "path", "drawOrder"]), id: z3.string(), prop: z3.string() };
5458
+ 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() };
5459
+ tool(
5460
+ "rig_set_control",
5461
+ "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.",
5462
+ { path: Path, control: RigControl },
5463
+ async ({ path, control }) => mutate(path, (d) => ({ ...d, controls: [...(d.controls ?? []).filter((c) => c.id !== control.id), control] }))
5464
+ );
5465
+ tool(
5466
+ "rig_set_control_binding",
5467
+ "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.",
5468
+ { path: Path, binding: ControlBinding },
5469
+ async ({ path, binding }) => mutate(path, (d) => ({ ...d, controlBindings: [...(d.controlBindings ?? []).filter((b) => b.id !== binding.id), binding] }))
5470
+ );
5471
+ tool(
5472
+ "rig_remove_control_binding",
5473
+ "Remove a control binding, restoring its channels to setup/animation values.",
5474
+ { path: Path, id: z3.string() },
5475
+ async ({ path, id }) => mutate(path, (d) => {
5476
+ if (!d.controlBindings?.some((b) => b.id === id)) throw new Error(`unknown binding "${id}"`);
5477
+ return { ...d, controlBindings: d.controlBindings.filter((b) => b.id !== id) };
5478
+ })
5479
+ );
5480
+ tool(
5481
+ "rig_remove_control",
5482
+ "Remove an unused control and its animation tracks. Remove dependent bindings first.",
5483
+ { path: Path, id: z3.string() },
5484
+ async ({ path, id }) => mutate(path, (d) => {
5485
+ if (!d.controls?.some((c) => c.id === id)) throw new Error(`unknown control "${id}"`);
5486
+ if (d.controlBindings?.some((b) => b.axes.some((a) => a.control === id))) throw new Error("remove dependent control bindings first");
5487
+ 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) })) };
5488
+ })
5489
+ );
5490
+ tool(
5491
+ "rig_sweep_controls",
5492
+ "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.",
5493
+ {
5494
+ path: Path,
5495
+ probes: z3.record(z3.array(z3.number().finite()).min(1)),
5496
+ output: z3.string(),
5497
+ scale: z3.number().positive().max(2).default(0.5),
5498
+ region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional()
5499
+ },
5500
+ async ({ path, probes, output, scale, region }) => {
5501
+ try {
5502
+ const raw = await readFile9(path, "utf8"), d = parse(JSON.parse(raw));
5503
+ const sweep = makeControlSweep(d, probes), out = resolveFrom(path, output);
5504
+ if (await exists2(out) && (await readdir4(out)).length) throw new Error("sweep output must be a new or empty directory");
5505
+ const qa = checkControlCases(d, sweep.cases);
5506
+ const r = await renderPreview(sweep.doc, { assetsDir: dirname8(path), animation: sweep.animation, times: sweep.times, scale, region, transparent: true });
5507
+ await mkdir6(out, { recursive: true });
5508
+ const frames = [];
5509
+ for (let i = 0; i < r.frames.length; i++) {
5510
+ const file = `pose-${String(i).padStart(3, "0")}.png`;
5511
+ await writeFile7(join9(out, file), r.frames[i]);
5512
+ frames.push({ file, sha256: sha256(r.frames[i]), ...sweep.cases[i] });
5513
+ }
5514
+ await writeFile7(join9(out, "sheet.png"), r.sheet);
5515
+ const report = {
5516
+ version: 1,
5517
+ sourceRig: sha256(raw),
5518
+ identity: r.identity,
5519
+ scale,
5520
+ region: region ?? null,
5521
+ ...qa,
5522
+ frames,
5523
+ sheet: { file: "sheet.png", sha256: sha256(r.sheet) }
5524
+ };
5525
+ await saveJson(join9(out, "report.json"), report);
5526
+ return { text: JSON.stringify({ output: out, poseCount: frames.length, ...qa }), image: r.sheet };
5527
+ } catch (e) {
5528
+ return fail(e);
5529
+ }
5530
+ }
5531
+ );
5273
5532
  var KeyIn = z3.object({
5274
5533
  t: z3.number().describe("seconds"),
5275
5534
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
@@ -6101,8 +6360,8 @@ tool(
6101
6360
  const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
6102
6361
  if (saveTo) await writeFile7(saveTo, r.sheet);
6103
6362
  if (framesDir) {
6104
- const { mkdir: mkdir7 } = await import("node:fs/promises");
6105
- await mkdir7(framesDir, { recursive: true });
6363
+ const { mkdir: mkdir8 } = await import("node:fs/promises");
6364
+ await mkdir8(framesDir, { recursive: true });
6106
6365
  const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
6107
6366
  for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
6108
6367
  const manifest = {
@@ -6252,7 +6511,7 @@ data: ${JSON.stringify({ version: v })}
6252
6511
  async function listRigs() {
6253
6512
  const out = [];
6254
6513
  const walk = async (dir) => {
6255
- for (const e of await readdir4(dir, { withFileTypes: true })) {
6514
+ for (const e of await readdir5(dir, { withFileTypes: true })) {
6256
6515
  if (e.isDirectory()) {
6257
6516
  if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
6258
6517
  continue;
@@ -6272,7 +6531,7 @@ data: ${JSON.stringify({ version: v })}
6272
6531
  }
6273
6532
  async function listFiles(rigAbs) {
6274
6533
  const dir = dirname9(rigAbs), out = [];
6275
- for (const e of (await readdir4(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6534
+ for (const e of (await readdir5(dir, { withFileTypes: true })).filter((e2) => e2.isFile() && /\.png$/i.test(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
6276
6535
  const fh = await open(join10(dir, e.name));
6277
6536
  try {
6278
6537
  const head = Buffer.alloc(24);
@@ -6390,7 +6649,7 @@ data: ${JSON.stringify({ version: v })}
6390
6649
  const abs = inside(rig);
6391
6650
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6392
6651
  if (await stat3(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
6393
- await mkdir6(dirname9(abs), { recursive: true });
6652
+ await mkdir7(dirname9(abs), { recursive: true });
6394
6653
  const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
6395
6654
  ensureWatch(abs);
6396
6655
  return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });