@energy8platform/golem 0.2.0 → 0.3.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/README.md +11 -2
- package/dist/editor.js +502 -87
- package/dist/lib/cli.js +1309 -359
- package/dist/lib/cli.js.map +4 -4
- package/dist/lib/editor/stage.d.ts +7 -0
- package/dist/lib/editor/store.d.ts +72 -32
- package/dist/lib/editor-entry.js +1309 -359
- package/dist/lib/editor-entry.js.map +4 -4
- package/dist/lib/harness.js +162 -27
- package/dist/lib/preview/export-video.d.ts +12 -0
- package/dist/lib/preview/harness.d.ts +2 -0
- package/dist/lib/preview/render-preview.d.ts +6 -0
- package/dist/lib/rig-anim.d.ts +1 -1
- package/dist/lib/rig-check.d.ts +2 -1
- package/dist/lib/rig-constraints.d.ts +63 -0
- package/dist/lib/rig-format.d.ts +551 -325
- package/dist/lib/rig-gates.d.ts +209 -0
- package/dist/lib/rig-mesh.d.ts +1 -0
- package/dist/lib/rig-motion.d.ts +22 -0
- package/dist/lib/rig-qa.d.ts +35 -0
- package/dist/lib/rig-quality.d.ts +12 -0
- package/dist/lib/rig-runtime.d.ts +10 -1
- package/dist/lib/rig-state.d.ts +3 -0
- package/dist/lib/rig-tools.d.ts +5 -1
- package/dist/lib/runtime.js +146 -25
- package/dist/lib/runtime.js.map +3 -3
- package/dist/lib/tools.js +1291 -341
- package/dist/lib/tools.js.map +4 -4
- package/package.json +2 -2
package/dist/lib/cli.js
CHANGED
|
@@ -3,10 +3,10 @@ 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
|
|
6
|
+
import { readFile as readFile10, stat as stat3, readdir as readdir4, mkdir as mkdir6, open } from "node:fs/promises";
|
|
7
7
|
import { watch as fsWatch } from "node:fs";
|
|
8
|
-
import { resolve as resolve2, dirname as
|
|
9
|
-
import { fileURLToPath as
|
|
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
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// src/rig-format.ts
|
|
12
12
|
import { z } from "zod";
|
|
@@ -65,7 +65,7 @@ function minJacobian(offsets, w, h, samples = 17) {
|
|
|
65
65
|
var ID_RE = /^[a-z][a-z0-9_]*$/;
|
|
66
66
|
var Id = z.string().regex(ID_RE, "snake_case id");
|
|
67
67
|
var Ease = z.union([
|
|
68
|
-
z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "back_out", "elastic_out"]),
|
|
68
|
+
z.enum(["linear", "step", "ease_in", "ease_out", "ease_in_out", "minimum_jerk", "back_out", "elastic_out"]),
|
|
69
69
|
// cubic-bezier control points, same semantics as CSS
|
|
70
70
|
z.object({ bezier: z.tuple([z.number(), z.number(), z.number(), z.number()]) })
|
|
71
71
|
]);
|
|
@@ -97,6 +97,7 @@ var Asset = z.object({
|
|
|
97
97
|
/** where this layer came from — provenance for agents */
|
|
98
98
|
origin: z.object({
|
|
99
99
|
sourceImage: z.string().optional(),
|
|
100
|
+
bboxSpace: z.enum(["source", "document"]).optional(),
|
|
100
101
|
/** bbox in source image: [x, y, w, h] */
|
|
101
102
|
bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
|
|
102
103
|
inpainted: z.boolean().default(false),
|
|
@@ -119,6 +120,8 @@ var Bone = z.object({
|
|
|
119
120
|
var Slot = z.object({
|
|
120
121
|
id: Id,
|
|
121
122
|
bone: Id,
|
|
123
|
+
/** Closed path attachment used as a moving clipping mask. */
|
|
124
|
+
clip: Id.optional(),
|
|
122
125
|
/** active attachment id; undefined = hidden */
|
|
123
126
|
attachment: Id.optional(),
|
|
124
127
|
alpha: z.number().min(0).max(1).default(1),
|
|
@@ -181,6 +184,8 @@ var IkConstraint = z.object({
|
|
|
181
184
|
order: z.number().int().default(0),
|
|
182
185
|
bones: z.array(Id).min(1),
|
|
183
186
|
target: Id,
|
|
187
|
+
iterations: z.number().int().min(1).max(1e3).optional(),
|
|
188
|
+
tolerance: z.number().positive().optional(),
|
|
184
189
|
mix: z.number().default(1),
|
|
185
190
|
softness: z.number().default(0),
|
|
186
191
|
bendPositive: z.boolean().default(true),
|
|
@@ -233,7 +238,7 @@ var BoneProp = z.enum(["x", "y", "rotation", "scaleX", "scaleY", "shearX", "shea
|
|
|
233
238
|
var SlotProp = z.enum(["alpha", "attachment", "z", "color"]);
|
|
234
239
|
var DeformProp = z.string().regex(/^(bulge|bend|sway|squash|p[0-2][0-2][xy]|vertices)$/, "deform prop must be bulge|bend|sway|squash|p<row><col>x|y|vertices");
|
|
235
240
|
var IkProp = z.enum(["mix", "softness", "bendPositive", "compress", "stretch"]);
|
|
236
|
-
var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY"]);
|
|
241
|
+
var TransformProp = z.enum(["mixRotate", "mixX", "mixY", "mixScaleX", "mixScaleY", "mixShearY", "x", "y", "rotation", "scaleX", "scaleY", "shearY"]);
|
|
237
242
|
var PathProp = z.enum(["position", "spacing", "mixRotate", "mixX", "mixY"]);
|
|
238
243
|
var KeyValue = z.union([z.number(), z.string(), z.null(), z.array(z.number()), z.array(z.string())]);
|
|
239
244
|
var Key = z.object({
|
|
@@ -242,14 +247,15 @@ var Key = z.object({
|
|
|
242
247
|
/** easing INTO the next key */
|
|
243
248
|
ease: Ease.default("ease_in_out")
|
|
244
249
|
});
|
|
250
|
+
var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
|
|
245
251
|
var Track = z.discriminatedUnion("target", [
|
|
246
|
-
z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, keys: z.array(Key).min(1) }),
|
|
247
|
-
z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, keys: z.array(Key).min(1) }),
|
|
248
|
-
z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, keys: z.array(Key).min(1) }),
|
|
249
|
-
z.object({ target: z.literal("ik"), id: Id, prop: IkProp, keys: z.array(Key).min(1) }),
|
|
250
|
-
z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, keys: z.array(Key).min(1) }),
|
|
251
|
-
z.object({ target: z.literal("path"), id: Id, prop: PathProp, keys: z.array(Key).min(1) }),
|
|
252
|
-
z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), keys: z.array(Key).min(1) })
|
|
252
|
+
z.object({ target: z.literal("bone"), id: Id, prop: BoneProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
253
|
+
z.object({ target: z.literal("slot"), id: Id, prop: SlotProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
254
|
+
z.object({ target: z.literal("deform"), id: Id, prop: DeformProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
255
|
+
z.object({ target: z.literal("ik"), id: Id, prop: IkProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
256
|
+
z.object({ target: z.literal("transform"), id: Id, prop: TransformProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
257
|
+
z.object({ target: z.literal("path"), id: Id, prop: PathProp, ...Interpolation, keys: z.array(Key).min(1) }),
|
|
258
|
+
z.object({ target: z.literal("drawOrder"), id: Id.default("order"), prop: z.literal("order").default("order"), ...Interpolation, keys: z.array(Key).min(1) })
|
|
253
259
|
]);
|
|
254
260
|
var Event = z.object({
|
|
255
261
|
t: z.number().nonnegative(),
|
|
@@ -391,6 +397,10 @@ function validate(doc) {
|
|
|
391
397
|
if (a.frame && !a.trim && !a.rotate && (a.frame[2] !== a.width || a.frame[3] !== a.height)) errs.push(`asset "${a.id}": frame size must equal width\xD7height (or declare trim/rotate)`);
|
|
392
398
|
}
|
|
393
399
|
for (const s2 of doc.slots) {
|
|
400
|
+
if (s2.clip) {
|
|
401
|
+
const mask = attachments.get(s2.clip);
|
|
402
|
+
if (mask?.type !== "path" || !mask.closed) errs.push(`slot "${s2.id}": clip must name a closed path attachment`);
|
|
403
|
+
}
|
|
394
404
|
if (!bones.has(s2.bone)) errs.push(`slot "${s2.id}": unknown bone "${s2.bone}"`);
|
|
395
405
|
if (s2.attachment) {
|
|
396
406
|
const a = attachments.get(s2.attachment);
|
|
@@ -422,7 +432,8 @@ function validate(doc) {
|
|
|
422
432
|
}
|
|
423
433
|
}
|
|
424
434
|
for (const c of doc.ik) {
|
|
425
|
-
|
|
435
|
+
for (let i = 1; i < c.bones.length; i++) if (doc.bones.find((b) => b.id === c.bones[i])?.parent !== c.bones[i - 1])
|
|
436
|
+
errs.push(`ik "${c.id}": bones must form a contiguous parent-to-child chain`);
|
|
426
437
|
for (const b of c.bones) if (!bones.has(b)) errs.push(`ik "${c.id}": unknown bone "${b}"`);
|
|
427
438
|
if (!bones.has(c.target)) errs.push(`ik "${c.id}": unknown target bone "${c.target}"`);
|
|
428
439
|
}
|
|
@@ -487,9 +498,9 @@ function parse(input) {
|
|
|
487
498
|
}
|
|
488
499
|
|
|
489
500
|
// src/rig-api.ts
|
|
490
|
-
import { z as
|
|
491
|
-
import { readFile as
|
|
492
|
-
import { dirname as
|
|
501
|
+
import { z as z3 } from "zod";
|
|
502
|
+
import { readFile as readFile9, writeFile as writeFile7, stat as stat2 } from "node:fs/promises";
|
|
503
|
+
import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
|
|
493
504
|
|
|
494
505
|
// src/rig-io.ts
|
|
495
506
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -544,6 +555,7 @@ var named = {
|
|
|
544
555
|
ease_in: (t) => t * t,
|
|
545
556
|
ease_out: (t) => 1 - (1 - t) * (1 - t),
|
|
546
557
|
ease_in_out: (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
|
|
558
|
+
minimum_jerk: (t) => t * t * t * (10 + t * (-15 + 6 * t)),
|
|
547
559
|
back_out: (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2,
|
|
548
560
|
elastic_out: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1
|
|
549
561
|
};
|
|
@@ -575,7 +587,25 @@ function ease(spec) {
|
|
|
575
587
|
return f;
|
|
576
588
|
}
|
|
577
589
|
var pk = (target, id, prop) => `${target}:${id}:${prop}`;
|
|
578
|
-
function
|
|
590
|
+
function smoothValue(track, i, t, component, loop) {
|
|
591
|
+
const k = track.keys, n = k.length;
|
|
592
|
+
const v = (j2) => component === void 0 ? k[j2].v : k[j2].v[component];
|
|
593
|
+
const h = (j2) => k[j2 + 1].t - k[j2].t;
|
|
594
|
+
const d = (j2) => (v(j2 + 1) - v(j2)) / h(j2);
|
|
595
|
+
const harmonic = (a, b, h0, h1) => a * b <= 0 ? 0 : 3 * (h0 + h1) / ((2 * h1 + h0) / a + (h1 + 2 * h0) / b);
|
|
596
|
+
const tangent = (j2) => {
|
|
597
|
+
if (j2 === 0 || j2 === n - 1) {
|
|
598
|
+
if (loop && n > 2 && v(0) === v(n - 1) && k[0].ease !== "step" && k[n - 2].ease !== "step")
|
|
599
|
+
return harmonic(d(n - 2), d(0), h(n - 2), h(0));
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
if (k[j2 - 1].ease === "step" || k[j2].ease === "step") return 0;
|
|
603
|
+
return harmonic(d(j2 - 1), d(j2), h(j2 - 1), h(j2));
|
|
604
|
+
};
|
|
605
|
+
const u = (t - k[i].t) / h(i), u2 = u * u, u3 = u2 * u;
|
|
606
|
+
return (2 * u3 - 3 * u2 + 1) * v(i) + (u3 - 2 * u2 + u) * h(i) * tangent(i) + (-2 * u3 + 3 * u2) * v(i + 1) + (u3 - u2) * h(i) * tangent(i + 1);
|
|
607
|
+
}
|
|
608
|
+
function sample(track, t, loop = false) {
|
|
579
609
|
const keys = track.keys;
|
|
580
610
|
if (t <= keys[0].t) return keys[0].v;
|
|
581
611
|
const last = keys[keys.length - 1];
|
|
@@ -583,18 +613,39 @@ function sample(track, t) {
|
|
|
583
613
|
let i = 0;
|
|
584
614
|
while (keys[i + 1].t <= t) i++;
|
|
585
615
|
const a = keys[i], b = keys[i + 1];
|
|
616
|
+
if (a.ease === "step") return a.v;
|
|
586
617
|
if (typeof a.v === "number" && typeof b.v === "number") {
|
|
618
|
+
if (track.interpolation === "pchip") return smoothValue(track, i, t, void 0, loop);
|
|
587
619
|
const u = ease(a.ease)((t - a.t) / (b.t - a.t));
|
|
588
620
|
return a.v + (b.v - a.v) * u;
|
|
589
621
|
}
|
|
590
622
|
if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
|
|
623
|
+
if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
|
|
591
624
|
const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
|
|
592
625
|
return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
|
|
593
626
|
}
|
|
594
627
|
return a.v;
|
|
595
628
|
}
|
|
596
629
|
function poseOf(anim, t, out = /* @__PURE__ */ new Map()) {
|
|
597
|
-
for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t));
|
|
630
|
+
for (const tr of anim.tracks) out.set(pk(tr.target, tr.id, tr.prop), sample(tr, t, anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === anim.duration));
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
function blendPoses(from, to, w, restOf) {
|
|
634
|
+
if (w >= 1) return new Map(to);
|
|
635
|
+
const out = /* @__PURE__ */ new Map();
|
|
636
|
+
for (const k of /* @__PURE__ */ new Set([...from.keys(), ...to.keys()])) {
|
|
637
|
+
const f = from.has(k) ? from.get(k) : restOf(k);
|
|
638
|
+
const cur = to.has(k) ? to.get(k) : restOf(k);
|
|
639
|
+
if (typeof f === "number" && !Array.isArray(cur)) {
|
|
640
|
+
const target = typeof cur === "number" ? cur : restOf(k);
|
|
641
|
+
out.set(k, f + (target - f) * w);
|
|
642
|
+
} else if (Array.isArray(f) && typeof f[0] === "number") {
|
|
643
|
+
const fv = f, tv = Array.isArray(cur) && typeof cur[0] === "number" ? cur : fv.map(() => 0);
|
|
644
|
+
out.set(k, fv.map((v, i) => v + ((tv[i] ?? 0) - v) * w));
|
|
645
|
+
} else if (Array.isArray(cur) && typeof cur[0] === "number" && typeof f !== "string") {
|
|
646
|
+
out.set(k, cur.map((v) => v * w));
|
|
647
|
+
} else out.set(k, f ?? null);
|
|
648
|
+
}
|
|
598
649
|
return out;
|
|
599
650
|
}
|
|
600
651
|
function restValue(doc, key) {
|
|
@@ -766,10 +817,43 @@ function aimAt(skel, boneId, tx, ty, mix, compress = false, stretch = false, uni
|
|
|
766
817
|
updateSubtree(skel, boneId);
|
|
767
818
|
}
|
|
768
819
|
var shortest = (deg) => ((deg + 180) % 360 + 360) % 360 - 180;
|
|
820
|
+
function applyLongIk(skel, c, tx, ty, mix) {
|
|
821
|
+
const initial = c.bones.map((id) => ({ ...skel.bones.get(id) }));
|
|
822
|
+
const last = c.bones.at(-1), len = boneOf(skel.doc, last).length;
|
|
823
|
+
const end = () => apply(skel.bones.get(last).world, len, 0);
|
|
824
|
+
const tolerance = c.tolerance ?? 0.05;
|
|
825
|
+
const root3 = skel.bones.get(c.bones[0]).world, tip = end();
|
|
826
|
+
if (Math.abs((tip[0] - root3.tx) * (ty - root3.ty) - (tip[1] - root3.ty) * (tx - root3.tx)) < 1e-7 && Math.hypot(tx - root3.tx, ty - root3.ty) < Math.hypot(tip[0] - root3.tx, tip[1] - root3.ty) - tolerance) {
|
|
827
|
+
skel.bones.get(last).rotation += cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 5 : -5;
|
|
828
|
+
updateSubtree(skel, last);
|
|
829
|
+
}
|
|
830
|
+
for (let iter = 0; iter < (c.iterations ?? 100); iter++) {
|
|
831
|
+
const e = end();
|
|
832
|
+
if (Math.hypot(e[0] - tx, e[1] - ty) <= tolerance) break;
|
|
833
|
+
for (let i = c.bones.length - 1; i >= 0; i--) {
|
|
834
|
+
const id = c.bones[i], b = boneOf(skel.doc, id), st = skel.bones.get(id), w = st.world, e2 = end();
|
|
835
|
+
const angle = wrapRad(Math.atan2(ty - w.ty, tx - w.tx) - Math.atan2(e2[1] - w.ty, e2[0] - w.tx));
|
|
836
|
+
const cos = Math.cos(angle), sin = Math.sin(angle);
|
|
837
|
+
setEffectiveWorld(skel, b, st, { ...w, a: cos * w.a - sin * w.b, b: sin * w.a + cos * w.b, c: cos * w.c - sin * w.d, d: sin * w.c + cos * w.d });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (mix < 1) {
|
|
841
|
+
c.bones.forEach((id, i) => {
|
|
842
|
+
const st = skel.bones.get(id), from = initial[i];
|
|
843
|
+
for (const p of ["rotation", "shearX", "shearY"]) st[p] = from[p] + shortest(st[p] - from[p]) * mix;
|
|
844
|
+
for (const p of ["x", "y", "scaleX", "scaleY"]) st[p] = from[p] + (st[p] - from[p]) * mix;
|
|
845
|
+
});
|
|
846
|
+
updateSubtree(skel, c.bones[0]);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
769
849
|
function applyIk(skel, c) {
|
|
770
850
|
const mix = cnum(skel, "ik", c.id, "mix", c.mix);
|
|
771
851
|
if (mix <= 0) return;
|
|
772
852
|
const target = skel.bones.get(c.target).world;
|
|
853
|
+
if (c.bones.length > 2) {
|
|
854
|
+
applyLongIk(skel, c, target.tx, target.ty, mix);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
773
857
|
const bendDir = cnum(skel, "ik", c.id, "bendPositive", c.bendPositive) > 0.5 ? 1 : -1;
|
|
774
858
|
const compress = cnum(skel, "ik", c.id, "compress", c.compress) > 0.5, stretch = cnum(skel, "ik", c.id, "stretch", c.stretch) > 0.5;
|
|
775
859
|
let softness = cnum(skel, "ik", c.id, "softness", c.softness);
|
|
@@ -904,6 +988,7 @@ function applyTransformConstraint(skel, c) {
|
|
|
904
988
|
const mixRotate = cnum(skel, "transform", c.id, "mixRotate", c.mixRotate), mixX = cnum(skel, "transform", c.id, "mixX", c.mixX), mixY = cnum(skel, "transform", c.id, "mixY", c.mixY);
|
|
905
989
|
const mixScaleX = cnum(skel, "transform", c.id, "mixScaleX", c.mixScaleX), mixScaleY = cnum(skel, "transform", c.id, "mixScaleY", c.mixScaleY), mixShearY = cnum(skel, "transform", c.id, "mixShearY", c.mixShearY);
|
|
906
990
|
if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
|
|
991
|
+
c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
|
|
907
992
|
const target = skel.bones.get(c.target), tw = target.world;
|
|
908
993
|
const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
|
|
909
994
|
for (const id of c.bones) {
|
|
@@ -1294,10 +1379,10 @@ function traceLoops(mask, threshold) {
|
|
|
1294
1379
|
return loops;
|
|
1295
1380
|
}
|
|
1296
1381
|
function traceHulls(mask, opts = {}) {
|
|
1297
|
-
const { threshold = 8, tolerance = 2, margin = 1 } = opts;
|
|
1382
|
+
const { threshold = 8, tolerance = 2, margin = 1, minIslandRatio = MIN_ISLAND_AREA } = opts;
|
|
1298
1383
|
const loops = traceLoops(mask, threshold).map((l) => ({ l, a: signedArea(l) }));
|
|
1299
1384
|
const biggest = loops.reduce((m, x) => Math.abs(x.a) > Math.abs(m.a) ? x : m, loops[0]);
|
|
1300
|
-
const islands = loops.filter((x) => Math.sign(x.a) === Math.sign(biggest.a) && Math.abs(x.a) >=
|
|
1385
|
+
const islands = loops.filter((x) => Math.sign(x.a) === Math.sign(biggest.a) && Math.abs(x.a) >= minIslandRatio * Math.abs(biggest.a)).sort((p, q) => Math.abs(q.a) - Math.abs(p.a));
|
|
1301
1386
|
return islands.map(({ l }) => {
|
|
1302
1387
|
const s2 = simplify(dropCollinear(l), tolerance);
|
|
1303
1388
|
return margin ? offsetPolygon(s2, margin) : s2;
|
|
@@ -1700,6 +1785,54 @@ function pathFromPoints(skel, slotId, points, closed) {
|
|
|
1700
1785
|
}
|
|
1701
1786
|
return { vertices, closed, lengths: curveLengths(flatten(ctrl.flat()), closed) };
|
|
1702
1787
|
}
|
|
1788
|
+
function constraintDependencies(doc) {
|
|
1789
|
+
const parent = new Map(doc.bones.map((b) => [b.id, b.parent]));
|
|
1790
|
+
const ancestors = (id) => {
|
|
1791
|
+
const out = /* @__PURE__ */ new Set();
|
|
1792
|
+
for (let b = id; b && !out.has(b); b = parent.get(b)) out.add(b);
|
|
1793
|
+
return out;
|
|
1794
|
+
};
|
|
1795
|
+
const all = [
|
|
1796
|
+
...doc.ik.map((c) => ({ kind: "ik", ...c })),
|
|
1797
|
+
...doc.transform.map((c) => ({ kind: "transform", ...c })),
|
|
1798
|
+
...doc.path.map((c) => ({ kind: "path", ...c }))
|
|
1799
|
+
].sort((a, b) => a.order - b.order);
|
|
1800
|
+
const active2 = (c) => MIX_PROPS[c.kind].some((p) => c[p] !== 0 || doc.animations.some((a) => a.tracks.some((t) => t.target === c.kind && t.id === c.id && t.prop === p && t.keys.some((k) => typeof k.v === "number" && k.v !== 0))));
|
|
1801
|
+
const edges = [];
|
|
1802
|
+
all.forEach((reader, ri) => {
|
|
1803
|
+
if (!active2(reader)) return;
|
|
1804
|
+
const reads = /* @__PURE__ */ new Set();
|
|
1805
|
+
const add = (b) => ancestors(b).forEach((x) => reads.add(x));
|
|
1806
|
+
if (reader.kind === "path") {
|
|
1807
|
+
const slot = doc.slots.find((s2) => s2.id === reader.target);
|
|
1808
|
+
if (slot) add(slot.bone);
|
|
1809
|
+
for (const a of doc.attachments) if (a.slot === reader.target && a.type === "path") {
|
|
1810
|
+
for (const list of a.weights ?? []) for (const inf of list) if (inf.w !== 0) add(inf.bone);
|
|
1811
|
+
}
|
|
1812
|
+
} else add(reader.target);
|
|
1813
|
+
for (const b of reader.bones) {
|
|
1814
|
+
const p = parent.get(b);
|
|
1815
|
+
if (p && !reader.bones.includes(p)) add(p);
|
|
1816
|
+
}
|
|
1817
|
+
all.forEach((writer, wi) => {
|
|
1818
|
+
if (ri === wi || !active2(writer)) return;
|
|
1819
|
+
const bone = writer.bones.find((b) => reads.has(b));
|
|
1820
|
+
if (bone) edges.push({ reader: reader.id, readerKind: reader.kind, writer: writer.id, writerKind: writer.kind, bone, late: wi > ri });
|
|
1821
|
+
});
|
|
1822
|
+
});
|
|
1823
|
+
return { all, edges };
|
|
1824
|
+
}
|
|
1825
|
+
function orderConstraints(doc) {
|
|
1826
|
+
const { all, edges } = constraintDependencies(doc);
|
|
1827
|
+
const pending3 = [...all], ids = [];
|
|
1828
|
+
while (pending3.length) {
|
|
1829
|
+
const i = pending3.findIndex((c2) => edges.filter((e) => e.reader === c2.id && e.readerKind === c2.kind).every((e) => ids.includes(`${e.writerKind}:${e.writer}`)));
|
|
1830
|
+
if (i < 0) throw new Error(`constraint dependency cycle: ${pending3.map((c2) => c2.id).join(", ")}`);
|
|
1831
|
+
const c = pending3.splice(i, 1)[0];
|
|
1832
|
+
ids.push(`${c.kind}:${c.id}`);
|
|
1833
|
+
}
|
|
1834
|
+
return { ...doc, ik: doc.ik.map((c) => ({ ...c, order: ids.indexOf(`ik:${c.id}`) })), transform: doc.transform.map((c) => ({ ...c, order: ids.indexOf(`transform:${c.id}`) })), path: doc.path.map((c) => ({ ...c, order: ids.indexOf(`path:${c.id}`) })) };
|
|
1835
|
+
}
|
|
1703
1836
|
|
|
1704
1837
|
// src/rig-tools.ts
|
|
1705
1838
|
function createDoc(name, width, height, notes) {
|
|
@@ -1801,13 +1934,13 @@ function placeAsset(doc, p) {
|
|
|
1801
1934
|
const boneId = cur?.bone ?? p.bone;
|
|
1802
1935
|
const world = evaluate(doc, /* @__PURE__ */ new Map()).bones.get(boneId)?.world;
|
|
1803
1936
|
if (!world) throw new Error(`unknown bone "${boneId}"`);
|
|
1804
|
-
const
|
|
1805
|
-
let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z:
|
|
1937
|
+
const z4 = p.z ?? cur?.z ?? (doc.slots.length ? Math.max(...doc.slots.map((s2) => s2.z)) + 1 : 0);
|
|
1938
|
+
let next = cur ? doc : setSlot(doc, { id: slotId, bone: boneId, z: z4 });
|
|
1806
1939
|
const [lx, ly] = apply(invert(world), p.x, p.y);
|
|
1807
1940
|
next = setAttachment(next, { id: p.asset, slot: slotId, asset: p.asset, x: round4(lx), y: round4(ly) });
|
|
1808
1941
|
return setSlot(next, { id: slotId, attachment: p.asset, ...p.z !== void 0 ? { z: p.z } : {} });
|
|
1809
1942
|
}
|
|
1810
|
-
function layerPrompt(doc, assetId) {
|
|
1943
|
+
function layerPrompt(doc, assetId, opts = {}) {
|
|
1811
1944
|
const a = doc.assets.find((a2) => a2.id === assetId);
|
|
1812
1945
|
if (!a) throw new Error(`unknown asset "${assetId}"`);
|
|
1813
1946
|
const att = doc.attachments.find((a2) => a2.type !== "path" && a2.asset === assetId);
|
|
@@ -1820,10 +1953,15 @@ function layerPrompt(doc, assetId) {
|
|
|
1820
1953
|
`Part: ${a.origin?.prompt ?? a.id}.`,
|
|
1821
1954
|
`Size: ${a.width}\xD7${a.height} px at source scale${a.origin?.bbox ? ` (source bbox [${a.origin.bbox.join(", ")}])` : ""}; keep the same silhouette registration` + (att && att.type === "region" ? ` so the image centre stays at (${+att.x.toFixed(1)}, ${+att.y.toFixed(1)}) from the joint.` : "."),
|
|
1822
1955
|
bone ? `Rig context: attached to bone "${bone.id}"${bone.parent ? `, parent "${bone.parent}"` : ""}${children.length ? `; child parts: ${children.join(", ")}` : ""}.` : "Rig context: not attached yet.",
|
|
1823
|
-
"
|
|
1956
|
+
"Paint overlap beyond each hidden joint end through the planned motion range; choose its depth from the pivot and extreme poses, not a fixed pixel margin.",
|
|
1824
1957
|
"Composition: orthographic front view, one connected opaque part only, same orientation as the current layer, centred with generous padding, no foreshortening.",
|
|
1825
|
-
"Backdrop:
|
|
1958
|
+
"Backdrop: transparent RGBA, no floor, shadow, text or watermark. If the selected generator cannot output alpha, use a flat #00FF00 guide background and remove only the reviewed background region."
|
|
1826
1959
|
];
|
|
1960
|
+
if (opts.method === "repair") lines.push(
|
|
1961
|
+
"Complete the host part inside the marked hidden-surface region. Retain surrounding art as a registration guide; small differences outside the repair are acceptable because assembly keeps the approved pixels there.",
|
|
1962
|
+
`Do not recreate removed occluders${opts.removed?.length ? ": " + opts.removed.join(", ") : " (neighbouring parts deliberately removed from the guide)"}. Continue only this part's own material and contour; no guide magenta in the output.`,
|
|
1963
|
+
"After generation: fit unchanged landmarks, remove explicitly masked background, match host-material tone, feather into the host colour and repair pale fringe only inside the ink mask."
|
|
1964
|
+
);
|
|
1827
1965
|
return lines.join("\n");
|
|
1828
1966
|
}
|
|
1829
1967
|
function setMeta(doc, patch) {
|
|
@@ -1887,7 +2025,7 @@ function removeSlot(doc, id) {
|
|
|
1887
2025
|
const gone = new Set(doc.attachments.filter((a) => a.slot === id).map((a) => a.id));
|
|
1888
2026
|
return patchDrawOrder({
|
|
1889
2027
|
...doc,
|
|
1890
|
-
slots: doc.slots.filter((s2) => s2.id !== id),
|
|
2028
|
+
slots: doc.slots.filter((s2) => s2.id !== id).map((s2) => gone.has(s2.clip ?? "") ? { ...s2, clip: void 0 } : s2),
|
|
1891
2029
|
attachments: doc.attachments.filter((a) => a.slot !== id),
|
|
1892
2030
|
animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
|
|
1893
2031
|
}, (o) => o.filter((s2) => s2 !== id));
|
|
@@ -1935,8 +2073,8 @@ function rebindSlot(doc, slotId, bone) {
|
|
|
1935
2073
|
function setDrawOrder(doc, order) {
|
|
1936
2074
|
const ids = new Set(doc.slots.map((s2) => s2.id));
|
|
1937
2075
|
if (order.length !== ids.size || new Set(order).size !== order.length || !order.every((s2) => ids.has(s2))) throw new Error("order must list every slot exactly once");
|
|
1938
|
-
const
|
|
1939
|
-
return { ...doc, slots: doc.slots.map((s2) => ({ ...s2, z:
|
|
2076
|
+
const z4 = new Map(order.map((id, i) => [id, i]));
|
|
2077
|
+
return { ...doc, slots: doc.slots.map((s2) => ({ ...s2, z: z4.get(s2.id) })) };
|
|
1940
2078
|
}
|
|
1941
2079
|
function setAttachment(doc, att) {
|
|
1942
2080
|
const cur = doc.attachments.find((a) => a.id === att.id);
|
|
@@ -1974,31 +2112,33 @@ function setMesh(doc, id, patch) {
|
|
|
1974
2112
|
const len = deformLength(next);
|
|
1975
2113
|
return dropVertexKeys(withAtt, id, (l) => l === len);
|
|
1976
2114
|
}
|
|
1977
|
-
var setWeights = (doc, id, weights) =>
|
|
1978
|
-
|
|
1979
|
-
const
|
|
1980
|
-
|
|
2115
|
+
var setWeights = (doc, id, weights) => {
|
|
2116
|
+
const old = doc.attachments.find((a) => a.id === id);
|
|
2117
|
+
const sameLayout = old?.type === "mesh" && old.weights?.length === weights.length && old.weights.every((list, i) => list.length === weights[i].length && list.every((inf, j2) => inf.bone === weights[i][j2].bone));
|
|
2118
|
+
const result = setMesh(doc, id, { weights });
|
|
2119
|
+
if (sameLayout) return result;
|
|
2120
|
+
const cleared = dropVertexKeys(result.doc, id, () => false);
|
|
2121
|
+
return { doc: cleared.doc, dropped: [...result.dropped, ...cleared.dropped] };
|
|
2122
|
+
};
|
|
2123
|
+
function convertToMesh(doc, mesh2) {
|
|
2124
|
+
const cur = doc.attachments.find((a) => a.id === mesh2.id);
|
|
2125
|
+
if (!cur || cur.type !== "region") throw new Error(`attachment "${mesh2.id}" is not a region`);
|
|
1981
2126
|
const droppedTracks = [];
|
|
1982
2127
|
const animations = doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => {
|
|
1983
|
-
const drop = t.target === "deform" && t.id ===
|
|
2128
|
+
const drop = t.target === "deform" && t.id === mesh2.id;
|
|
1984
2129
|
if (drop) droppedTracks.push(`${trackId2(t)} in ${a.id}`);
|
|
1985
2130
|
return !drop;
|
|
1986
2131
|
}) }));
|
|
1987
|
-
return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id ===
|
|
2132
|
+
return { doc: { ...doc, attachments: doc.attachments.map((a) => a.id === mesh2.id ? Attachment.parse(mesh2) : a), animations }, droppedTracks };
|
|
1988
2133
|
}
|
|
1989
2134
|
function removeAttachment(doc, id) {
|
|
1990
2135
|
return {
|
|
1991
2136
|
...doc,
|
|
1992
2137
|
attachments: doc.attachments.filter((a) => a.id !== id),
|
|
1993
|
-
slots: doc.slots.map((s2) => s2.attachment === id ?
|
|
2138
|
+
slots: doc.slots.map((s2) => ({ ...s2, attachment: s2.attachment === id ? void 0 : s2.attachment, clip: s2.clip === id ? void 0 : s2.clip })),
|
|
1994
2139
|
animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "deform" && t.id === id)).map((t) => t.target === "slot" && t.prop === "attachment" ? { ...t, keys: t.keys.map((k) => k.v === id ? { ...k, v: null } : k) } : t) }))
|
|
1995
2140
|
};
|
|
1996
2141
|
}
|
|
1997
|
-
function placementFromPivot(pivot, offset, w, h) {
|
|
1998
|
-
const o = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, ...offset };
|
|
1999
|
-
const [x, y] = apply(affineOf(o.x, o.y, o.rotation, o.scaleX, o.scaleY), (0.5 - pivot.x) * w, (0.5 - pivot.y) * h);
|
|
2000
|
-
return { x: +x.toFixed(4) + 0, y: +y.toFixed(4) + 0, rotation: o.rotation, scaleX: o.scaleX, scaleY: o.scaleY };
|
|
2001
|
-
}
|
|
2002
2142
|
var CONSTRAINT_SCHEMA = { ik: IkConstraint, transform: TransformConstraint, path: PathConstraint };
|
|
2003
2143
|
var article = (kind) => kind === "ik" ? "an" : "a";
|
|
2004
2144
|
var constraintKind = (doc, id) => doc.ik.some((c) => c.id === id) ? "ik" : doc.transform.some((c) => c.id === id) ? "transform" : doc.path.some((c) => c.id === id) ? "path" : void 0;
|
|
@@ -2010,8 +2150,10 @@ function setConstraint(doc, kind, id, patch) {
|
|
|
2010
2150
|
const given = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0));
|
|
2011
2151
|
if (!cur && !(given.bones && given.target)) throw new Error(`no ${kind} constraint "${id}" yet: creating one needs at least bones and target`);
|
|
2012
2152
|
const next = CONSTRAINT_SCHEMA[kind].parse({ ...cur, ...given, id });
|
|
2013
|
-
if (kind === "ik" && next.bones.length > 2) throw new Error(`ik "${id}": 1 or 2 bones, got ${next.bones.length} (${next.bones.join(",")})`);
|
|
2014
2153
|
const list = cur ? doc[kind].map((c) => c.id === id ? next : c) : [...doc[kind], next];
|
|
2154
|
+
if (kind === "ik") {
|
|
2155
|
+
for (let i = 1; i < next.bones.length; i++) if (doc.bones.find((b) => b.id === next.bones[i])?.parent !== next.bones[i - 1]) throw new Error(`ik "${id}": bones must form a contiguous parent-to-child chain`);
|
|
2156
|
+
}
|
|
2015
2157
|
return { ...doc, [kind]: list };
|
|
2016
2158
|
}
|
|
2017
2159
|
function removeConstraint(doc, id) {
|
|
@@ -2026,8 +2168,10 @@ function removeConstraint(doc, id) {
|
|
|
2026
2168
|
function addIkChain(doc, bone, opts = {}) {
|
|
2027
2169
|
const id = `${bone}_ik`;
|
|
2028
2170
|
if (constraintKind(doc, id)) throw new Error(`constraint "${id}" exists; remove it first (rig_remove_constraint) or patch it`);
|
|
2029
|
-
const full = ikChainFor(doc, bone);
|
|
2171
|
+
const full = opts.bones ?? ikChainFor(doc, bone);
|
|
2172
|
+
if (!full.length || full.at(-1) !== bone) throw new Error("explicit chain must end at the named tip bone");
|
|
2030
2173
|
const rootId = doc.bones.find((b) => !b.parent).id;
|
|
2174
|
+
if (opts.bones && full.includes(rootId)) throw new Error("explicit chain cannot include the root carrying its target");
|
|
2031
2175
|
const note = full.includes(rootId) ? "single bone \u2014 the two-bone chain would include the root, which carries the target" : void 0;
|
|
2032
2176
|
const chain = note ? [bone] : full;
|
|
2033
2177
|
if (chain.length === 1 && !doc.bones.find((b) => b.id === bone).length)
|
|
@@ -2141,10 +2285,11 @@ function pathAnchorOp(doc, id, op, index, t = 0.5) {
|
|
|
2141
2285
|
function setKeys(doc, animId, track, keys, duration, opts = {}) {
|
|
2142
2286
|
const anim = doc.animations.find((a) => a.id === animId) ?? { id: animId, duration: duration ?? 1, loop: false, tracks: [], events: [] };
|
|
2143
2287
|
const same = (t) => t.target === track.target && t.id === track.id && t.prop === track.prop;
|
|
2288
|
+
const old = anim.tracks.find(same);
|
|
2144
2289
|
const given = keys.map((k) => ({ ...k, ease: k.ease ?? "ease_in_out" }));
|
|
2145
2290
|
const kept = opts.merge ? (anim.tracks.find(same)?.keys ?? []).filter((k) => !given.some((g) => Math.abs(g.t - k.t) < 1e-9)) : [];
|
|
2146
2291
|
const sorted = [...kept, ...given].sort((a, b) => a.t - b.t);
|
|
2147
|
-
const edited = { ...track, keys: sorted };
|
|
2292
|
+
const edited = { ...old, ...track, interpolation: track.interpolation ?? old?.interpolation, keys: sorted };
|
|
2148
2293
|
const tracks = anim.tracks.some(same) ? anim.tracks.map((t) => same(t) ? edited : t) : [...anim.tracks, edited];
|
|
2149
2294
|
const dur = duration ?? Math.max(anim.duration, ...sorted.map((k) => k.t));
|
|
2150
2295
|
const next = { ...anim, tracks, duration: dur, preset: void 0 };
|
|
@@ -2227,7 +2372,7 @@ function copyTracks(doc, animId, from, to, opts) {
|
|
|
2227
2372
|
const shifted = round6(k.t + shift);
|
|
2228
2373
|
return { t: shifted >= anim.duration ? shifted - anim.duration : shifted, v: mirror ? -k.v : k.v, ease: k.ease };
|
|
2229
2374
|
});
|
|
2230
|
-
next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop }, keys);
|
|
2375
|
+
next = setKeys(next, animId, { target: "bone", id: dstId, prop: t.prop, interpolation: t.interpolation }, keys);
|
|
2231
2376
|
copied++;
|
|
2232
2377
|
}
|
|
2233
2378
|
if (!any) empty.push(srcId);
|
|
@@ -2287,7 +2432,7 @@ function rename2(doc, kind, id, to) {
|
|
|
2287
2432
|
return {
|
|
2288
2433
|
...doc,
|
|
2289
2434
|
attachments: doc.attachments.map((a) => ({ ...a, id: sub(a.id) })),
|
|
2290
|
-
slots: doc.slots.map((s2) =>
|
|
2435
|
+
slots: doc.slots.map((s2) => ({ ...s2, attachment: s2.attachment ? sub(s2.attachment) : void 0, clip: s2.clip ? sub(s2.clip) : void 0 }))
|
|
2291
2436
|
};
|
|
2292
2437
|
}
|
|
2293
2438
|
case "asset": {
|
|
@@ -2317,10 +2462,10 @@ function describeAttachment(doc, att) {
|
|
|
2317
2462
|
}
|
|
2318
2463
|
const n = vertexCount(att);
|
|
2319
2464
|
const bones = att.weights ? [...new Set(att.weights.flat().map((i) => i.bone))] : [];
|
|
2320
|
-
const
|
|
2465
|
+
const skin2 = att.weights ? `skinned to ${bones.length} bone${bones.length === 1 ? "" : "s"} (${bones.join(", ")}), ${att.weights.reduce((t, l) => t + l.length, 0)} influences` : `unskinned (\`vertices\` in slot bone "${doc.slots.find((s2) => s2.id === att.slot)?.bone ?? "?"}"'s space)`;
|
|
2321
2466
|
const shape = att.type === "mesh" ? `${n} vertices, ${att.triangles.length / 3} triangles, hull ${att.hull}` : `${n} control points = ${anchorCount(n)} anchors, ${att.closed ? "closed" : "open"}, ${att.constantSpeed ? "constantSpeed" : "stored lengths"}`;
|
|
2322
2467
|
const keyed = doc.animations.filter((a) => a.tracks.some((t) => t.target === "deform" && t.id === att.id && t.prop === "vertices")).map((a) => a.id);
|
|
2323
|
-
return `${head}: ${shape}; ${
|
|
2468
|
+
return `${head}: ${shape}; ${skin2}; deform key length ${deformLength(att)}${keyed.length ? `; vertices keyed in ${keyed.length} animation${keyed.length === 1 ? "" : "s"} (${keyed.join(", ")})` : ""}`;
|
|
2324
2469
|
}
|
|
2325
2470
|
function describe(doc, opts = {}) {
|
|
2326
2471
|
const lines = [`${doc.meta.name} ${doc.meta.width}x${doc.meta.height}`];
|
|
@@ -2481,9 +2626,11 @@ async function importLayers(doc, dir, relPrefix = "layers") {
|
|
|
2481
2626
|
let specs;
|
|
2482
2627
|
try {
|
|
2483
2628
|
specs = JSON.parse(await readFile(join2(dir, "layers.json"), "utf8"));
|
|
2484
|
-
} catch {
|
|
2629
|
+
} catch (e) {
|
|
2630
|
+
if (e.code !== "ENOENT") throw e;
|
|
2485
2631
|
specs = (await readdir(dir)).filter((f) => f.endsWith(".png")).sort().map((file) => ({ file }));
|
|
2486
2632
|
}
|
|
2633
|
+
if (!Array.isArray(specs)) throw new Error("layers.json must be an array");
|
|
2487
2634
|
let next = doc;
|
|
2488
2635
|
const parsed = [];
|
|
2489
2636
|
for (const s2 of specs) {
|
|
@@ -2491,21 +2638,32 @@ async function importLayers(doc, dir, relPrefix = "layers") {
|
|
|
2491
2638
|
const [w, h] = pngSize(buf);
|
|
2492
2639
|
const m = basename2(s2.file, ".png").match(/^(?:(\d+)_)?(.+)$/);
|
|
2493
2640
|
const id = s2.id ?? m[2].toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
2494
|
-
const
|
|
2641
|
+
const z4 = s2.z ?? (m[1] ? +m[1] : parsed.length);
|
|
2495
2642
|
const role = s2.role ?? (DEFAULT_PARENT[id] || id === "root" ? id : void 0);
|
|
2496
2643
|
const bbox = s2.bbox ?? [0, 0, w, h];
|
|
2497
|
-
|
|
2644
|
+
if (bbox.length !== 4 || bbox.some((v) => !Number.isFinite(v)) || bbox[2] <= 0 || bbox[3] <= 0)
|
|
2645
|
+
throw new Error(`layer "${id}": bbox must have positive width and height`);
|
|
2646
|
+
parsed.push({ ...s2, id, z: z4, role, w, h, bbox });
|
|
2498
2647
|
}
|
|
2499
2648
|
const ids = new Set(parsed.map((p) => p.id));
|
|
2500
2649
|
const rootId = next.bones.find((b) => !b.parent).id;
|
|
2501
2650
|
const exists3 = (id) => ids.has(id) || next.bones.some((b) => b.id === id);
|
|
2502
2651
|
const resolveParent = (p) => {
|
|
2503
|
-
if (p.parent
|
|
2652
|
+
if (p.parent) {
|
|
2653
|
+
if (!exists3(p.parent)) throw new Error(`layer "${p.id}": unknown parent "${p.parent}"`);
|
|
2654
|
+
return p.parent;
|
|
2655
|
+
}
|
|
2504
2656
|
let cur = p.role && DEFAULT_PARENT[p.role];
|
|
2505
2657
|
while (cur && !exists3(cur)) cur = DEFAULT_PARENT[cur];
|
|
2506
2658
|
return cur ?? rootId;
|
|
2507
2659
|
};
|
|
2508
|
-
const dependency = (p) =>
|
|
2660
|
+
const dependency = (p) => {
|
|
2661
|
+
if (p.bone) {
|
|
2662
|
+
if (!exists3(p.bone)) throw new Error(`layer "${p.id}": unknown bone "${p.bone}"`);
|
|
2663
|
+
return p.bone;
|
|
2664
|
+
}
|
|
2665
|
+
return resolveParent(p);
|
|
2666
|
+
};
|
|
2509
2667
|
const pending3 = [...parsed];
|
|
2510
2668
|
while (pending3.length) {
|
|
2511
2669
|
const i = pending3.findIndex((p2) => next.bones.some((b) => b.id === dependency(p2)));
|
|
@@ -2514,36 +2672,45 @@ async function importLayers(doc, dir, relPrefix = "layers") {
|
|
|
2514
2672
|
if (!next.bones.some((b) => b.id === target)) throw new Error(`layer "${p.id}": bone "${target}" is neither in the rig nor in the import`);
|
|
2515
2673
|
const [bx, by, bw, bh] = p.bbox;
|
|
2516
2674
|
const joint = p.joint ?? [bx + bw / 2, by + bh / 2];
|
|
2517
|
-
const host =
|
|
2675
|
+
const host = evaluate(next, /* @__PURE__ */ new Map()).bones.get(target).world;
|
|
2676
|
+
if (Math.abs(host.a * host.d - host.b * host.c) < 1e-10) throw new Error(`layer "${p.id}": singular host transform`);
|
|
2518
2677
|
next = { ...next, assets: [...next.assets, RigSchema.shape.assets.element.parse({
|
|
2519
2678
|
id: p.id,
|
|
2520
2679
|
src: `${relPrefix}/${p.file}`,
|
|
2521
2680
|
width: p.w,
|
|
2522
2681
|
height: p.h,
|
|
2523
|
-
origin: { sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
|
|
2682
|
+
origin: { bboxSpace: "document", sourceImage: p.sourceImage, bbox: s_bbox(p), inpainted: p.inpainted ?? false, prompt: p.prompt }
|
|
2524
2683
|
})] };
|
|
2525
|
-
const
|
|
2684
|
+
const place = (slotId) => {
|
|
2685
|
+
const slot = next.slots.find((s2) => s2.id === slotId);
|
|
2686
|
+
const inv = invert(evaluate(next, /* @__PURE__ */ new Map()).bones.get(slot.bone).world);
|
|
2687
|
+
const [x, y] = apply(inv, bx + bw / 2, by + bh / 2);
|
|
2688
|
+
const m = { a: inv.a * bw / p.w, b: inv.b * bw / p.w, c: inv.c * bh / p.h, d: inv.d * bh / p.h, tx: x, ty: y };
|
|
2689
|
+
const sx = Math.hypot(m.a, m.b), sy = Math.hypot(m.c, m.d);
|
|
2690
|
+
if (Math.abs(m.a * m.c + m.b * m.d) > 1e-8 * sx * sy) {
|
|
2691
|
+
const vertices = [[bx, by], [bx + bw, by], [bx + bw, by + bh], [bx, by + bh]].flatMap(([x2, y2]) => apply(inv, x2, y2));
|
|
2692
|
+
const att = Attachment.parse({ id: p.id, slot: slotId, type: "mesh", asset: p.id, vertices, uvs: [0, 0, 1, 0, 1, 1, 0, 1], triangles: [0, 1, 2, 0, 2, 3], hull: 4 });
|
|
2693
|
+
next = { ...next, attachments: [...next.attachments.filter((a) => a.id !== p.id), att] };
|
|
2694
|
+
} else next = setAttachment(next, { id: p.id, slot: slotId, asset: p.id, x: +x.toFixed(12), y: +y.toFixed(12), rotation: Math.atan2(m.b, m.a) * 180 / Math.PI, scaleX: sx, scaleY: (m.a * m.d - m.b * m.c < 0 ? -1 : 1) * sy });
|
|
2695
|
+
};
|
|
2526
2696
|
if (p.swap || p.bone) {
|
|
2527
2697
|
const slotId = p.slot ?? p.id;
|
|
2528
2698
|
if (!next.slots.some((s2) => s2.id === slotId)) next = setSlot(next, { id: slotId, bone: target, z: p.z, blend: p.blend });
|
|
2529
|
-
|
|
2699
|
+
place(slotId);
|
|
2530
2700
|
if (!p.swap) next = setSlot(next, { id: slotId, attachment: p.id });
|
|
2531
2701
|
continue;
|
|
2532
2702
|
}
|
|
2533
|
-
if (!next.bones.some((b) => b.id === p.id))
|
|
2534
|
-
|
|
2703
|
+
if (!next.bones.some((b) => b.id === p.id)) {
|
|
2704
|
+
const [x, y] = apply(invert(host), ...joint);
|
|
2705
|
+
next = addBone(next, { id: p.id, parent: target, role: p.role, transform: { x, y } });
|
|
2706
|
+
}
|
|
2535
2707
|
next = setSlot(next, { id: p.id, bone: p.id, z: p.z, blend: p.blend });
|
|
2536
|
-
|
|
2708
|
+
place(p.id);
|
|
2537
2709
|
next = setSlot(next, { id: p.id, attachment: p.id });
|
|
2538
2710
|
}
|
|
2539
2711
|
return next;
|
|
2540
2712
|
}
|
|
2541
2713
|
var s_bbox = (p) => p.bbox;
|
|
2542
|
-
function worldOf(doc, id) {
|
|
2543
|
-
const b = doc.bones.find((b2) => b2.id === id);
|
|
2544
|
-
const p = b.parent ? worldOf(doc, b.parent) : { x: 0, y: 0 };
|
|
2545
|
-
return { x: p.x + b.transform.x, y: p.y + b.transform.y };
|
|
2546
|
-
}
|
|
2547
2714
|
|
|
2548
2715
|
// src/rig-presets.ts
|
|
2549
2716
|
var root = (doc) => doc.bones.find((b) => !b.parent);
|
|
@@ -2751,18 +2918,57 @@ function applyPreset(doc, name, params = {}) {
|
|
|
2751
2918
|
return { ...doc, animations };
|
|
2752
2919
|
}
|
|
2753
2920
|
|
|
2921
|
+
// src/preview/export-video.ts
|
|
2922
|
+
import { spawn } from "node:child_process";
|
|
2923
|
+
import { once } from "node:events";
|
|
2924
|
+
import { mkdtemp, writeFile as writeFile2, rm as rm2, rename as rename3, mkdir as mkdir2 } from "node:fs/promises";
|
|
2925
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
2926
|
+
|
|
2754
2927
|
// src/preview/render-preview.ts
|
|
2755
2928
|
import { chromium } from "playwright";
|
|
2756
|
-
import { readFile as
|
|
2757
|
-
import { join as
|
|
2929
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
2930
|
+
import { join as join4, dirname as dirname3 } from "node:path";
|
|
2758
2931
|
import { fileURLToPath } from "node:url";
|
|
2759
|
-
|
|
2932
|
+
|
|
2933
|
+
// src/rig-qa.ts
|
|
2934
|
+
import { createHash } from "node:crypto";
|
|
2935
|
+
import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises";
|
|
2936
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
2937
|
+
var sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
2938
|
+
async function verifyRenderManifest(rigPath, framesDir, expected) {
|
|
2939
|
+
const m = JSON.parse(await readFile2(join3(framesDir, "manifest.json"), "utf8"));
|
|
2940
|
+
if (m.version !== 1 || !m.frames?.length || !m.times?.length) throw new Error("missing or empty capture manifest");
|
|
2941
|
+
const raw = await readFile2(rigPath, "utf8");
|
|
2942
|
+
if (sha256(raw) !== m.rigFile) throw new Error("stale render: rig file changed");
|
|
2943
|
+
const doc = parse(JSON.parse(raw));
|
|
2944
|
+
if (sha256(JSON.stringify(doc)) !== m.identity.document) throw new Error("stale render: rig changed");
|
|
2945
|
+
if (sha256(await readFile2(expected.renderer)) !== m.identity.renderer) throw new Error("stale render: renderer changed");
|
|
2946
|
+
for (const a of doc.assets) if (sha256(await readFile2(join3(dirname2(rigPath), a.src))) !== m.identity.assets[a.src]) throw new Error(`stale render: texture ${a.src} changed`);
|
|
2947
|
+
for (const [path, hash] of Object.entries(m.identity.masks)) if (sha256(await readFile2(join3(dirname2(rigPath), path))) !== hash) throw new Error(`stale render: fill mask ${path} changed`);
|
|
2948
|
+
if (m.animation !== (expected.animation ?? null) || JSON.stringify(m.times) !== JSON.stringify(expected.times)) throw new Error("capture does not cover the requested animation/times");
|
|
2949
|
+
if (expected.transparent !== void 0 && m.transparent !== expected.transparent) throw new Error("capture has the wrong alpha mode");
|
|
2950
|
+
if (m.frames.length !== expected.times.length || new Set(m.frames.map((f) => f.file)).size !== m.frames.length) throw new Error("capture has missing or duplicate frames");
|
|
2951
|
+
const files2 = (await readdir2(framesDir)).filter((f) => f.endsWith(".png")).sort();
|
|
2952
|
+
if (JSON.stringify(files2) !== JSON.stringify(m.frames.map((f) => f.file).sort())) throw new Error("capture directory has missing or unmanifested frames");
|
|
2953
|
+
for (const f of m.frames) {
|
|
2954
|
+
if (!/^\d{6}\.png$/.test(f.file)) throw new Error("invalid capture filename");
|
|
2955
|
+
if (sha256(await readFile2(join3(framesDir, f.file))) !== f.sha256) throw new Error(`frame ${f.file} changed since capture`);
|
|
2956
|
+
}
|
|
2957
|
+
return m;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// src/preview/render-preview.ts
|
|
2961
|
+
import { PNG } from "pngjs";
|
|
2962
|
+
var HERE = dirname3(fileURLToPath(import.meta.url));
|
|
2760
2963
|
var browser;
|
|
2761
2964
|
var page;
|
|
2762
2965
|
var harnessJs;
|
|
2763
2966
|
var pending2 = Promise.resolve();
|
|
2764
2967
|
async function getPage() {
|
|
2765
|
-
|
|
2968
|
+
const latestHarness = await readFile3(join4(HERE, "harness.js"), "utf8");
|
|
2969
|
+
if (page && !page.isClosed() && latestHarness === harnessJs) return page;
|
|
2970
|
+
if (page && !page.isClosed()) await page.close();
|
|
2971
|
+
harnessJs = latestHarness;
|
|
2766
2972
|
if (browser && !browser.isConnected()) browser = void 0;
|
|
2767
2973
|
browser ??= await chromium.launch({
|
|
2768
2974
|
headless: true,
|
|
@@ -2772,7 +2978,7 @@ async function getPage() {
|
|
|
2772
2978
|
try {
|
|
2773
2979
|
next.on("pageerror", (e) => console.error("[harness]", e.message));
|
|
2774
2980
|
await next.setContent(`<!doctype html><html><body style="margin:0;background:transparent"></body></html>`);
|
|
2775
|
-
harnessJs ??= await
|
|
2981
|
+
harnessJs ??= await readFile3(join4(HERE, "harness.js"), "utf8");
|
|
2776
2982
|
await next.addScriptTag({ content: harnessJs });
|
|
2777
2983
|
page = next;
|
|
2778
2984
|
return next;
|
|
@@ -2810,6 +3016,8 @@ async function render(doc, opts) {
|
|
|
2810
3016
|
if (opts.count !== void 0) positive(opts.count, "count", true);
|
|
2811
3017
|
const anim = opts.animation === void 0 ? void 0 : doc.animations.find((a) => a.id === opts.animation);
|
|
2812
3018
|
if (opts.animation !== void 0 && !anim) throw new Error(`unknown animation "${opts.animation}"`);
|
|
3019
|
+
if (opts.weightBone && !doc.bones.some((b) => b.id === opts.weightBone)) throw new Error(`unknown weight bone "${opts.weightBone}"`);
|
|
3020
|
+
for (const src of Object.keys(opts.debugFillMasks ?? {})) if (!doc.assets.some((a) => a.src === src)) throw new Error(`fill mask refers to unknown texture "${src}"`);
|
|
2813
3021
|
if (opts.times && (!opts.times.length || opts.times.some((t) => !Number.isFinite(t) || t < 0)))
|
|
2814
3022
|
throw new Error("times must contain finite, nonnegative sample times");
|
|
2815
3023
|
if (opts.region && (opts.region.length !== 4 || opts.region.some((v) => !Number.isFinite(v)) || opts.region[2] <= 0 || opts.region[3] <= 0))
|
|
@@ -2826,12 +3034,27 @@ async function render(doc, opts) {
|
|
|
2826
3034
|
}
|
|
2827
3035
|
}
|
|
2828
3036
|
const assets = {};
|
|
3037
|
+
const identity = { document: sha256(JSON.stringify(doc)), assets: {}, renderer: "", masks: {} };
|
|
2829
3038
|
for (const a of doc.assets) {
|
|
2830
3039
|
if (assets[a.src]) continue;
|
|
2831
|
-
|
|
3040
|
+
let buf = await readFile3(join4(opts.assetsDir, a.src));
|
|
3041
|
+
identity.assets[a.src] = sha256(buf);
|
|
3042
|
+
if (opts.debugFillMasks?.[a.src]) {
|
|
3043
|
+
const maskData = await readFile3(join4(opts.assetsDir, opts.debugFillMasks[a.src]));
|
|
3044
|
+
identity.masks[opts.debugFillMasks[a.src]] = sha256(maskData);
|
|
3045
|
+
const png = PNG.sync.read(buf), mask = PNG.sync.read(maskData);
|
|
3046
|
+
if (png.width !== mask.width || png.height !== mask.height) throw new Error(`fill mask dimensions differ for ${a.src}`);
|
|
3047
|
+
for (let i = 0; i < png.data.length; i += 4) if (mask.data[i] > 127 && mask.data[i + 3] > 0) {
|
|
3048
|
+
png.data[i] = 255;
|
|
3049
|
+
png.data[i + 1] = 0;
|
|
3050
|
+
png.data[i + 2] = 255;
|
|
3051
|
+
}
|
|
3052
|
+
buf = PNG.sync.write(png);
|
|
3053
|
+
}
|
|
2832
3054
|
assets[a.src] = `data:${mime(a.src)};base64,${buf.toString("base64")}`;
|
|
2833
3055
|
}
|
|
2834
3056
|
const p = await getPage();
|
|
3057
|
+
identity.renderer = sha256(harnessJs);
|
|
2835
3058
|
const res = await p.evaluate(
|
|
2836
3059
|
async ({ doc: doc2, assets: assets2, scale, ro }) => {
|
|
2837
3060
|
const h = window.rigHarness;
|
|
@@ -2842,10 +3065,11 @@ async function render(doc, opts) {
|
|
|
2842
3065
|
doc,
|
|
2843
3066
|
assets,
|
|
2844
3067
|
scale: opts.scale ?? 1,
|
|
2845
|
-
ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
|
|
3068
|
+
ro: { animation: opts.animation, times: times2, overlayBones: opts.overlayBones, background: opts.background, transparent: opts.transparent, weightBone: opts.weightBone, sheetColumns: opts.sheetColumns, onionSkin: opts.onionSkin, region }
|
|
2846
3069
|
}
|
|
2847
3070
|
);
|
|
2848
3071
|
return {
|
|
3072
|
+
identity,
|
|
2849
3073
|
frames: res.frames.map(fromDataUrl),
|
|
2850
3074
|
sheet: fromDataUrl(res.sheet),
|
|
2851
3075
|
times: times2,
|
|
@@ -2854,6 +3078,146 @@ async function render(doc, opts) {
|
|
|
2854
3078
|
};
|
|
2855
3079
|
}
|
|
2856
3080
|
|
|
3081
|
+
// src/preview/export-video.ts
|
|
3082
|
+
async function exportVideo(doc, opts) {
|
|
3083
|
+
const anim = doc.animations.find((a) => a.id === opts.animation), fps = opts.fps ?? 60;
|
|
3084
|
+
if (!anim) throw new Error(`unknown animation "${opts.animation}"`);
|
|
3085
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 240) throw new Error("video fps must be an integer between 1 and 240");
|
|
3086
|
+
const count = Math.max(1, Math.round(anim.duration * fps));
|
|
3087
|
+
if (count > 1e4) throw new Error("video exceeds 10000 frames; export shorter clips");
|
|
3088
|
+
await mkdir2(dirname4(opts.outFile), { recursive: true });
|
|
3089
|
+
const temp = await mkdtemp(join5(dirname4(opts.outFile), ".golem-video-"));
|
|
3090
|
+
try {
|
|
3091
|
+
for (let start = 0; start < count; start += 24) {
|
|
3092
|
+
const times2 = Array.from({ length: Math.min(24, count - start) }, (_, i) => (start + i) / fps);
|
|
3093
|
+
const rendered = await renderPreview(doc, { ...opts, transparent: false, times: times2 });
|
|
3094
|
+
await Promise.all(rendered.frames.map((frame, i) => writeFile2(join5(temp, `${String(start + i).padStart(6, "0")}.png`), frame)));
|
|
3095
|
+
}
|
|
3096
|
+
const output = join5(temp, "preview.mp4");
|
|
3097
|
+
const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-framerate", String(fps), "-i", join5(temp, "%06d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", output], { stdio: ["ignore", "ignore", "pipe"] });
|
|
3098
|
+
let diagnostic = "";
|
|
3099
|
+
proc.stderr.on("data", (chunk) => {
|
|
3100
|
+
diagnostic = (diagnostic + chunk).slice(-8e3);
|
|
3101
|
+
});
|
|
3102
|
+
const [code] = await once(proc, "close");
|
|
3103
|
+
if (code !== 0) throw new Error(`MP4 encoder failed: ${diagnostic}`);
|
|
3104
|
+
await rename3(output, opts.outFile);
|
|
3105
|
+
return { frames: count, fps, duration: count / fps };
|
|
3106
|
+
} finally {
|
|
3107
|
+
await rm2(temp, { recursive: true, force: true });
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// src/rig-quality.ts
|
|
3112
|
+
var values = (v) => typeof v === "number" ? [v] : Array.isArray(v) && v.every((n) => typeof n === "number") ? v : void 0;
|
|
3113
|
+
var delta = (a, b) => a.map((v, i) => v - b[i]);
|
|
3114
|
+
var magnitude = (a) => Math.max(0, ...a.map(Math.abs));
|
|
3115
|
+
function motionQuality(doc, anim, opts = {}) {
|
|
3116
|
+
const out = [], D = anim.duration;
|
|
3117
|
+
const report = (code, subject, message, time, level = "warn") => out.push({ level, code, subject, animation: anim.id, time, message });
|
|
3118
|
+
for (const tr of anim.tracks) {
|
|
3119
|
+
const id = `${tr.target}.${tr.id}.${tr.prop}`, looping = anim.loop && tr.keys[0].t === 0 && tr.keys.at(-1).t === D;
|
|
3120
|
+
const at = (t) => sample(tr, t, looping), numeric = tr.keys.every((k) => values(k.v) !== void 0);
|
|
3121
|
+
if (anim.loop) {
|
|
3122
|
+
const first = at(0), last = at(D), av = values(first), bv = values(last);
|
|
3123
|
+
const diff = av && bv && av.length === bv.length ? magnitude(delta(av, bv)) : first === last || JSON.stringify(first) === JSON.stringify(last) ? 0 : Infinity;
|
|
3124
|
+
if (diff > 1e-3 && typeof first !== "number") report("loop_seam", id, `loop endpoint values differ (${Number.isFinite(diff) ? `max delta ${diff.toFixed(4)}` : "discrete state"}); all channels must close`);
|
|
3125
|
+
if (numeric && diff <= 1e-3 && tr.keys.length > 1) {
|
|
3126
|
+
const h = Math.min(D / 1e4, (tr.keys[1].t - tr.keys[0].t) / 100, (tr.keys.at(-1).t - tr.keys.at(-2).t) / 100);
|
|
3127
|
+
const va = delta(values(at(h)), values(at(0))).map((v) => v / h), vb = delta(values(at(D)), values(at(D - h))).map((v) => v / h);
|
|
3128
|
+
const dv = magnitude(delta(va, vb));
|
|
3129
|
+
if (dv > Math.max(0.05, 0.05 * Math.max(magnitude(va), magnitude(vb)))) report("loop_velocity", id, `loop velocity changes by ${dv.toFixed(3)} units/s at the wrap`);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
if (!numeric) continue;
|
|
3133
|
+
for (let i = 1; i < tr.keys.length - 1; i++) {
|
|
3134
|
+
const p = tr.keys[i - 1], k = tr.keys[i], n = tr.keys[i + 1];
|
|
3135
|
+
if (p.ease === "step" || k.ease === "step") continue;
|
|
3136
|
+
const h = Math.min(k.t - p.t, n.t - k.t) / 1e3;
|
|
3137
|
+
const left = delta(values(at(k.t)), values(at(k.t - h))).map((v) => v / h), right = delta(values(at(k.t + h)), values(at(k.t))).map((v) => v / h);
|
|
3138
|
+
const before = delta(values(k.v), values(p.v)), after = delta(values(n.v), values(k.v));
|
|
3139
|
+
const pass = before.some((v, j2) => v * after[j2] > 1e-6);
|
|
3140
|
+
const speed = Math.max(magnitude(before) / (k.t - p.t), magnitude(after) / (n.t - k.t));
|
|
3141
|
+
if (pass && Math.max(magnitude(left), magnitude(right)) < speed * 0.02) report("pass_key_stop", id, `motion stops at a pass-through key; use track interpolation pchip or matched tangents`, k.t);
|
|
3142
|
+
const jump = magnitude(delta(left, right));
|
|
3143
|
+
if (jump > Math.max(0.1, speed * 0.1)) report("velocity_jump", id, `velocity jumps ${jump.toFixed(3)} units/s across this key`, k.t);
|
|
3144
|
+
const acceleration = (a, b, c) => values(at(b)).map((v, j2) => (values(at(c))[j2] - 2 * v + values(at(a))[j2]) / (h * h));
|
|
3145
|
+
const al = acceleration(k.t - 2 * h, k.t - h, k.t), ar = acceleration(k.t, k.t + h, k.t + 2 * h);
|
|
3146
|
+
const aj = magnitude(delta(al, ar));
|
|
3147
|
+
if (aj > Math.max(1, speed / Math.min(k.t - p.t, n.t - k.t) * 2)) report("acceleration_jump", id, `acceleration changes by ${aj.toFixed(2)} units/s\xB2 at this key; PCHIP guarantees velocity, not acceleration continuity`, k.t, "info");
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
const steps = Math.max(2, Math.ceil(D * 60));
|
|
3151
|
+
const times2 = new Set(Array.from({ length: steps + 1 }, (_, i) => i * D / steps));
|
|
3152
|
+
for (const tr of anim.tracks) for (const k of tr.keys) {
|
|
3153
|
+
times2.add(k.t);
|
|
3154
|
+
if (k.t > 0) times2.add(Math.max(0, k.t - 1e-5));
|
|
3155
|
+
}
|
|
3156
|
+
const samples = [...times2].sort((a, b) => a - b).map((t) => ({ t, pose: poseOf(anim, t) }));
|
|
3157
|
+
for (const bone of opts.secondaryBones ?? []) {
|
|
3158
|
+
if (!doc.bones.some((b) => b.id === bone)) throw new Error(`unknown secondary bone "${bone}"`);
|
|
3159
|
+
const local = samples.map((s2) => evaluate(doc, s2.pose).bones.get(bone));
|
|
3160
|
+
const curves = ["rotation", "x", "y"].map((prop) => local.map((b) => b[prop]));
|
|
3161
|
+
if (curves.every((v) => Math.max(...v) - Math.min(...v) < 1e-3)) report("secondary_frozen", bone, `declared secondary bone has no local motion in this animation`);
|
|
3162
|
+
}
|
|
3163
|
+
const reported = /* @__PURE__ */ new Set();
|
|
3164
|
+
for (const [front, back] of opts.frontOf ?? []) {
|
|
3165
|
+
if (![front, back].every((id) => doc.slots.some((s2) => s2.id === id))) throw new Error(`unknown frontOf slots: ${front}, ${back}`);
|
|
3166
|
+
for (const { t, pose } of samples) {
|
|
3167
|
+
const skel = evaluate(doc, pose);
|
|
3168
|
+
if (!skel.attachment.get(front) || !skel.attachment.get(back)) continue;
|
|
3169
|
+
const visible = (id) => {
|
|
3170
|
+
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;
|
|
3173
|
+
};
|
|
3174
|
+
if (!visible(front) || !visible(back)) continue;
|
|
3175
|
+
const order = pose.get(pk("drawOrder", "order", "order"));
|
|
3176
|
+
const z4 = (id) => order ? order.indexOf(id) : Number(pose.get(pk("slot", id, "z")) ?? doc.slots.find((s2) => s2.id === id).z);
|
|
3177
|
+
const pair = JSON.stringify([front, back]);
|
|
3178
|
+
if (z4(front) <= z4(back) && !reported.has(pair)) {
|
|
3179
|
+
reported.add(pair);
|
|
3180
|
+
report("draw_order_rule", front, `"${front}" must stay explicitly in front of "${back}" according to the brief (equal z is ambiguous)`, t);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
for (const tr of anim.tracks.filter((t) => t.target === "slot" && t.prop === "attachment")) for (const key of tr.keys.filter((k) => k.t > 0)) {
|
|
3185
|
+
const a = evaluate(doc, poseOf(anim, Math.max(0, key.t - 1e-6))), b = evaluate(doc, poseOf(anim, key.t));
|
|
3186
|
+
const before = a.attachment.get(tr.id), after = b.attachment.get(tr.id);
|
|
3187
|
+
if (!before || !after || before.id === after.id) continue;
|
|
3188
|
+
const landmarksA = opts.landmarks?.[before.id], landmarksB = opts.landmarks?.[after.id];
|
|
3189
|
+
if (!landmarksA && !landmarksB) continue;
|
|
3190
|
+
if (!landmarksA || !landmarksB || !Object.keys(landmarksA).some((name) => landmarksB[name])) {
|
|
3191
|
+
report("landmark_unmeasured", tr.id, `swap ${before.id} \u2192 ${after.id} has no shared declared landmarks`, key.t);
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
3194
|
+
const point = (skel, att, uv) => {
|
|
3195
|
+
if (att.type === "region") {
|
|
3196
|
+
const [w, h] = regionSize(doc, att);
|
|
3197
|
+
return apply(regionMatrix(skel, att.slot, att), (uv[0] - 0.5) * w, (uv[1] - 0.5) * h);
|
|
3198
|
+
}
|
|
3199
|
+
if (att.type !== "mesh") return;
|
|
3200
|
+
const v = attachmentWorldVertices(skel, att.slot, att);
|
|
3201
|
+
for (let i = 0; i < att.triangles.length; i += 3) {
|
|
3202
|
+
const [ia, ib, ic] = att.triangles.slice(i, i + 3), ux = att.uvs[ia * 2], uy = att.uvs[ia * 2 + 1], bx = att.uvs[ib * 2] - ux, by = att.uvs[ib * 2 + 1] - uy, cx = att.uvs[ic * 2] - ux, cy = att.uvs[ic * 2 + 1] - uy, det = bx * cy - by * cx;
|
|
3203
|
+
if (Math.abs(det) < 1e-12) continue;
|
|
3204
|
+
const s2 = ((uv[0] - ux) * cy - (uv[1] - uy) * cx) / det, r = (bx * (uv[1] - uy) - by * (uv[0] - ux)) / det;
|
|
3205
|
+
if (s2 >= -1e-6 && r >= -1e-6 && s2 + r <= 1 + 1e-6) return [v[ia * 2] * (1 - s2 - r) + v[ib * 2] * s2 + v[ic * 2] * r, v[ia * 2 + 1] * (1 - s2 - r) + v[ib * 2 + 1] * s2 + v[ic * 2 + 1] * r];
|
|
3206
|
+
}
|
|
3207
|
+
};
|
|
3208
|
+
for (const [name, uv] of Object.entries(landmarksA)) if (landmarksB[name]) {
|
|
3209
|
+
const p = point(a, before, uv), q = point(b, after, landmarksB[name]);
|
|
3210
|
+
if (!p || !q) {
|
|
3211
|
+
report("landmark_unmeasured", tr.id, `landmark "${name}" is outside attachment geometry`, key.t);
|
|
3212
|
+
continue;
|
|
3213
|
+
}
|
|
3214
|
+
const distance = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
|
3215
|
+
if (distance > (opts.landmarkTolerance ?? 2)) report("attachment_landmark_jump", tr.id, `"${name}" moves ${distance.toFixed(2)}px when ${before.id} changes to ${after.id}`, key.t);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return out;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
2857
3221
|
// src/rig-check.ts
|
|
2858
3222
|
var FOLD_ERROR_RATIO = 0.05;
|
|
2859
3223
|
var IK_SLACK = 1;
|
|
@@ -2862,15 +3226,22 @@ var isNumeric = (t) => t.target !== "slot" || t.prop !== "attachment";
|
|
|
2862
3226
|
var trackId3 = (t) => `${t.target}.${t.id}.${t.prop}`;
|
|
2863
3227
|
var fmt = (n) => String(Math.round(n * 1e3) / 1e3);
|
|
2864
3228
|
var times = (a, fps) => {
|
|
2865
|
-
const n = Math.max(1, Math.
|
|
2866
|
-
for (let i = 0; i <= n; i++) out.push(
|
|
3229
|
+
const n = Math.max(1, Math.ceil(a.duration * fps)), out = [];
|
|
3230
|
+
for (let i = 0; i <= n; i++) out.push(a.duration * i / n);
|
|
2867
3231
|
return out;
|
|
2868
3232
|
};
|
|
2869
3233
|
function checkRig(doc, opts = {}) {
|
|
2870
3234
|
const fps = opts.fps ?? 30, dfps = opts.deformFps ?? 60, overlapMin = opts.overlap ?? 0.6, margin = opts.margin ?? 1;
|
|
3235
|
+
if (![fps, dfps].every((v) => Number.isFinite(v) && v > 0)) throw new Error("check sampling rates must be positive");
|
|
2871
3236
|
const anims = opts.animation === void 0 ? doc.animations : doc.animations.filter((a) => a.id === opts.animation);
|
|
2872
3237
|
if (opts.animation !== void 0 && !anims.length) throw new Error(`unknown animation "${opts.animation}"`);
|
|
2873
3238
|
const out = [];
|
|
3239
|
+
for (const e of constraintDependencies(doc).edges.filter((e2) => e2.late)) out.push({
|
|
3240
|
+
level: "warn",
|
|
3241
|
+
code: "constraint_order",
|
|
3242
|
+
subject: e.reader,
|
|
3243
|
+
message: `constraint "${e.reader}" reads bone "${e.bone}" before later constraint "${e.writer}" moves it; use rig_order_constraints or set explicit order`
|
|
3244
|
+
});
|
|
2874
3245
|
const W = doc.meta.width, H = doc.meta.height;
|
|
2875
3246
|
const overflowOf = (pose, slotId, skel) => {
|
|
2876
3247
|
const pts = slotOutline(doc, pose, slotId, 4, skel);
|
|
@@ -3042,6 +3413,7 @@ function checkRig(doc, opts = {}) {
|
|
|
3042
3413
|
return rest;
|
|
3043
3414
|
};
|
|
3044
3415
|
for (const a of anims) {
|
|
3416
|
+
out.push(...motionQuality(doc, a, opts));
|
|
3045
3417
|
for (const t of a.tracks) {
|
|
3046
3418
|
if (!isNumeric(t)) continue;
|
|
3047
3419
|
const vals = t.keys.map((k) => k.v);
|
|
@@ -3149,7 +3521,9 @@ function checkRig(doc, opts = {}) {
|
|
|
3149
3521
|
if (!sorted.length) continue;
|
|
3150
3522
|
const typical = sorted[Math.floor(sorted.length / 2)];
|
|
3151
3523
|
const worst2 = ds.reduce((m, x) => x.d > m.d ? x : m, ds[0]);
|
|
3152
|
-
|
|
3524
|
+
const index = ds.indexOf(worst2);
|
|
3525
|
+
const neighbours = Math.max(ds[index - 1]?.d ?? 0, ds[index + 1]?.d ?? 0);
|
|
3526
|
+
if (worst2.d > Math.max(6, 5 * typical, 3 * neighbours)) {
|
|
3153
3527
|
const stepped = stepTimes.some((st) => st > worst2.t - 1 / dfps - 1e-9 && st <= worst2.t + 1e-9);
|
|
3154
3528
|
out.push({
|
|
3155
3529
|
level: stepped ? "info" : "warn",
|
|
@@ -3200,7 +3574,7 @@ function checkRig(doc, opts = {}) {
|
|
|
3200
3574
|
for (const att of doc.attachments) {
|
|
3201
3575
|
if (att.type !== "region") continue;
|
|
3202
3576
|
const asset = doc.assets.find((a) => a.id === att.asset), bbox = asset?.origin?.bbox;
|
|
3203
|
-
if (!asset || !bbox || !doc.slots.some((s2) => s2.id === att.slot)) continue;
|
|
3577
|
+
if (!asset || !bbox || asset.origin?.bboxSpace === "source" || !doc.slots.some((s2) => s2.id === att.slot)) continue;
|
|
3204
3578
|
const [cx, cy] = apply(regionMatrix(restWorld, att.slot, att), 0, 0);
|
|
3205
3579
|
const ex = bbox[0] + bbox[2] / 2, ey = bbox[1] + bbox[3] / 2, dx = cx - ex, dy = cy - ey;
|
|
3206
3580
|
const dist = Math.hypot(dx, dy);
|
|
@@ -3245,7 +3619,8 @@ function checkRig(doc, opts = {}) {
|
|
|
3245
3619
|
for (const att of doc.attachments) if (att.type !== "path" && shownAttachments.has(att.id)) used.add(att.asset);
|
|
3246
3620
|
for (const asset of doc.assets) if (!used.has(asset.id))
|
|
3247
3621
|
out.push({ level: "info", code: "unused_asset", subject: asset.id, message: `asset "${asset.id}" is not shown by any attachment at rest or in any animation` });
|
|
3248
|
-
|
|
3622
|
+
const maskSlots = new Set(doc.slots.flatMap((s2) => s2.clip ? [doc.attachments.find((a) => a.id === s2.clip)?.slot] : []));
|
|
3623
|
+
for (const s2 of doc.slots) if (!s2.attachment && !swappedIn.has(s2.id) && !maskSlots.has(s2.id))
|
|
3249
3624
|
out.push({ level: "warn", code: "never_visible", subject: s2.id, message: `slot "${s2.id}" has no attachment and no animation ever shows one` });
|
|
3250
3625
|
return out;
|
|
3251
3626
|
}
|
|
@@ -3273,14 +3648,14 @@ function summarize(findings, max = 12) {
|
|
|
3273
3648
|
}
|
|
3274
3649
|
|
|
3275
3650
|
// src/rig-history.ts
|
|
3276
|
-
import { mkdir as
|
|
3277
|
-
import { basename as basename3, dirname as
|
|
3651
|
+
import { mkdir as mkdir3, readdir as readdir3, readFile as readFile4, writeFile as writeFile3, unlink } from "node:fs/promises";
|
|
3652
|
+
import { basename as basename3, dirname as dirname5, join as join6 } from "node:path";
|
|
3278
3653
|
function historyDir(rigPath) {
|
|
3279
|
-
return
|
|
3654
|
+
return join6(dirname5(rigPath), ".rig-history", basename3(rigPath).replace(/\.json$/, ""));
|
|
3280
3655
|
}
|
|
3281
3656
|
async function files(dir) {
|
|
3282
3657
|
try {
|
|
3283
|
-
return (await
|
|
3658
|
+
return (await readdir3(dir)).filter((f) => /^\d{4,}\.json$/.test(f)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
|
|
3284
3659
|
} catch {
|
|
3285
3660
|
return [];
|
|
3286
3661
|
}
|
|
@@ -3289,22 +3664,22 @@ async function snapshots(rigPath) {
|
|
|
3289
3664
|
return files(historyDir(rigPath));
|
|
3290
3665
|
}
|
|
3291
3666
|
async function listHistory(rigPath) {
|
|
3292
|
-
return (await snapshots(rigPath)).map((f) =>
|
|
3667
|
+
return (await snapshots(rigPath)).map((f) => join6(historyDir(rigPath), f));
|
|
3293
3668
|
}
|
|
3294
|
-
var redoDir = (rigPath) =>
|
|
3669
|
+
var redoDir = (rigPath) => join6(historyDir(rigPath), "redo");
|
|
3295
3670
|
async function listRedo(rigPath) {
|
|
3296
|
-
return (await files(redoDir(rigPath))).map((f) =>
|
|
3671
|
+
return (await files(redoDir(rigPath))).map((f) => join6(redoDir(rigPath), f));
|
|
3297
3672
|
}
|
|
3298
3673
|
async function clearRedo(rigPath) {
|
|
3299
3674
|
for (const f of await listRedo(rigPath)) await unlink(f);
|
|
3300
3675
|
}
|
|
3301
3676
|
async function pushInto(dir, contents, keep) {
|
|
3302
|
-
await
|
|
3677
|
+
await mkdir3(dir, { recursive: true });
|
|
3303
3678
|
const fs = await files(dir);
|
|
3304
3679
|
const next = fs.length ? +fs[fs.length - 1].slice(0, -5) + 1 : 1;
|
|
3305
|
-
const file =
|
|
3306
|
-
await
|
|
3307
|
-
for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(
|
|
3680
|
+
const file = join6(dir, `${String(next).padStart(4, "0")}.json`);
|
|
3681
|
+
await writeFile3(file, contents);
|
|
3682
|
+
for (const f of fs.slice(0, Math.max(0, fs.length + 1 - keep))) await unlink(join6(dir, f));
|
|
3308
3683
|
return file;
|
|
3309
3684
|
}
|
|
3310
3685
|
async function pushHistory(rigPath, contents, keep = 50, opts = {}) {
|
|
@@ -3317,12 +3692,12 @@ async function popHistory(rigPath, steps = 1) {
|
|
|
3317
3692
|
if (!Number.isInteger(steps) || steps < 1 || steps > files2.length)
|
|
3318
3693
|
throw new Error(`only ${files2.length} snapshot${files2.length === 1 ? "" : "s"} available, cannot undo ${steps}`);
|
|
3319
3694
|
const dir = historyDir(rigPath);
|
|
3320
|
-
const contents = await
|
|
3321
|
-
for (const f of files2.slice(files2.length - steps)) await unlink(
|
|
3695
|
+
const contents = await readFile4(join6(dir, files2[files2.length - steps]), "utf8");
|
|
3696
|
+
for (const f of files2.slice(files2.length - steps)) await unlink(join6(dir, f));
|
|
3322
3697
|
return contents;
|
|
3323
3698
|
}
|
|
3324
3699
|
async function undo(rigPath, steps = 1) {
|
|
3325
|
-
const current = await
|
|
3700
|
+
const current = await readFile4(rigPath, "utf8");
|
|
3326
3701
|
const restored = await popHistory(rigPath, steps);
|
|
3327
3702
|
await pushInto(redoDir(rigPath), current, 50);
|
|
3328
3703
|
return restored;
|
|
@@ -3331,8 +3706,8 @@ async function redo(rigPath) {
|
|
|
3331
3706
|
const entries2 = await listRedo(rigPath);
|
|
3332
3707
|
if (!entries2.length) throw new Error("nothing to redo");
|
|
3333
3708
|
const file = entries2[entries2.length - 1];
|
|
3334
|
-
const contents = await
|
|
3335
|
-
await pushHistory(rigPath, await
|
|
3709
|
+
const contents = await readFile4(file, "utf8");
|
|
3710
|
+
await pushHistory(rigPath, await readFile4(rigPath, "utf8"), 50, { keepRedo: true });
|
|
3336
3711
|
await unlink(file);
|
|
3337
3712
|
return contents;
|
|
3338
3713
|
}
|
|
@@ -3426,9 +3801,9 @@ function diffDocs(a, b) {
|
|
|
3426
3801
|
}
|
|
3427
3802
|
|
|
3428
3803
|
// src/rig-atlas.ts
|
|
3429
|
-
import { mkdir as
|
|
3430
|
-
import { join as
|
|
3431
|
-
import { PNG } from "pngjs";
|
|
3804
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
|
|
3805
|
+
import { join as join7 } from "node:path";
|
|
3806
|
+
import { PNG as PNG2 } from "pngjs";
|
|
3432
3807
|
function packRects(rects, opts = {}) {
|
|
3433
3808
|
const extrude = opts.extrude ?? 1, pad = Math.max(opts.padding ?? 2, 2 * extrude), max = opts.maxSize ?? 4096;
|
|
3434
3809
|
const sorted = [...rects].sort((a, b) => b.height - a.height || b.width - a.width || a.id.localeCompare(b.id));
|
|
@@ -3486,10 +3861,10 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
|
|
|
3486
3861
|
for (const a of doc.assets) {
|
|
3487
3862
|
if (!/\.png$/i.test(a.src)) throw new Error(`atlas requires PNG assets: "${a.src}" (asset "${a.id}") is not a PNG`);
|
|
3488
3863
|
let png = files2.get(a.src);
|
|
3489
|
-
if (!png) files2.set(a.src, png =
|
|
3864
|
+
if (!png) files2.set(a.src, png = PNG2.sync.read(await readFile5(join7(assetsDir, a.src))));
|
|
3490
3865
|
if (a.frame) {
|
|
3491
|
-
const [fx, fy, fw, fh] = a.frame, sub = new
|
|
3492
|
-
|
|
3866
|
+
const [fx, fy, fw, fh] = a.frame, sub = new PNG2({ width: fw, height: fh });
|
|
3867
|
+
PNG2.bitblt(png, sub, fx, fy, fw, fh, 0, 0);
|
|
3493
3868
|
png = sub;
|
|
3494
3869
|
}
|
|
3495
3870
|
if (png.width !== a.width || png.height !== a.height)
|
|
@@ -3497,26 +3872,26 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
|
|
|
3497
3872
|
images.set(a.id, png);
|
|
3498
3873
|
}
|
|
3499
3874
|
const packed = packRects(doc.assets.map((a) => ({ id: a.id, width: a.width, height: a.height })), opts);
|
|
3500
|
-
const sheet = new
|
|
3875
|
+
const sheet = new PNG2({ width: packed.width, height: packed.height });
|
|
3501
3876
|
for (const [id, [x, y, w, h]] of packed.frames) {
|
|
3502
|
-
|
|
3877
|
+
PNG2.bitblt(images.get(id), sheet, 0, 0, w, h, x, y);
|
|
3503
3878
|
extrudeFrame(sheet, x, y, w, h, opts.extrude ?? 1);
|
|
3504
3879
|
}
|
|
3505
|
-
await
|
|
3506
|
-
await
|
|
3880
|
+
await mkdir4(outDir, { recursive: true });
|
|
3881
|
+
await writeFile4(join7(outDir, "atlas.png"), PNG2.sync.write(sheet));
|
|
3507
3882
|
const out = { ...doc, assets: doc.assets.map((a) => ({ ...a, src: "atlas.png", frame: packed.frames.get(a.id) })) };
|
|
3508
|
-
await saveJson(
|
|
3883
|
+
await saveJson(join7(outDir, "rig.json"), out);
|
|
3509
3884
|
return out;
|
|
3510
3885
|
}
|
|
3511
3886
|
|
|
3512
3887
|
// src/spine-import.ts
|
|
3513
|
-
import { readFile as
|
|
3514
|
-
import { basename as basename4, dirname as
|
|
3888
|
+
import { readFile as readFile7, stat, writeFile as writeFile5 } from "node:fs/promises";
|
|
3889
|
+
import { basename as basename4, dirname as dirname6, extname, join as join8, relative } from "node:path";
|
|
3515
3890
|
|
|
3516
3891
|
// src/ktx2.ts
|
|
3517
|
-
import { readFile as
|
|
3892
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
3518
3893
|
import { createRequire } from "node:module";
|
|
3519
|
-
import { PNG as
|
|
3894
|
+
import { PNG as PNG3 } from "pngjs";
|
|
3520
3895
|
var require2 = createRequire(import.meta.url);
|
|
3521
3896
|
var basisPromise;
|
|
3522
3897
|
async function basis2() {
|
|
@@ -3528,8 +3903,8 @@ async function basis2() {
|
|
|
3528
3903
|
} catch {
|
|
3529
3904
|
throw new Error("KTX2 pages need the basis transcoder from three.js: `npm i -D three`, or convert the page to PNG yourself");
|
|
3530
3905
|
}
|
|
3531
|
-
const wasm = await
|
|
3532
|
-
const src = await
|
|
3906
|
+
const wasm = await readFile6(jsPath.replace(/\.js$/, ".wasm"));
|
|
3907
|
+
const src = await readFile6(jsPath, "utf8");
|
|
3533
3908
|
const mod = { exports: {} };
|
|
3534
3909
|
new Function("require", "module", "exports", "__dirname", "__filename", src)(require2, mod, mod.exports, jsPath.replace(/\/[^/]+$/, ""), jsPath);
|
|
3535
3910
|
const Basis = await mod.exports({ wasmBinary: wasm });
|
|
@@ -3556,9 +3931,9 @@ async function ktx2ToRgba(data) {
|
|
|
3556
3931
|
}
|
|
3557
3932
|
async function ktx2ToPng(data) {
|
|
3558
3933
|
const { rgba, ...info } = await ktx2ToRgba(data);
|
|
3559
|
-
const png = new
|
|
3934
|
+
const png = new PNG3({ width: info.width, height: info.height });
|
|
3560
3935
|
png.data = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
|
|
3561
|
-
return { ...info, png:
|
|
3936
|
+
return { ...info, png: PNG3.sync.write(png) };
|
|
3562
3937
|
}
|
|
3563
3938
|
|
|
3564
3939
|
// src/spine-import.ts
|
|
@@ -3698,9 +4073,9 @@ function importSpine(json, atlasText, opts = {}) {
|
|
|
3698
4073
|
};
|
|
3699
4074
|
const sequences = /* @__PURE__ */ new Map();
|
|
3700
4075
|
const skinnedName = /* @__PURE__ */ new Map();
|
|
3701
|
-
for (const
|
|
3702
|
-
const prefix =
|
|
3703
|
-
for (const [slotName, atts] of Object.entries(
|
|
4076
|
+
for (const skin2 of skins) {
|
|
4077
|
+
const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
|
|
4078
|
+
for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
|
|
3704
4079
|
for (const [name, a] of Object.entries(atts)) {
|
|
3705
4080
|
const key = `${slotName} ${name}`;
|
|
3706
4081
|
const seq = a.sequence;
|
|
@@ -3765,11 +4140,11 @@ function importSpine(json, atlasText, opts = {}) {
|
|
|
3765
4140
|
}
|
|
3766
4141
|
return { weights };
|
|
3767
4142
|
};
|
|
3768
|
-
for (const
|
|
3769
|
-
const prefix =
|
|
3770
|
-
for (const [slotName, atts] of Object.entries(
|
|
4143
|
+
for (const skin2 of skins) {
|
|
4144
|
+
const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
|
|
4145
|
+
for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
|
|
3771
4146
|
if (!slotIndexByName.has(slotName)) {
|
|
3772
|
-
warnings.push(`skin "${
|
|
4147
|
+
warnings.push(`skin "${skin2.name}": unknown slot "${slotName}"`);
|
|
3773
4148
|
continue;
|
|
3774
4149
|
}
|
|
3775
4150
|
const slot = slotIds.get(slotName);
|
|
@@ -4100,9 +4475,9 @@ function importSpine(json, atlasText, opts = {}) {
|
|
|
4100
4475
|
const attFrames = attachmentFrames.get(slotName);
|
|
4101
4476
|
const setupName = spineSlots.find((x) => x.name === slotName)?.attachment ?? null;
|
|
4102
4477
|
const at = (xs, t) => {
|
|
4103
|
-
let
|
|
4104
|
-
for (const x of xs) if (x.t <= t + 1e-9)
|
|
4105
|
-
return
|
|
4478
|
+
let hit2;
|
|
4479
|
+
for (const x of xs) if (x.t <= t + 1e-9) hit2 = x;
|
|
4480
|
+
return hit2;
|
|
4106
4481
|
};
|
|
4107
4482
|
const shownAt = (t) => {
|
|
4108
4483
|
let name = setupName;
|
|
@@ -4208,21 +4583,21 @@ async function firstExisting(candidates, what) {
|
|
|
4208
4583
|
throw new Error(`${what} not found, tried: ${candidates.join(", ")}`);
|
|
4209
4584
|
}
|
|
4210
4585
|
async function importSpineProject(opts) {
|
|
4211
|
-
const json = JSON.parse(await
|
|
4586
|
+
const json = JSON.parse(await readFile7(opts.json, "utf8"));
|
|
4212
4587
|
const stem = opts.json.replace(/\.json$/i, "");
|
|
4213
4588
|
const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
|
|
4214
|
-
const atlasText = await
|
|
4589
|
+
const atlasText = await readFile7(atlasPath, "utf8");
|
|
4215
4590
|
const { pages } = parseAtlas(atlasText);
|
|
4216
4591
|
if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
|
|
4217
|
-
const pageStem =
|
|
4218
|
-
const imagePath = opts.image ?? await firstExisting([
|
|
4219
|
-
const rigDir =
|
|
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");
|
|
4594
|
+
const rigDir = dirname6(opts.rigPath);
|
|
4220
4595
|
let pngPath = imagePath;
|
|
4221
4596
|
if (extname(imagePath).toLowerCase() === ".ktx2") {
|
|
4222
|
-
pngPath =
|
|
4597
|
+
pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
|
|
4223
4598
|
if (!await exists(pngPath)) {
|
|
4224
|
-
const { png } = await ktx2ToPng(new Uint8Array(await
|
|
4225
|
-
await
|
|
4599
|
+
const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
|
|
4600
|
+
await writeFile5(pngPath, png);
|
|
4226
4601
|
}
|
|
4227
4602
|
}
|
|
4228
4603
|
const pageImage = relative(rigDir, pngPath).split("\\").join("/");
|
|
@@ -4342,11 +4717,11 @@ function describeTemplate(t) {
|
|
|
4342
4717
|
}
|
|
4343
4718
|
|
|
4344
4719
|
// src/rig-template-library.ts
|
|
4345
|
-
import { readFile as
|
|
4346
|
-
import { dirname as
|
|
4720
|
+
import { readFile as readFile8, writeFile as writeFile6, mkdir as mkdir5 } from "node:fs/promises";
|
|
4721
|
+
import { dirname as dirname7 } from "node:path";
|
|
4347
4722
|
async function loadLibrary(path) {
|
|
4348
4723
|
try {
|
|
4349
|
-
return JSON.parse(await
|
|
4724
|
+
return JSON.parse(await readFile8(path, "utf8"));
|
|
4350
4725
|
} catch (e) {
|
|
4351
4726
|
if (e.code === "ENOENT") return [];
|
|
4352
4727
|
throw e;
|
|
@@ -4357,14 +4732,14 @@ async function saveTemplate(path, tpl) {
|
|
|
4357
4732
|
const i = all.findIndex((t) => t.name === tpl.name);
|
|
4358
4733
|
if (i >= 0) all[i] = tpl;
|
|
4359
4734
|
else all.push(tpl);
|
|
4360
|
-
await
|
|
4361
|
-
await
|
|
4735
|
+
await mkdir5(dirname7(path), { recursive: true });
|
|
4736
|
+
await writeFile6(path, JSON.stringify(all, null, 2));
|
|
4362
4737
|
}
|
|
4363
4738
|
|
|
4364
4739
|
// src/rig-mesh-image.ts
|
|
4365
|
-
import { PNG as
|
|
4740
|
+
import { PNG as PNG4 } from "pngjs";
|
|
4366
4741
|
function alphaMaskFromPng(buf, frame) {
|
|
4367
|
-
const png =
|
|
4742
|
+
const png = PNG4.sync.read(buf);
|
|
4368
4743
|
const [fx, fy, w, h] = frame ?? [0, 0, png.width, png.height];
|
|
4369
4744
|
if (fx < 0 || fy < 0 || w < 1 || h < 1 || fx + w > png.width || fy + h > png.height) {
|
|
4370
4745
|
throw new Error(`frame [${fx}, ${fy}, ${w}, ${h}] is outside the ${png.width}x${png.height} image`);
|
|
@@ -4374,11 +4749,479 @@ function alphaMaskFromPng(buf, frame) {
|
|
|
4374
4749
|
return { width: w, height: h, alpha };
|
|
4375
4750
|
}
|
|
4376
4751
|
|
|
4752
|
+
// src/rig-gates.ts
|
|
4753
|
+
import { z as z2 } from "zod";
|
|
4754
|
+
|
|
4755
|
+
// src/rig-state.ts
|
|
4756
|
+
var AnimationState = class {
|
|
4757
|
+
constructor(sink, opts = {}) {
|
|
4758
|
+
this.sink = sink;
|
|
4759
|
+
this.opts = opts;
|
|
4760
|
+
this.defaultMix = opts.mix ?? 0;
|
|
4761
|
+
}
|
|
4762
|
+
onEvent;
|
|
4763
|
+
base;
|
|
4764
|
+
main;
|
|
4765
|
+
queue = [];
|
|
4766
|
+
overlays = /* @__PURE__ */ new Map();
|
|
4767
|
+
/** last composed pose without overlays — mix snapshots start here */
|
|
4768
|
+
pose = /* @__PURE__ */ new Map();
|
|
4769
|
+
from;
|
|
4770
|
+
mixLeft = 0;
|
|
4771
|
+
mixTotal = 0;
|
|
4772
|
+
/** a mix begun in this update shows weight 0 for the frame it starts on */
|
|
4773
|
+
mixFresh = false;
|
|
4774
|
+
defaultMix;
|
|
4775
|
+
/** id of the animation currently shown (main, else base) */
|
|
4776
|
+
get current() {
|
|
4777
|
+
return (this.main ?? this.base)?.anim.id;
|
|
4778
|
+
}
|
|
4779
|
+
get baseId() {
|
|
4780
|
+
return this.base?.anim.id;
|
|
4781
|
+
}
|
|
4782
|
+
channel(id, loop, priority = 0) {
|
|
4783
|
+
const anim = this.sink.doc.animations.find((a) => a.id === id);
|
|
4784
|
+
if (!anim) {
|
|
4785
|
+
const message = `unknown animation "${id}"; available: ${this.sink.doc.animations.map((a) => a.id).join(", ")}`;
|
|
4786
|
+
if (this.opts.missingAnimation === "throw") throw new Error(message);
|
|
4787
|
+
(this.opts.onWarning ?? console.warn)(message);
|
|
4788
|
+
return void 0;
|
|
4789
|
+
}
|
|
4790
|
+
return { anim, time: 0, loop: loop ?? anim.loop, done: false, priority, fresh: true };
|
|
4791
|
+
}
|
|
4792
|
+
setBase(id, opts = {}) {
|
|
4793
|
+
const next = id === void 0 ? void 0 : this.channel(id);
|
|
4794
|
+
if (id !== void 0 && !next) return;
|
|
4795
|
+
this.base = next;
|
|
4796
|
+
if (!this.main || this.main.done) {
|
|
4797
|
+
this.main = void 0;
|
|
4798
|
+
this.beginMix(opts.mix);
|
|
4799
|
+
this.update(0);
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
play(id, opts = {}) {
|
|
4803
|
+
const ch = this.channel(id, opts.loop, opts.priority ?? 0);
|
|
4804
|
+
if (!ch) {
|
|
4805
|
+
this.main?.settle?.("interrupted");
|
|
4806
|
+
for (const queued of this.queue) queued.settle?.("dropped");
|
|
4807
|
+
this.main = void 0;
|
|
4808
|
+
this.queue = [];
|
|
4809
|
+
this.beginMix(opts.mix);
|
|
4810
|
+
this.update(0);
|
|
4811
|
+
opts.onComplete?.("dropped");
|
|
4812
|
+
return Promise.resolve("dropped");
|
|
4813
|
+
}
|
|
4814
|
+
const promise = new Promise((resolve4) => {
|
|
4815
|
+
ch.settle = (r) => {
|
|
4816
|
+
ch.settle = void 0;
|
|
4817
|
+
opts.onComplete?.(r);
|
|
4818
|
+
resolve4(r);
|
|
4819
|
+
};
|
|
4820
|
+
});
|
|
4821
|
+
const cur = this.main, busy = cur !== void 0 && !cur.done, mode = opts.ifBusy ?? "queue";
|
|
4822
|
+
if (!busy || mode === "interrupt" || ch.priority >= cur.priority) {
|
|
4823
|
+
if (busy) cur.settle?.("interrupted");
|
|
4824
|
+
this.main = ch;
|
|
4825
|
+
this.beginMix(opts.mix);
|
|
4826
|
+
this.update(0);
|
|
4827
|
+
} else if (mode === "drop") ch.settle("dropped");
|
|
4828
|
+
else this.queue.push(ch);
|
|
4829
|
+
return promise;
|
|
4830
|
+
}
|
|
4831
|
+
/** Independent loop on top of the shown pose; `undefined` removes it. */
|
|
4832
|
+
setOverlay(name, id) {
|
|
4833
|
+
if (id === void 0) this.overlays.delete(name);
|
|
4834
|
+
else {
|
|
4835
|
+
const ch = this.channel(id);
|
|
4836
|
+
if (ch) this.overlays.set(name, ch);
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
/** Clear everything and show the setup pose. Pending plays resolve as "stopped". */
|
|
4840
|
+
stop() {
|
|
4841
|
+
this.main?.settle?.("stopped");
|
|
4842
|
+
for (const ch of this.queue) ch.settle?.("stopped");
|
|
4843
|
+
this.main = this.base = void 0;
|
|
4844
|
+
this.queue = [];
|
|
4845
|
+
this.overlays.clear();
|
|
4846
|
+
this.from = void 0;
|
|
4847
|
+
this.mixLeft = this.mixTotal = 0;
|
|
4848
|
+
this.mixFresh = false;
|
|
4849
|
+
this.pose = /* @__PURE__ */ new Map();
|
|
4850
|
+
this.sink.setPose(/* @__PURE__ */ new Map());
|
|
4851
|
+
}
|
|
4852
|
+
update(dt) {
|
|
4853
|
+
if (this.base) this.advance(this.base, dt, this.main === void 0);
|
|
4854
|
+
if (this.main) {
|
|
4855
|
+
const wasDone = this.main.done;
|
|
4856
|
+
this.advance(this.main, dt, true);
|
|
4857
|
+
if (this.main.done && !wasDone) {
|
|
4858
|
+
this.main.settle?.("completed");
|
|
4859
|
+
const next = this.queue.shift();
|
|
4860
|
+
if (next || this.base) {
|
|
4861
|
+
this.main = next;
|
|
4862
|
+
this.beginMix();
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
const shown = this.main ?? this.base;
|
|
4867
|
+
let pose = shown ? poseOf(shown.anim, shown.time) : /* @__PURE__ */ new Map();
|
|
4868
|
+
if (this.from) {
|
|
4869
|
+
if (!this.mixFresh) this.mixLeft = Math.max(0, this.mixLeft - dt);
|
|
4870
|
+
this.mixFresh = false;
|
|
4871
|
+
const w = this.mixTotal > 0 ? 1 - this.mixLeft / this.mixTotal : 1;
|
|
4872
|
+
pose = blendPoses(this.from, pose, w, (k) => restValue(this.sink.doc, k));
|
|
4873
|
+
if (this.mixLeft === 0) this.from = void 0;
|
|
4874
|
+
}
|
|
4875
|
+
this.pose = pose;
|
|
4876
|
+
if (this.overlays.size) {
|
|
4877
|
+
pose = new Map(pose);
|
|
4878
|
+
for (const [name, ov] of this.overlays) {
|
|
4879
|
+
this.advance(ov, dt, false);
|
|
4880
|
+
poseOf(ov.anim, ov.time, pose);
|
|
4881
|
+
if (ov.done) this.overlays.delete(name);
|
|
4882
|
+
}
|
|
4883
|
+
}
|
|
4884
|
+
this.sink.setPose(pose);
|
|
4885
|
+
}
|
|
4886
|
+
beginMix(mix = this.defaultMix) {
|
|
4887
|
+
if (mix > 0) {
|
|
4888
|
+
this.from = new Map(this.pose);
|
|
4889
|
+
this.mixLeft = this.mixTotal = mix;
|
|
4890
|
+
this.mixFresh = true;
|
|
4891
|
+
} else {
|
|
4892
|
+
this.from = void 0;
|
|
4893
|
+
this.mixLeft = this.mixTotal = 0;
|
|
4894
|
+
this.mixFresh = false;
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
advance(ch, dt, emit) {
|
|
4898
|
+
if (ch.done) return;
|
|
4899
|
+
const prev = ch.fresh ? -1 : ch.time, d = ch.anim.duration;
|
|
4900
|
+
ch.fresh = false;
|
|
4901
|
+
ch.time += dt;
|
|
4902
|
+
if (emit) {
|
|
4903
|
+
for (const e of ch.anim.events) if (hit(e.t, prev, ch.time, d, ch.loop)) this.onEvent?.(e.name, e.payload);
|
|
4904
|
+
}
|
|
4905
|
+
if (ch.time >= d) {
|
|
4906
|
+
if (ch.loop) ch.time %= d;
|
|
4907
|
+
else {
|
|
4908
|
+
ch.time = d;
|
|
4909
|
+
ch.done = true;
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
}
|
|
4913
|
+
};
|
|
4914
|
+
function hit(t, prev, cur, d, loop) {
|
|
4915
|
+
if (cur < d || !loop) return t > prev && t <= cur;
|
|
4916
|
+
return t > prev && t <= d || t <= cur % d;
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
// src/rig-gates.ts
|
|
4920
|
+
var Limit = z2.number().nonnegative().optional();
|
|
4921
|
+
var Window = z2.tuple([z2.number().nonnegative(), z2.number().nonnegative()]).optional();
|
|
4922
|
+
var GateConfig = z2.object({
|
|
4923
|
+
animation: z2.string(),
|
|
4924
|
+
fps: z2.number().int().min(1).max(240).default(60),
|
|
4925
|
+
requiredAnimations: z2.array(z2.string()).default([]),
|
|
4926
|
+
motion: z2.array(z2.object({ bone: z2.string(), channel: z2.enum(["position", "rotation"]).default("position"), space: z2.enum(["world", "local"]).default("world"), minAmplitude: Limit, maxSpeed: Limit, maxAcceleration: Limit, maxJerk: Limit, maxStep: Limit, maxSetupDrift: Limit, during: Window })).default([]),
|
|
4927
|
+
distances: z2.array(z2.object({ a: z2.string(), b: z2.string(), min: Limit, max: Limit, minRatio: Limit, maxRatio: Limit, during: Window })).default([]),
|
|
4928
|
+
conditions: z2.array(z2.object({ whenSlotVisible: z2.string(), bone: z2.string(), prop: z2.enum(["x", "y", "rotation"]), space: z2.enum(["world", "local"]).default("world"), min: z2.number().optional(), max: z2.number().optional(), required: z2.boolean().default(true) })).default([]),
|
|
4929
|
+
transitions: z2.array(z2.object({ from: z2.string(), to: z2.string(), fromTime: z2.number().nonnegative().default(0), mix: z2.number().nonnegative().default(0.18), maxStep: z2.number().nonnegative(), duration: z2.number().positive().default(0.5) })).default([])
|
|
4930
|
+
}).strict();
|
|
4931
|
+
var norm = (v) => Math.hypot(...v);
|
|
4932
|
+
var subtract = (a, b) => a.map((v, i) => v - b[i]);
|
|
4933
|
+
var wrap = (v) => ((v + 180) % 360 + 360) % 360 - 180;
|
|
4934
|
+
function runGates(doc, config) {
|
|
4935
|
+
const cfg = GateConfig.parse(config), animation = doc.animations.find((a) => a.id === cfg.animation);
|
|
4936
|
+
if (!animation) throw new Error(`unknown animation "${cfg.animation}"`);
|
|
4937
|
+
if (!cfg.requiredAnimations.length && !cfg.motion.length && !cfg.distances.length && !cfg.conditions.length && !cfg.transitions.length) throw new Error("gate config needs at least one explicit rule");
|
|
4938
|
+
const failures = [];
|
|
4939
|
+
const metrics = {};
|
|
4940
|
+
for (const id of cfg.requiredAnimations) if (!doc.animations.some((a) => a.id === id)) failures.push(`required animation missing: ${id}`);
|
|
4941
|
+
const n = Math.max(1, Math.ceil(animation.duration * cfg.fps)), dt = animation.duration / n;
|
|
4942
|
+
const frames = Array.from({ length: n + 1 }, (_, i) => {
|
|
4943
|
+
const t = i * dt, pose = poseOf(animation, t);
|
|
4944
|
+
return { t, pose, skel: evaluate(doc, pose) };
|
|
4945
|
+
});
|
|
4946
|
+
const rest = evaluate(doc, /* @__PURE__ */ new Map());
|
|
4947
|
+
const bone = (s2, id, channel, space) => {
|
|
4948
|
+
const b = s2.bones.get(id);
|
|
4949
|
+
if (!b) throw new Error(`unknown gate bone "${id}"`);
|
|
4950
|
+
return channel === "rotation" ? [space === "local" ? b.rotation : Math.atan2(b.world.b, b.world.a) * 180 / Math.PI] : space === "local" ? [b.x, b.y] : [b.world.tx, b.world.ty];
|
|
4951
|
+
};
|
|
4952
|
+
const inside = (t, w) => !w || t >= w[0] - 1e-9 && t <= w[1] + 1e-9;
|
|
4953
|
+
const windowCheck = (w) => {
|
|
4954
|
+
if (w && (w[0] > w[1] || w[1] > animation.duration)) throw new Error("invalid gate time window");
|
|
4955
|
+
};
|
|
4956
|
+
for (const rule of cfg.motion) {
|
|
4957
|
+
if ([rule.minAmplitude, rule.maxSpeed, rule.maxAcceleration, rule.maxJerk, rule.maxStep, rule.maxSetupDrift].every((v) => v === void 0)) throw new Error("motion gate needs an explicit threshold");
|
|
4958
|
+
windowCheck(rule.during);
|
|
4959
|
+
const fs = frames.filter((f) => inside(f.t, rule.during));
|
|
4960
|
+
if (fs.length < 4) throw new Error(`motion gate ${rule.bone} needs at least four samples`);
|
|
4961
|
+
const vs = fs.map((f) => bone(f.skel, rule.bone, rule.channel, rule.space));
|
|
4962
|
+
if (rule.channel === "rotation") for (let i = 1; i < vs.length; i++) vs[i][0] = vs[i - 1][0] + wrap(vs[i][0] - vs[i - 1][0]);
|
|
4963
|
+
const derivative = (xs) => xs.slice(1).map((v, i) => subtract(v, xs[i]).map((v2) => v2 / dt));
|
|
4964
|
+
const velocity = derivative(vs), acceleration = derivative(velocity), jerk = derivative(acceleration);
|
|
4965
|
+
const range = vs[0].map((_, i) => Math.max(...vs.map((v) => v[i])) - Math.min(...vs.map((v) => v[i])));
|
|
4966
|
+
const setup = bone(rest, rule.bone, rule.channel, rule.space);
|
|
4967
|
+
const m = { samples: vs.length, amplitude: norm(range), speed: Math.max(...velocity.map(norm)), acceleration: Math.max(...acceleration.map(norm)), jerk: Math.max(...jerk.map(norm)), step: Math.max(...velocity.map(norm)) * dt, setupDrift: Math.max(...vs.map((v) => norm(subtract(v, setup)))) };
|
|
4968
|
+
const id = `${rule.bone}.${rule.channel}.${rule.space}`;
|
|
4969
|
+
metrics[id] = m;
|
|
4970
|
+
if (rule.minAmplitude !== void 0 && m.amplitude < rule.minAmplitude) failures.push(`${id}: amplitude ${m.amplitude} < ${rule.minAmplitude}`);
|
|
4971
|
+
for (const [limit, key] of [["maxSpeed", "speed"], ["maxAcceleration", "acceleration"], ["maxJerk", "jerk"], ["maxStep", "step"], ["maxSetupDrift", "setupDrift"]])
|
|
4972
|
+
if (rule[limit] !== void 0 && m[key] > rule[limit]) failures.push(`${id}: ${key} ${m[key]} > ${rule[limit]}`);
|
|
4973
|
+
}
|
|
4974
|
+
for (const rule of cfg.distances) {
|
|
4975
|
+
if ([rule.min, rule.max, rule.minRatio, rule.maxRatio].every((v) => v === void 0)) throw new Error("distance gate needs an explicit threshold");
|
|
4976
|
+
windowCheck(rule.during);
|
|
4977
|
+
const fs = frames.filter((f) => inside(f.t, rule.during));
|
|
4978
|
+
if (!fs.length) throw new Error("distance gate has no samples");
|
|
4979
|
+
const distance = (s2) => norm(subtract(bone(s2, rule.a, "position", "world"), bone(s2, rule.b, "position", "world")));
|
|
4980
|
+
const setup = distance(rest), values2 = fs.map((f) => distance(f.skel)), lo = Math.min(...values2), hi = Math.max(...values2);
|
|
4981
|
+
metrics[`${rule.a}-${rule.b}`] = { samples: fs.length, min: lo, max: hi, setup };
|
|
4982
|
+
if ((rule.minRatio !== void 0 || rule.maxRatio !== void 0) && setup < 1e-9) throw new Error("distance ratio needs noncoincident setup landmarks");
|
|
4983
|
+
if (rule.min !== void 0 && lo < rule.min || rule.max !== void 0 && hi > rule.max || rule.minRatio !== void 0 && lo < setup * rule.minRatio || rule.maxRatio !== void 0 && hi > setup * rule.maxRatio) failures.push(`distance ${rule.a}-${rule.b} outside allowed range`);
|
|
4984
|
+
}
|
|
4985
|
+
for (const rule of cfg.conditions) {
|
|
4986
|
+
if (rule.min === void 0 && rule.max === void 0) throw new Error("condition gate needs an explicit threshold");
|
|
4987
|
+
const slot = doc.slots.find((s2) => s2.id === rule.whenSlotVisible);
|
|
4988
|
+
if (!slot) throw new Error(`unknown gate slot "${rule.whenSlotVisible}"`);
|
|
4989
|
+
const ts = new Set(frames.map((f) => f.t));
|
|
4990
|
+
for (const tr of animation.tracks) for (const k of tr.keys) {
|
|
4991
|
+
ts.add(k.t);
|
|
4992
|
+
if (k.t > 0) ts.add(Math.max(0, k.t - 1e-6));
|
|
4993
|
+
}
|
|
4994
|
+
let active2 = 0, bad = 0;
|
|
4995
|
+
for (const t of ts) {
|
|
4996
|
+
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];
|
|
4999
|
+
if (!s2.attachment.get(slot.id) || alpha <= 0.01) continue;
|
|
5000
|
+
active2++;
|
|
5001
|
+
const v = bone(s2, rule.bone, rule.prop === "rotation" ? "rotation" : "position", rule.space)[rule.prop === "y" ? 1 : 0];
|
|
5002
|
+
if (rule.min !== void 0 && v < rule.min || rule.max !== void 0 && v > rule.max) bad++;
|
|
5003
|
+
}
|
|
5004
|
+
metrics[`condition:${slot.id}:${rule.bone}.${rule.prop}`] = { active: active2, bad };
|
|
5005
|
+
if (bad || !active2 && rule.required) failures.push(`${slot.id} condition: ${bad} invalid visible samples, ${active2} active samples`);
|
|
5006
|
+
}
|
|
5007
|
+
for (const tr of cfg.transitions) {
|
|
5008
|
+
for (const id of [tr.from, tr.to]) if (!doc.animations.some((a) => a.id === id)) throw new Error(`missing transition animation ${id}`);
|
|
5009
|
+
const sink = { doc, pose: /* @__PURE__ */ new Map(), setPose(p) {
|
|
5010
|
+
this.pose = p;
|
|
5011
|
+
} }, state = new AnimationState(sink, { mix: tr.mix, missingAnimation: "throw" });
|
|
5012
|
+
state.setBase(tr.from, { mix: 0 });
|
|
5013
|
+
state.update(tr.fromTime);
|
|
5014
|
+
let previous = evaluate(doc, sink.pose), previousPose = sink.pose, maxStep = 0;
|
|
5015
|
+
state.play(tr.to);
|
|
5016
|
+
const measure = () => {
|
|
5017
|
+
const next = evaluate(doc, sink.pose);
|
|
5018
|
+
for (const b of doc.bones) {
|
|
5019
|
+
const a = previous.bones.get(b.id).world, c = next.bones.get(b.id).world;
|
|
5020
|
+
const p = apply(a, b.length, 0), q = apply(c, b.length, 0);
|
|
5021
|
+
maxStep = Math.max(maxStep, Math.hypot(c.tx - a.tx, c.ty - a.ty), Math.hypot(q[0] - p[0], q[1] - p[1]));
|
|
5022
|
+
}
|
|
5023
|
+
for (const s2 of doc.slots) {
|
|
5024
|
+
if (previous.attachment.get(s2.id)?.id !== next.attachment.get(s2.id)?.id) continue;
|
|
5025
|
+
const before = slotOutline(doc, previousPose, s2.id, 1, previous), after = slotOutline(doc, sink.pose, s2.id, 1, next);
|
|
5026
|
+
if (before && after && before.length === after.length) for (let i = 0; i < before.length; i++) maxStep = Math.max(maxStep, Math.hypot(after[i][0] - before[i][0], after[i][1] - before[i][1]));
|
|
5027
|
+
}
|
|
5028
|
+
previous = next;
|
|
5029
|
+
previousPose = sink.pose;
|
|
5030
|
+
};
|
|
5031
|
+
measure();
|
|
5032
|
+
const count = Math.ceil(tr.duration * cfg.fps);
|
|
5033
|
+
for (let i = 0; i < count; i++) {
|
|
5034
|
+
state.update(1 / cfg.fps);
|
|
5035
|
+
measure();
|
|
5036
|
+
}
|
|
5037
|
+
state.stop();
|
|
5038
|
+
metrics[`transition:${tr.from}->${tr.to}`] = { samples: count + 1, maxStep, mix: tr.mix };
|
|
5039
|
+
if (maxStep > tr.maxStep) failures.push(`transition ${tr.from}->${tr.to}: step ${maxStep} > ${tr.maxStep}`);
|
|
5040
|
+
}
|
|
5041
|
+
return { pass: failures.length === 0, animation: animation.id, fps: cfg.fps, interval: dt, expected: n + 1, inspected: frames.length, metrics, failures };
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
// src/rig-api.ts
|
|
5045
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5046
|
+
|
|
5047
|
+
// src/rig-motion.ts
|
|
5048
|
+
var wrap2 = (x) => ((x + 180) % 360 + 360) % 360 - 180;
|
|
5049
|
+
var mesh = (doc, id) => {
|
|
5050
|
+
const a = doc.attachments.find((a2) => a2.id === id);
|
|
5051
|
+
if (a?.type !== "mesh") throw new Error(`expected mesh "${id}"`);
|
|
5052
|
+
return a;
|
|
5053
|
+
};
|
|
5054
|
+
function skin(doc, a, weights) {
|
|
5055
|
+
const skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
|
|
5056
|
+
return weights.map((list, i) => {
|
|
5057
|
+
const sum = [...list.values()].reduce((a2, b) => a2 + b, 0);
|
|
5058
|
+
if (!(sum > 0)) throw new Error(`vertex ${i}: weights sum to zero`);
|
|
5059
|
+
return [...list].filter(([, w]) => w > 1e-9).map(([bone, w]) => {
|
|
5060
|
+
const b = skel.bones.get(bone);
|
|
5061
|
+
if (!b) throw new Error(`unknown weight bone "${bone}"`);
|
|
5062
|
+
const [x, y] = apply(invert(b.world), world[i * 2], world[i * 2 + 1]);
|
|
5063
|
+
return { bone, x, y, w: w / sum };
|
|
5064
|
+
});
|
|
5065
|
+
});
|
|
5066
|
+
}
|
|
5067
|
+
function gradientWeights(doc, id, bones) {
|
|
5068
|
+
const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map());
|
|
5069
|
+
if (bones.length < 2 || new Set(bones).size !== bones.length) throw new Error("gradient needs at least two distinct bones");
|
|
5070
|
+
const points = bones.map((id2) => {
|
|
5071
|
+
const b = skel.bones.get(id2);
|
|
5072
|
+
if (!b) throw new Error(`unknown bone "${id2}"`);
|
|
5073
|
+
return [b.world.tx, b.world.ty];
|
|
5074
|
+
});
|
|
5075
|
+
const world = attachmentWorldVertices(skel, a.slot, a), weights = [];
|
|
5076
|
+
for (let v = 0; v < world.length; v += 2) {
|
|
5077
|
+
let best = Infinity, segment = 0, u = 0;
|
|
5078
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
5079
|
+
const [x, y] = points[i], dx = points[i + 1][0] - x, dy = points[i + 1][1] - y, l2 = dx * dx + dy * dy;
|
|
5080
|
+
if (l2 < 1e-12) throw new Error("gradient chain contains coincident joints");
|
|
5081
|
+
const t = Math.max(0, Math.min(1, ((world[v] - x) * dx + (world[v + 1] - y) * dy) / l2));
|
|
5082
|
+
const d = (world[v] - x - t * dx) ** 2 + (world[v + 1] - y - t * dy) ** 2;
|
|
5083
|
+
if (d < best) {
|
|
5084
|
+
best = d;
|
|
5085
|
+
segment = i;
|
|
5086
|
+
u = t;
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
weights.push(/* @__PURE__ */ new Map([[bones[segment], 1 - u], [bones[segment + 1], u]]));
|
|
5090
|
+
}
|
|
5091
|
+
return setWeights(doc, id, skin(doc, a, weights)).doc;
|
|
5092
|
+
}
|
|
5093
|
+
function smoothWeights(doc, id, iterations = 3, strength = 0.5, locked = []) {
|
|
5094
|
+
const a = mesh(doc, id);
|
|
5095
|
+
if (!a.weights) throw new Error("skin the mesh before smoothing weights");
|
|
5096
|
+
if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100 || strength < 0 || strength > 1) throw new Error("invalid smoothing options");
|
|
5097
|
+
const fixed = new Set(locked), neighbours = a.weights.map(() => /* @__PURE__ */ new Set());
|
|
5098
|
+
for (const v of fixed) if (!Number.isInteger(v) || v < 0 || v >= neighbours.length) throw new Error(`invalid locked vertex ${v}`);
|
|
5099
|
+
for (let i = 0; i < a.triangles.length; i += 3) for (const v of a.triangles.slice(i, i + 3)) for (const n of a.triangles.slice(i, i + 3)) if (v !== n) neighbours[v].add(n);
|
|
5100
|
+
let w = a.weights.map((list) => new Map(list.map((i) => [i.bone, i.w])));
|
|
5101
|
+
for (let iter = 0; iter < iterations; iter++) w = w.map((own, i) => {
|
|
5102
|
+
if (fixed.has(i) || !neighbours[i].size) return own;
|
|
5103
|
+
const next = new Map([...own].map(([b, v]) => [b, v * (1 - strength)]));
|
|
5104
|
+
for (const n of neighbours[i]) for (const [b, v] of w[n]) next.set(b, (next.get(b) ?? 0) + strength * v / neighbours[i].size);
|
|
5105
|
+
return next;
|
|
5106
|
+
});
|
|
5107
|
+
return setWeights(doc, id, skin(doc, a, w)).doc;
|
|
5108
|
+
}
|
|
5109
|
+
function pinWeights(doc, id, bone, vertices, region) {
|
|
5110
|
+
const a = mesh(doc, id), skel = evaluate(doc, /* @__PURE__ */ new Map()), world = attachmentWorldVertices(skel, a.slot, a);
|
|
5111
|
+
if (!skel.bones.has(bone)) throw new Error(`unknown bone "${bone}"`);
|
|
5112
|
+
if (!vertices?.length && !region) throw new Error("select vertices or a document-space region");
|
|
5113
|
+
const selected = new Set(vertices);
|
|
5114
|
+
for (const v of selected) if (!Number.isInteger(v) || v < 0 || v >= world.length / 2) throw new Error(`invalid vertex ${v}`);
|
|
5115
|
+
const host = doc.slots.find((s2) => s2.id === a.slot).bone;
|
|
5116
|
+
let count = 0;
|
|
5117
|
+
const w = Array.from({ length: world.length / 2 }, (_, i) => {
|
|
5118
|
+
const x = world[i * 2], y = world[i * 2 + 1];
|
|
5119
|
+
const inside = region && x >= region[0] && y >= region[1] && x <= region[0] + region[2] && y <= region[1] + region[3];
|
|
5120
|
+
if (selected.has(i) || inside) {
|
|
5121
|
+
count++;
|
|
5122
|
+
return /* @__PURE__ */ new Map([[bone, 1]]);
|
|
5123
|
+
}
|
|
5124
|
+
return new Map(a.weights?.[i].map((inf) => [inf.bone, inf.w]) ?? [[host, 1]]);
|
|
5125
|
+
});
|
|
5126
|
+
if (!count) throw new Error("selection contains no mesh vertices");
|
|
5127
|
+
return setWeights(doc, id, skin(doc, a, w)).doc;
|
|
5128
|
+
}
|
|
5129
|
+
function attachToBone(doc, animation, bone, target, t) {
|
|
5130
|
+
const anim = doc.animations.find((a) => a.id === animation);
|
|
5131
|
+
if (!anim || t < 0 || t > anim.duration) throw new Error("attach requires an existing animation and time within its duration");
|
|
5132
|
+
const skel = evaluate(doc, poseOf(anim, t)), from = skel.bones.get(bone)?.world, to = skel.bones.get(target)?.world;
|
|
5133
|
+
if (!from || !to || bone === target) throw new Error("attach needs distinct existing bones");
|
|
5134
|
+
for (let b = doc.bones.find((b2) => b2.id === target); b; b = doc.bones.find((x2) => x2.id === b.parent)) if (b.id === bone) throw new Error("cannot attach to a descendant");
|
|
5135
|
+
if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
|
|
5136
|
+
const [x, y] = apply(invert(to), from.tx, from.ty);
|
|
5137
|
+
const id = `attach_${bone}_${target}`;
|
|
5138
|
+
const offset = {
|
|
5139
|
+
x,
|
|
5140
|
+
y,
|
|
5141
|
+
rotation: wrap2(rotationOf(from) - rotationOf(to)),
|
|
5142
|
+
scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
|
|
5143
|
+
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)
|
|
5145
|
+
};
|
|
5146
|
+
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
|
+
let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
|
|
5148
|
+
for (const c of next.transform.filter((c2) => c2.id.startsWith(`attach_${bone}_`) && c2.bones.length === 1 && c2.bones[0] === bone)) {
|
|
5149
|
+
for (const prop of ["mixX", "mixY", "mixRotate", "mixScaleX", "mixScaleY", "mixShearY"]) {
|
|
5150
|
+
const has = anim.tracks.some((tr) => tr.target === "transform" && tr.id === c.id && tr.prop === prop);
|
|
5151
|
+
const keys = [...!has && t > 0 ? [{ t: 0, v: 0, ease: "step" }] : [], { t, v: c.id === id ? 1 : 0, ease: "step" }];
|
|
5152
|
+
next = setKeys(next, animation, { target: "transform", id: c.id, prop }, keys, void 0, { merge: true });
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
for (const [prop, v] of Object.entries(offset)) next = setKeys(next, animation, { target: "transform", id, prop }, [{ t, v, ease: "step" }], void 0, { merge: true });
|
|
5156
|
+
return next;
|
|
5157
|
+
}
|
|
5158
|
+
function bakeSecondary(doc, animation, opts) {
|
|
5159
|
+
const anim = doc.animations.find((a) => a.id === animation);
|
|
5160
|
+
if (!anim) throw new Error(`unknown animation "${animation}"`);
|
|
5161
|
+
const fps = opts.fps ?? 60, hz = opts.frequency ?? 2.5, damping = opts.damping ?? 0.65, gain = opts.gain ?? 0.2, maxAngle = opts.maxAngle ?? 30;
|
|
5162
|
+
if (fps <= 0 || hz <= 0 || damping <= 0 || maxAngle <= 0 || !opts.bones.length) throw new Error("invalid spring parameters");
|
|
5163
|
+
if (!doc.bones.some((b) => b.id === opts.driver) || opts.bones.includes(opts.driver)) throw new Error("driver must exist and be outside the secondary chain");
|
|
5164
|
+
if (new Set(opts.bones).size !== opts.bones.length) throw new Error("secondary bones must be distinct");
|
|
5165
|
+
for (let b = doc.bones.find((b2) => b2.id === opts.driver); b; b = doc.bones.find((x) => x.id === b.parent)) {
|
|
5166
|
+
if (opts.bones.includes(b.id)) throw new Error("secondary bones cannot be ancestors of the driver");
|
|
5167
|
+
}
|
|
5168
|
+
const n = Math.max(2, Math.ceil(anim.duration * fps)), dt = anim.duration / n;
|
|
5169
|
+
const base = { ...anim, tracks: anim.tracks.filter((t) => !(t.target === "bone" && opts.bones.includes(t.id) && t.prop === "rotation")) };
|
|
5170
|
+
const angular = (opts.driverProp ?? "rotation") === "rotation";
|
|
5171
|
+
const angles = Array.from({ length: n + 1 }, (_, i) => {
|
|
5172
|
+
const world = evaluate(doc, poseOf(base, dt * i)).bones.get(opts.driver).world;
|
|
5173
|
+
return angular ? rotationOf(world) : opts.driverProp === "x" ? world.tx : world.ty;
|
|
5174
|
+
});
|
|
5175
|
+
const seam = angles[n] - angles[0];
|
|
5176
|
+
if (anim.loop && Math.abs(angular ? wrap2(seam) : seam) > 1e-3) throw new Error("loop driver must return to its starting world pose before baking");
|
|
5177
|
+
let next = doc;
|
|
5178
|
+
for (let bi = 0; bi < opts.bones.length; bi++) {
|
|
5179
|
+
const bone = doc.bones.find((b) => b.id === opts.bones[bi]);
|
|
5180
|
+
if (!bone) throw new Error(`unknown secondary bone "${opts.bones[bi]}"`);
|
|
5181
|
+
const omega = 2 * Math.PI * hz / (1 + bi * 0.25), sub = Math.max(1, Math.ceil(dt * Math.max(240, omega * 12))), h = dt / sub;
|
|
5182
|
+
let x = 0, velocity = 0, samples = [];
|
|
5183
|
+
const cycle = () => {
|
|
5184
|
+
samples = [x];
|
|
5185
|
+
for (let i = 1; i <= n; i++) {
|
|
5186
|
+
const difference = angles[i] - angles[i - 1];
|
|
5187
|
+
const drive = (angular ? wrap2(difference) : difference) / dt;
|
|
5188
|
+
for (let j2 = 0; j2 < sub; j2++) {
|
|
5189
|
+
velocity += (-omega * omega * x - 2 * damping * omega * velocity - gain * omega * drive) * h;
|
|
5190
|
+
x += velocity * h;
|
|
5191
|
+
if (Math.abs(x) > maxAngle) {
|
|
5192
|
+
x = Math.sign(x) * maxAngle;
|
|
5193
|
+
velocity = 0;
|
|
5194
|
+
}
|
|
5195
|
+
}
|
|
5196
|
+
samples.push(x);
|
|
5197
|
+
}
|
|
5198
|
+
};
|
|
5199
|
+
if (anim.loop) {
|
|
5200
|
+
let converged = false;
|
|
5201
|
+
for (let i = 0; i < 200; i++) {
|
|
5202
|
+
const oldX = x, oldV = velocity;
|
|
5203
|
+
cycle();
|
|
5204
|
+
if (Math.abs(x - oldX) < 1e-7 && Math.abs(velocity - oldV) < 1e-7) {
|
|
5205
|
+
converged = true;
|
|
5206
|
+
break;
|
|
5207
|
+
}
|
|
5208
|
+
}
|
|
5209
|
+
if (!converged) throw new Error("spring loop did not converge; increase damping or duration");
|
|
5210
|
+
samples[n] = samples[0];
|
|
5211
|
+
} else cycle();
|
|
5212
|
+
const origin = anim.loop ? samples[0] : 0;
|
|
5213
|
+
const amplitude = Math.max(...samples.map((v) => Math.abs(v - origin)));
|
|
5214
|
+
const scale = Math.min(1, maxAngle / Math.max(amplitude, 1e-12));
|
|
5215
|
+
next = setKeys(next, animation, { target: "bone", id: bone.id, prop: "rotation", interpolation: "pchip" }, samples.map((v, i) => ({ t: i * dt, v: bone.transform.rotation + (v - origin) * scale, ease: "linear" })), anim.duration);
|
|
5216
|
+
}
|
|
5217
|
+
return next;
|
|
5218
|
+
}
|
|
5219
|
+
|
|
4377
5220
|
// src/rig-api.ts
|
|
4378
5221
|
var tools = [];
|
|
4379
5222
|
function tool(name, description, input, run) {
|
|
4380
5223
|
const handler = run;
|
|
4381
|
-
tools.push({ name, description, input:
|
|
5224
|
+
tools.push({ name, description, input: z3.object(input).strict(), run: (args2) => typeof args2.path === "string" ? withRigQueue(args2.path, () => handler(args2)) : handler(args2) });
|
|
4382
5225
|
}
|
|
4383
5226
|
async function callTool(name, args2) {
|
|
4384
5227
|
const t = tools.find((t2) => t2.name === name);
|
|
@@ -4394,24 +5237,24 @@ async function callTool(name, args2) {
|
|
|
4394
5237
|
var closeTools = () => closePreview();
|
|
4395
5238
|
var text = (s2, doc) => ({ text: s2, doc });
|
|
4396
5239
|
var fail = (e) => ({ text: `ERROR: ${e instanceof Error ? e.message : String(e)}`, isError: true });
|
|
4397
|
-
var Path =
|
|
4398
|
-
var Library =
|
|
5240
|
+
var Path = z3.string().describe("path to rig.json; assets are resolved relative to its directory");
|
|
5241
|
+
var Library = z3.string().optional().describe("template library JSON; default presets.json next to rig.json");
|
|
4399
5242
|
async function load(path) {
|
|
4400
|
-
return RigDocument.parse(JSON.parse(await
|
|
5243
|
+
return RigDocument.parse(JSON.parse(await readFile9(path, "utf8")));
|
|
4401
5244
|
}
|
|
4402
5245
|
var save = (path, doc) => saveJson(path, doc);
|
|
4403
5246
|
var exists2 = (p) => stat2(p).then(() => true, () => false);
|
|
4404
|
-
var resolveFrom = (rigPath, p) => isAbsolute(p) ? p :
|
|
4405
|
-
var srcFor = (rigPath, file) => relative2(
|
|
4406
|
-
var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) :
|
|
5247
|
+
var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join9(dirname8(rigPath), p);
|
|
5248
|
+
var srcFor = (rigPath, file) => relative2(dirname8(rigPath), file).split("\\").join("/");
|
|
5249
|
+
var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join9(dirname8(rigPath), "presets.json");
|
|
4407
5250
|
async function pngInfo(rigPath, file) {
|
|
4408
5251
|
const abs = resolveFrom(rigPath, file);
|
|
4409
|
-
const [width, height] = pngSize(await
|
|
5252
|
+
const [width, height] = pngSize(await readFile9(abs));
|
|
4410
5253
|
return { abs, width, height, src: srcFor(rigPath, abs) };
|
|
4411
5254
|
}
|
|
4412
5255
|
async function mutate(path, op, summary = false) {
|
|
4413
5256
|
try {
|
|
4414
|
-
const raw = await
|
|
5257
|
+
const raw = await readFile9(path, "utf8");
|
|
4415
5258
|
const prev = RigDocument.parse(JSON.parse(raw));
|
|
4416
5259
|
const next = parse(await op(prev));
|
|
4417
5260
|
await pushHistory(path, raw);
|
|
@@ -4425,7 +5268,7 @@ changed:${changes.length ? "\n " + changes.join("\n ") : " nothing"}`, next);
|
|
|
4425
5268
|
}
|
|
4426
5269
|
}
|
|
4427
5270
|
async function create(path, doc) {
|
|
4428
|
-
if (await exists2(path)) await pushHistory(path, await
|
|
5271
|
+
if (await exists2(path)) await pushHistory(path, await readFile9(path, "utf8"));
|
|
4429
5272
|
const saved = parse(doc);
|
|
4430
5273
|
await save(path, saved);
|
|
4431
5274
|
return text(describe(saved), saved);
|
|
@@ -4437,18 +5280,18 @@ function previewCheck(doc, animation) {
|
|
|
4437
5280
|
const a = animation ? doc.animations.find((x) => x.id === animation) : void 0;
|
|
4438
5281
|
return a ? `check: OK for "${a.id}" (${a.tracks.length} tracks, ${a.duration}s${a.loop ? " loop" : ""})` : "check: OK (rest pose)";
|
|
4439
5282
|
}
|
|
4440
|
-
var TrackRef = { target:
|
|
4441
|
-
var KeyIn =
|
|
4442
|
-
t:
|
|
4443
|
-
v:
|
|
4444
|
-
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")
|
|
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() };
|
|
5284
|
+
var KeyIn = z3.object({
|
|
5285
|
+
t: z3.number().describe("seconds"),
|
|
5286
|
+
v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
|
|
5287
|
+
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")
|
|
4445
5288
|
});
|
|
4446
5289
|
var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
|
|
4447
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].";
|
|
4448
5291
|
tool(
|
|
4449
5292
|
"rig_create",
|
|
4450
5293
|
"Create a new empty rig document with a root bone at bottom-center.",
|
|
4451
|
-
{ path: Path, name:
|
|
5294
|
+
{ path: Path, name: z3.string(), width: z3.number().int(), height: z3.number().int(), notes: z3.string().optional().describe("style anchor + what is intentionally missing; reused by rig_layer_prompt") },
|
|
4452
5295
|
async ({ path, name, width, height, notes }) => {
|
|
4453
5296
|
try {
|
|
4454
5297
|
return await create(path, createDoc(name, width, height, notes));
|
|
@@ -4460,7 +5303,7 @@ tool(
|
|
|
4460
5303
|
tool(
|
|
4461
5304
|
"rig_create_symbol",
|
|
4462
5305
|
"Create a rig from ONE image (a slot symbol): canvas = image + margin, root at the image's bottom centre, slot pivot (0.5, 1). Then apply symbol_idle / symbol_win / anticipation_shake / dim presets.",
|
|
4463
|
-
{ path: Path, image:
|
|
5306
|
+
{ path: Path, image: z3.string().describe("PNG path, absolute or relative to rig.json dir"), name: z3.string().optional(), margin: z3.number().default(0.15).describe("fraction of the image size on every side, room for bounces"), notes: z3.string().optional() },
|
|
4464
5307
|
async ({ path, image, name, margin, notes }) => {
|
|
4465
5308
|
try {
|
|
4466
5309
|
const { width, height, src } = await pngInfo(path, image);
|
|
@@ -4470,22 +5313,22 @@ tool(
|
|
|
4470
5313
|
}
|
|
4471
5314
|
}
|
|
4472
5315
|
);
|
|
4473
|
-
var ReferenceIn =
|
|
4474
|
-
src:
|
|
4475
|
-
opacity:
|
|
4476
|
-
x:
|
|
4477
|
-
y:
|
|
4478
|
-
scale:
|
|
5316
|
+
var ReferenceIn = z3.object({
|
|
5317
|
+
src: z3.string().describe("PNG next to rig.json (relative path)"),
|
|
5318
|
+
opacity: z3.number().min(0).max(1).optional(),
|
|
5319
|
+
x: z3.number().optional(),
|
|
5320
|
+
y: z3.number().optional(),
|
|
5321
|
+
scale: z3.number().positive().optional()
|
|
4479
5322
|
}).strict();
|
|
4480
5323
|
tool(
|
|
4481
5324
|
"rig_set_meta",
|
|
4482
5325
|
"Patch the document's meta: name, canvas width/height (moves nothing \u2014 only the bounds change), notes, and the editor's reference underlay `reference {src, opacity, x, y, scale}` \u2014 a PNG next to rig.json drawn under the canvas to fit parts against; runtime, preview and export ignore it; null removes it.",
|
|
4483
5326
|
{
|
|
4484
5327
|
path: Path,
|
|
4485
|
-
name:
|
|
4486
|
-
width:
|
|
4487
|
-
height:
|
|
4488
|
-
notes:
|
|
5328
|
+
name: z3.string().optional(),
|
|
5329
|
+
width: z3.number().int().positive().optional(),
|
|
5330
|
+
height: z3.number().int().positive().optional(),
|
|
5331
|
+
notes: z3.string().optional(),
|
|
4489
5332
|
reference: ReferenceIn.nullable().optional()
|
|
4490
5333
|
},
|
|
4491
5334
|
async ({ path, ...patch }) => mutate(path, (d) => setMeta(d, patch))
|
|
@@ -4493,7 +5336,7 @@ tool(
|
|
|
4493
5336
|
tool(
|
|
4494
5337
|
"rig_get",
|
|
4495
5338
|
"Read a rig. Compact summary by default; verbose=true adds slot pivot/offset and every track with key counts and value ranges (no asset prompts); full=true returns the JSON.",
|
|
4496
|
-
{ path: Path, verbose:
|
|
5339
|
+
{ path: Path, verbose: z3.boolean().default(false), full: z3.boolean().default(false) },
|
|
4497
5340
|
async ({ path, verbose, full }) => {
|
|
4498
5341
|
try {
|
|
4499
5342
|
const d = await load(path);
|
|
@@ -4506,7 +5349,7 @@ tool(
|
|
|
4506
5349
|
tool(
|
|
4507
5350
|
"rig_get_attachment",
|
|
4508
5351
|
"ONE attachment, in full: the exact `vertices` / `weights` / `uvs` / `triangles` arrays it stores. This is what the raw forms of rig_set_path_attachment and rig_set_mesh want \u2014 they edit a shape in place instead of rebuilding it, and to edit `weights` you have to start from the `weights` that are there. The FIRST LINE is a summary (kind, slot, vertex/anchor counts, skinning, the length a deform `vertices` key must have); everything after the first newline is the attachment object as JSON, ready to hand straight back. rig_get full=true returns the same arrays inside the whole document, which is 3.3 MB on a Spine import against a few KB here.",
|
|
4509
|
-
{ path: Path, id:
|
|
5352
|
+
{ path: Path, id: z3.string().describe("attachment id (rig_get lists them per slot with verbose=true)") },
|
|
4510
5353
|
async ({ path, id }) => {
|
|
4511
5354
|
try {
|
|
4512
5355
|
const d = await load(path);
|
|
@@ -4524,10 +5367,10 @@ tool(
|
|
|
4524
5367
|
"Where every bone actually IS at one moment: world x, y, angle (degrees) and axis lengths, with the bone's own local x, y and rotation, plus each slot's shown attachment and world bounding box. Constraints are applied, so this is what the runtime draws, not what the document stores. Without `animation` it reports the rest pose, and `t` is then an error rather than a number that does nothing. A `t` past the animation's duration is clamped to the end, and the summary line says so. `bones` and `slots` narrow the report \u2014 pass them on a large rig, where the whole report runs to tens of KB. Use it to check an edit landed where you meant instead of reading it off a preview image.",
|
|
4525
5368
|
{
|
|
4526
5369
|
path: Path,
|
|
4527
|
-
animation:
|
|
4528
|
-
t:
|
|
4529
|
-
bones:
|
|
4530
|
-
slots:
|
|
5370
|
+
animation: z3.string().optional(),
|
|
5371
|
+
t: z3.number().min(0).optional(),
|
|
5372
|
+
bones: z3.array(z3.string()).optional(),
|
|
5373
|
+
slots: z3.array(z3.string()).optional()
|
|
4531
5374
|
},
|
|
4532
5375
|
async ({ path, ...opts }) => {
|
|
4533
5376
|
try {
|
|
@@ -4556,11 +5399,11 @@ tool(
|
|
|
4556
5399
|
);
|
|
4557
5400
|
tool(
|
|
4558
5401
|
"rig_check",
|
|
4559
|
-
"Cheap self-critique without rendering: out_of_canvas (slot leaves the canvas, worst time), static_track, loop_seam, deform_flip (lattice folds over itself \u2014 an error), mesh_fold (a skinned/deformed mesh folds over itself: error when \u2265 5 % of the mesh folds, warning below \u2014 a dense mesh bent hard at a joint pinches the odd hairline triangle), weights_sum (skinned mesh vertex weights not summing to 1), patch_offset (a slot sits away from its asset's source bbox), rest_overlap, unused_asset, never_visible, and for constraints: constraint_dead (every mix is 0 at rest and no track lifts one \u2014 the solver skips it), constraint_cycle (the target is a bone the constraint drives, or hangs off one: it reads what it writes \u2014 an error), ik_unreachable (the target sits beyond the chain's reach for over 20 % of an animation, so the limb hangs straight), path_missing (a path constraint whose target slot never shows a path attachment \u2014 an error). Findings are sorted by severity. Run it before spending vision tokens on a preview.",
|
|
4560
|
-
{ path: Path, animation:
|
|
4561
|
-
async ({ path,
|
|
5402
|
+
"Cheap self-critique without rendering: out_of_canvas (slot leaves the canvas, worst time), static_track, loop_seam, deform_flip (lattice folds over itself \u2014 an error), mesh_fold (a skinned/deformed mesh folds over itself: error when \u2265 5 % of the mesh folds, warning below \u2014 a dense mesh bent hard at a joint pinches the odd hairline triangle), weights_sum (skinned mesh vertex weights not summing to 1), patch_offset (a slot sits away from its asset's source bbox), rest_overlap, unused_asset, never_visible, and for constraints: constraint_dead (every mix is 0 at rest and no track lifts one \u2014 the solver skips it), constraint_cycle (the target is a bone the constraint drives, or hangs off one: it reads what it writes \u2014 an error), ik_unreachable (the target sits beyond the chain's reach for over 20 % of an animation, so the limb hangs straight), path_missing (a path constraint whose target slot never shows a path attachment \u2014 an error). Motion diagnostics also cover pass_key_stop, velocity_jump, acceleration_jump, loop_velocity and constraint_order; provide secondaryBones, frontOf slot pairs and named attachment UV landmarks for brief-specific frozen-motion, ordering and swap-registration checks. Findings are sorted by severity. Run it before spending vision tokens on a preview.",
|
|
5403
|
+
{ path: Path, animation: z3.string().optional().describe("limit to one animation (rest pose is always checked)"), fps: z3.number().positive().optional(), deformFps: z3.number().positive().optional(), secondaryBones: z3.array(z3.string()).optional(), frontOf: z3.array(z3.tuple([z3.string(), z3.string()])).optional(), landmarks: z3.record(z3.record(z3.tuple([z3.number(), z3.number()]))).optional(), landmarkTolerance: z3.number().nonnegative().optional() },
|
|
5404
|
+
async ({ path, ...opts }) => {
|
|
4562
5405
|
try {
|
|
4563
|
-
return text(summarize(checkRig(await load(path),
|
|
5406
|
+
return text(summarize(checkRig(await load(path), opts), 100));
|
|
4564
5407
|
} catch (e) {
|
|
4565
5408
|
return fail(e);
|
|
4566
5409
|
}
|
|
@@ -4571,12 +5414,12 @@ tool(
|
|
|
4571
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.",
|
|
4572
5415
|
{
|
|
4573
5416
|
path: Path,
|
|
4574
|
-
json:
|
|
4575
|
-
atlas:
|
|
4576
|
-
image:
|
|
4577
|
-
name:
|
|
4578
|
-
margin:
|
|
4579
|
-
notes:
|
|
5417
|
+
json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
|
|
5418
|
+
atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
|
|
5419
|
+
image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
|
|
5420
|
+
name: z3.string().optional().describe("default: the json file name"),
|
|
5421
|
+
margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
|
|
5422
|
+
notes: z3.string().optional()
|
|
4580
5423
|
},
|
|
4581
5424
|
async ({ path, json, atlas, image, name, margin, notes }) => {
|
|
4582
5425
|
try {
|
|
@@ -4594,13 +5437,13 @@ warnings:
|
|
|
4594
5437
|
tool(
|
|
4595
5438
|
"rig_import_layers",
|
|
4596
5439
|
"Import PNG layers from a directory as assets + slots + bones. Uses layers.json sidecar if present (id, role, parent, z, bbox, joint, swap, bone, slot), else filename convention `03_arm_l.png` \u2192 z=3, id=arm_l, role from known names. Bone hierarchy is inferred from roles (head\u2192neck\u2192torso\u2192hips\u2192root, arms\u2192torso, legs\u2192hips). `swap: true` = patch (closed eyes, mouth) as a hidden slot on the parent's bone; several swap layers with the same `slot` share one slot; `bone` = attach to an existing bone without creating one.",
|
|
4597
|
-
{ path: Path, layersDir:
|
|
5440
|
+
{ path: Path, layersDir: z3.string().describe("absolute dir, or relative to rig.json dir"), relPrefix: z3.string().default("layers").describe("how asset.src refers to the dir, relative to rig.json") },
|
|
4598
5441
|
async ({ path, layersDir, relPrefix }) => mutate(path, (d) => importLayers(d, resolveFrom(path, layersDir), relPrefix), true)
|
|
4599
5442
|
);
|
|
4600
5443
|
tool(
|
|
4601
5444
|
"rig_replace_asset",
|
|
4602
5445
|
"Swap one layer's PNG (e.g. a regenerated part) without rebuilding the rig. Pivots are fractions, so the slot keeps working; the image should keep the same silhouette registration.",
|
|
4603
|
-
{ path: Path, id:
|
|
5446
|
+
{ path: Path, id: z3.string(), file: z3.string().describe("new PNG, absolute or relative to rig.json dir"), prompt: z3.string().optional().describe("provenance: how it was generated") },
|
|
4604
5447
|
async ({ path, id, file, prompt }) => mutate(path, async (d) => {
|
|
4605
5448
|
const { width, height, src } = await pngInfo(path, file);
|
|
4606
5449
|
return replaceAsset(d, id, { src, width, height, prompt, inpainted: true });
|
|
@@ -4609,27 +5452,27 @@ tool(
|
|
|
4609
5452
|
tool(
|
|
4610
5453
|
"rig_layer_prompt",
|
|
4611
5454
|
"Build a parts-first image-generation prompt for one layer from its provenance (asset.origin), the rig's style notes and its bone context. Generate the image with your image tool, then rig_replace_asset.",
|
|
4612
|
-
{ path: Path, id:
|
|
4613
|
-
async ({ path, id }) => {
|
|
5455
|
+
{ path: Path, method: z3.enum(["part", "repair"]).default("part"), removed: z3.array(z3.string()).optional(), id: z3.string() },
|
|
5456
|
+
async ({ path, id, method, removed }) => {
|
|
4614
5457
|
try {
|
|
4615
|
-
return text(layerPrompt(await load(path), id));
|
|
5458
|
+
return text(layerPrompt(await load(path), id, { method, removed }));
|
|
4616
5459
|
} catch (e) {
|
|
4617
5460
|
return fail(e);
|
|
4618
5461
|
}
|
|
4619
5462
|
}
|
|
4620
5463
|
);
|
|
4621
|
-
var Frame =
|
|
4622
|
-
var OriginIn =
|
|
5464
|
+
var Frame = z3.tuple([z3.number().int(), z3.number().int(), z3.number().int(), z3.number().int()]).describe("[x, y, w, h] rectangle in the source image's pixels");
|
|
5465
|
+
var OriginIn = z3.object({ bboxSpace: z3.enum(["source", "document"]).optional(), sourceImage: z3.string().optional(), bbox: Frame.optional(), inpainted: z3.boolean().default(false), prompt: z3.string().optional() }).strict();
|
|
4623
5466
|
tool(
|
|
4624
5467
|
"rig_set_asset",
|
|
4625
5468
|
"Create or patch an asset. `src` = a PNG relative to rig.json (needed to create); `frame` = one rectangle of that PNG (a part split off a sheet \u2014 the runtime, preview, export and editor all cut it at draw time, no crop file); without `frame` the asset is the whole image. Re-framing an asset that regions show keeps the source pixel under each image centre where it is drawn; a mesh keeps its uvs and is named so you can re-trace it. Place it with rig_place_asset.",
|
|
4626
5469
|
{
|
|
4627
5470
|
path: Path,
|
|
4628
|
-
id:
|
|
4629
|
-
src:
|
|
5471
|
+
id: z3.string(),
|
|
5472
|
+
src: z3.string().optional(),
|
|
4630
5473
|
frame: Frame.nullable().optional().describe("null = the whole image again"),
|
|
4631
|
-
width:
|
|
4632
|
-
height:
|
|
5474
|
+
width: z3.number().int().positive().optional(),
|
|
5475
|
+
height: z3.number().int().positive().optional(),
|
|
4633
5476
|
origin: OriginIn.optional()
|
|
4634
5477
|
},
|
|
4635
5478
|
async ({ path, id, src, ...patch }) => mutateWith(path, async (d, say) => {
|
|
@@ -4647,19 +5490,19 @@ tool(
|
|
|
4647
5490
|
tool(
|
|
4648
5491
|
"rig_remove_asset",
|
|
4649
5492
|
"Remove an asset nothing shows any more (refused, with the list, while an attachment still uses it).",
|
|
4650
|
-
{ path: Path, id:
|
|
5493
|
+
{ path: Path, id: z3.string() },
|
|
4651
5494
|
async ({ path, id }) => mutate(path, (d) => removeAsset(d, id))
|
|
4652
5495
|
);
|
|
4653
5496
|
tool(
|
|
4654
5497
|
"rig_place_asset",
|
|
4655
5498
|
"Put an asset on a bone in ONE call: a slot (`slot` or the asset's id; created on `bone` when missing, an existing slot keeps its bone), a region attachment named like the asset with its centre at the DOCUMENT point (x, y), and the slot showing it. z = max + 1 unless given. Then rotate/scale with rig_set_attachment.",
|
|
4656
|
-
{ path: Path, asset:
|
|
5499
|
+
{ path: Path, asset: z3.string(), bone: z3.string(), x: z3.number(), y: z3.number(), slot: z3.string().optional(), z: z3.number().int().optional() },
|
|
4657
5500
|
async ({ path, ...p }) => mutate(path, (d) => placeAsset(d, p), true)
|
|
4658
5501
|
);
|
|
4659
5502
|
tool(
|
|
4660
5503
|
"rig_undo",
|
|
4661
5504
|
"Restore the rig as it was `steps` mutations ago (every mutation snapshots the previous file into .rig-history/ next to rig.json).",
|
|
4662
|
-
{ path: Path, steps:
|
|
5505
|
+
{ path: Path, steps: z3.number().int().min(1).default(1) },
|
|
4663
5506
|
async ({ path, steps }) => {
|
|
4664
5507
|
try {
|
|
4665
5508
|
const doc = parse(JSON.parse(await undo(path, steps)));
|
|
@@ -4688,13 +5531,13 @@ tool(
|
|
|
4688
5531
|
tool(
|
|
4689
5532
|
"rig_diff",
|
|
4690
5533
|
"Structural difference between the current rig and the snapshot `steps` mutations ago: bones/slots/assets added, removed, changed; tracks with key counts and value ranges; preset params.",
|
|
4691
|
-
{ path: Path, steps:
|
|
5534
|
+
{ path: Path, steps: z3.number().int().min(1).default(1) },
|
|
4692
5535
|
async ({ path, steps }) => {
|
|
4693
5536
|
try {
|
|
4694
5537
|
const history = await listHistory(path);
|
|
4695
5538
|
if (!history.length) return text("no history for this rig");
|
|
4696
5539
|
if (steps > history.length) return text(`only ${history.length} snapshot${history.length === 1 ? "" : "s"} available`);
|
|
4697
|
-
const old = RigDocument.parse(JSON.parse(await
|
|
5540
|
+
const old = RigDocument.parse(JSON.parse(await readFile9(history[history.length - steps], "utf8")));
|
|
4698
5541
|
const lines = diffDocs(old, await load(path));
|
|
4699
5542
|
return text(lines.length ? lines.join("\n") : "no differences");
|
|
4700
5543
|
} catch (e) {
|
|
@@ -4702,19 +5545,19 @@ tool(
|
|
|
4702
5545
|
}
|
|
4703
5546
|
}
|
|
4704
5547
|
);
|
|
4705
|
-
var TransformPatch =
|
|
5548
|
+
var TransformPatch = z3.object({ x: z3.number(), y: z3.number(), rotation: z3.number(), scaleX: z3.number(), scaleY: z3.number(), shearX: z3.number(), shearY: z3.number() }).partial();
|
|
4706
5549
|
var BoneFields = {
|
|
4707
|
-
parent:
|
|
4708
|
-
role:
|
|
4709
|
-
length:
|
|
4710
|
-
inherit:
|
|
4711
|
-
color:
|
|
5550
|
+
parent: z3.string().optional(),
|
|
5551
|
+
role: z3.string().optional(),
|
|
5552
|
+
length: z3.number().optional().describe("px to the far joint; editor rotation handle, weight_shift leg length"),
|
|
5553
|
+
inherit: z3.enum(["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"]).optional(),
|
|
5554
|
+
color: z3.string().optional().describe("editor colour"),
|
|
4712
5555
|
transform: TransformPatch.optional()
|
|
4713
5556
|
};
|
|
4714
5557
|
tool(
|
|
4715
5558
|
"rig_add_bone",
|
|
4716
5559
|
"Add a bone. transform is LOCAL to parent (px, degrees CW). Omit parent \u2192 root. `bind` moves those slots onto the new bone without moving their images (the editor's B tool with a part selected).",
|
|
4717
|
-
{ path: Path, id:
|
|
5560
|
+
{ path: Path, id: z3.string(), ...BoneFields, bind: z3.array(z3.string()).optional().describe("slot ids to re-bind onto the new bone, world placement kept") },
|
|
4718
5561
|
async ({ path, bind, ...bone }) => mutateWith(path, (d, say) => {
|
|
4719
5562
|
let next = addBone(d, bone);
|
|
4720
5563
|
for (const s2 of bind ?? []) {
|
|
@@ -4728,13 +5571,13 @@ tool(
|
|
|
4728
5571
|
tool(
|
|
4729
5572
|
"rig_update_bone",
|
|
4730
5573
|
"Patch bone fields (parent, role, length, inherit, color, transform \u2014 partial, merged). parent=null detaches the bone into a root of its own.",
|
|
4731
|
-
{ path: Path, id:
|
|
5574
|
+
{ path: Path, id: z3.string(), ...BoneFields, parent: z3.string().nullable().optional().describe("new parent bone; null = make it a root bone (a rig may have several)") },
|
|
4732
5575
|
async ({ path, id, ...patch }) => mutate(path, (d) => updateBone(d, id, patch))
|
|
4733
5576
|
);
|
|
4734
5577
|
tool(
|
|
4735
5578
|
"rig_remove_bone",
|
|
4736
5579
|
"Remove a bone; children and slots re-parent to its parent.",
|
|
4737
|
-
{ path: Path, id:
|
|
5580
|
+
{ path: Path, id: z3.string() },
|
|
4738
5581
|
async ({ path, id }) => mutateWith(path, (d, say) => {
|
|
4739
5582
|
const parent = d.bones.find((b) => b.id === id)?.parent;
|
|
4740
5583
|
const slots = d.slots.filter((s2) => s2.bone === id).map((s2) => s2.id);
|
|
@@ -4745,18 +5588,19 @@ tool(
|
|
|
4745
5588
|
);
|
|
4746
5589
|
tool(
|
|
4747
5590
|
"rig_set_slot",
|
|
4748
|
-
"Create or patch a slot (a draw layer on a bone). attachment = id of one of its attachments, null hides it (swap slots). blend=add for glow/fire layers painted on black; color is #rrggbbaa tint. Changing `bone` on an existing slot KEEPS its images where they are on the document (placements are re-expressed in the new bone's rest frame; weighted meshes need nothing).",
|
|
5591
|
+
"Create or patch a slot (a draw layer on a bone). attachment = id of one of its attachments, null hides it (swap slots). blend=add for glow/fire layers painted on black; color is #rrggbbaa tint. clip names a closed path attachment used as a moving mask; null removes it. Changing `bone` on an existing slot KEEPS its images where they are on the document (placements are re-expressed in the new bone's rest frame; weighted meshes need nothing).",
|
|
4749
5592
|
{
|
|
4750
5593
|
path: Path,
|
|
4751
|
-
id:
|
|
4752
|
-
bone:
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
5594
|
+
id: z3.string(),
|
|
5595
|
+
bone: z3.string().optional(),
|
|
5596
|
+
clip: z3.string().nullable().optional(),
|
|
5597
|
+
attachment: z3.string().nullable().optional(),
|
|
5598
|
+
z: z3.number().int().optional(),
|
|
5599
|
+
alpha: z3.number().optional(),
|
|
5600
|
+
blend: z3.enum(["normal", "add", "multiply", "screen"]).optional(),
|
|
5601
|
+
color: z3.string().optional()
|
|
4758
5602
|
},
|
|
4759
|
-
async ({ path, attachment, bone, ...slot }) => mutateWith(path, (d, say) => {
|
|
5603
|
+
async ({ path, attachment, bone, clip, ...slot }) => mutateWith(path, (d, say) => {
|
|
4760
5604
|
let next = d;
|
|
4761
5605
|
const cur = d.slots.find((s2) => s2.id === slot.id);
|
|
4762
5606
|
if (cur && bone !== void 0 && bone !== cur.bone) {
|
|
@@ -4764,7 +5608,7 @@ tool(
|
|
|
4764
5608
|
next = r.doc;
|
|
4765
5609
|
if (r.approx > 0.01) say(`slot "${slot.id}" re-bound to "${bone}" approximately: a region corner moved ${r.approx} px (shear or non-uniform scale between the bones)`);
|
|
4766
5610
|
}
|
|
4767
|
-
return setSlot(next, { ...slot, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
|
|
5611
|
+
return setSlot(next, { ...slot, ...clip === void 0 ? {} : { clip: clip ?? void 0 }, ...bone !== void 0 ? { bone } : {}, ...attachment === void 0 ? {} : { attachment: attachment ?? void 0 } });
|
|
4768
5612
|
})
|
|
4769
5613
|
);
|
|
4770
5614
|
tool(
|
|
@@ -4772,21 +5616,21 @@ tool(
|
|
|
4772
5616
|
"Create or patch a REGION attachment: an image (asset) placed in a slot. x/y = the image CENTRE in the slot bone's space, rotation in degrees, scale; fields are merged. A slot can hold several attachments (swap states); set slot.attachment to show one. Meshes come from rig_mesh_from_image (trace this region's image) or rig_import_spine.",
|
|
4773
5617
|
{
|
|
4774
5618
|
path: Path,
|
|
4775
|
-
id:
|
|
4776
|
-
slot:
|
|
4777
|
-
asset:
|
|
4778
|
-
x:
|
|
4779
|
-
y:
|
|
4780
|
-
rotation:
|
|
4781
|
-
scaleX:
|
|
4782
|
-
scaleY:
|
|
4783
|
-
width:
|
|
4784
|
-
height:
|
|
5619
|
+
id: z3.string(),
|
|
5620
|
+
slot: z3.string().optional(),
|
|
5621
|
+
asset: z3.string().optional(),
|
|
5622
|
+
x: z3.number().optional(),
|
|
5623
|
+
y: z3.number().optional(),
|
|
5624
|
+
rotation: z3.number().optional(),
|
|
5625
|
+
scaleX: z3.number().optional(),
|
|
5626
|
+
scaleY: z3.number().optional(),
|
|
5627
|
+
width: z3.number().optional(),
|
|
5628
|
+
height: z3.number().optional()
|
|
4785
5629
|
},
|
|
4786
5630
|
async ({ path, ...att }) => mutate(path, (d) => setAttachment(d, att))
|
|
4787
5631
|
);
|
|
4788
|
-
var Influence2 =
|
|
4789
|
-
var WeightsIn =
|
|
5632
|
+
var Influence2 = z3.object({ bone: z3.string(), x: z3.number(), y: z3.number(), w: z3.number() }).describe("one influence: the vertex in that bone's local space and its weight");
|
|
5633
|
+
var WeightsIn = z3.array(z3.array(Influence2).min(1)).describe("one influence list per vertex, in uv order; weights of a list should sum to 1");
|
|
4790
5634
|
async function mutateWith(path, op, summary = false) {
|
|
4791
5635
|
const lines = [];
|
|
4792
5636
|
const res = await mutate(path, (d) => op(d, (l) => lines.push(l)), summary);
|
|
@@ -4801,14 +5645,15 @@ var meshOf = (doc, id) => {
|
|
|
4801
5645
|
};
|
|
4802
5646
|
tool(
|
|
4803
5647
|
"rig_mesh_from_image",
|
|
4804
|
-
"Turn a REGION attachment into a mesh traced from its PNG's alpha: the outline (pixels with alpha \u2265 threshold, simplified by tolerance px, pushed out by margin px) plus an interior grid every `spacing` px, Delaunay-triangulated. EVERY separate opaque island of the layer is meshed (a closed-eyes overlay is two strokes) \u2014 islands
|
|
5648
|
+
"Turn a REGION attachment into a mesh traced from its PNG's alpha: the outline (pixels with alpha \u2265 threshold, simplified by tolerance px, pushed out by margin px) plus an interior grid every `spacing` px, Delaunay-triangulated. EVERY separate opaque island of the layer is meshed (a closed-eyes overlay is two strokes) \u2014 islands below minIslandRatio (default 5% of the largest), and holes, are skipped; the answer says `islands: N` when there is more than one, and such a mesh reports hull 0. The mesh keeps the region's id, slot, asset and placement, so nothing moves; lattice deform tracks (bulge/bend/sway/squash/p<rc>) on it are dropped (they only apply to regions). Next: rig_auto_weights, then key deform `vertices`.",
|
|
4805
5649
|
{
|
|
4806
5650
|
path: Path,
|
|
4807
|
-
id:
|
|
4808
|
-
spacing:
|
|
4809
|
-
tolerance:
|
|
4810
|
-
margin:
|
|
4811
|
-
|
|
5651
|
+
id: z3.string().describe("attachment id (a region)"),
|
|
5652
|
+
spacing: z3.number().min(1).default(24).describe("interior vertex spacing in image px; smaller = denser mesh"),
|
|
5653
|
+
tolerance: z3.number().nonnegative().default(2).describe("outline simplification in px; 0 keeps every pixel step"),
|
|
5654
|
+
margin: z3.number().default(1).describe("px the outline is pushed outward, so the traced edge keeps the soft pixels"),
|
|
5655
|
+
minIslandRatio: z3.number().min(0).max(1).default(0.05).describe("minimum island area relative to largest; 0 preserves small intentional fragments"),
|
|
5656
|
+
threshold: z3.number().int().min(1).max(255).default(8).describe("alpha value counted as opaque")
|
|
4812
5657
|
},
|
|
4813
5658
|
async ({ path, id, ...opts }) => mutateWith(path, async (d, say) => {
|
|
4814
5659
|
const att = d.attachments.find((a) => a.id === id);
|
|
@@ -4817,15 +5662,15 @@ tool(
|
|
|
4817
5662
|
const asset = d.assets.find((a) => a.id === att.asset);
|
|
4818
5663
|
if (!asset) throw new Error(`unknown asset "${att.asset}"`);
|
|
4819
5664
|
if (asset.rotate || asset.trim) throw new Error(`asset "${asset.id}" is packed into an exported atlas (rotate/trim): run rig_mesh_from_image on the unpacked rig, not an atlas export`);
|
|
4820
|
-
const png = await
|
|
5665
|
+
const png = await readFile9(resolveFrom(path, asset.src)).catch((e) => {
|
|
4821
5666
|
throw new Error(`asset "${asset.id}": cannot read ${asset.src} (${e.code ?? e.message})`);
|
|
4822
5667
|
});
|
|
4823
5668
|
const { spacing, ...trace } = opts;
|
|
4824
5669
|
const mask = alphaMaskFromPng(png, asset.frame);
|
|
4825
5670
|
const islands = traceHulls(mask, trace);
|
|
4826
|
-
const
|
|
4827
|
-
const { doc, droppedTracks } = convertToMesh(d,
|
|
4828
|
-
say(`${id}=mesh(${
|
|
5671
|
+
const mesh2 = meshFromIslands(islands, att, asset, mask, spacing);
|
|
5672
|
+
const { doc, droppedTracks } = convertToMesh(d, mesh2);
|
|
5673
|
+
say(`${id}=mesh(${mesh2.uvs.length / 2}v, ${mesh2.triangles.length / 3}t, hull ${mesh2.hull}${islands.length > 1 ? `, islands: ${islands.length}` : ""})`);
|
|
4829
5674
|
if (droppedTracks.length) say(`dropped lattice deform tracks: ${droppedTracks.join(", ")}`);
|
|
4830
5675
|
return doc;
|
|
4831
5676
|
})
|
|
@@ -4835,10 +5680,10 @@ tool(
|
|
|
4835
5680
|
"Skin a mesh to bones by distance: each vertex takes the `maxInfluences` (default 2) nearest candidate bones, inverse-distance weighted and normalized to 1. Nothing is dropped for being far away, so a vertex far from every joint still blends its two nearest bones instead of snapping to one at weight 1. `radius` scales the falloff \u2014 weight `1 / (distance/radius + 1)\xB2` \u2014 in world px, default 1: a larger radius spreads influence across more bones, a smaller one binds each vertex more tightly to its nearest bone. There is no cutoff at any radius. Default `bones`, from the REST pose: the slot's bone, its parent and its direct children. Replaces the mesh's `vertices` with `weights`, so deform `vertices` keys on it are dropped (their length changes). Check the result with rig_render_preview.",
|
|
4836
5681
|
{
|
|
4837
5682
|
path: Path,
|
|
4838
|
-
id:
|
|
4839
|
-
bones:
|
|
4840
|
-
radius:
|
|
4841
|
-
maxInfluences:
|
|
5683
|
+
id: z3.string().describe("mesh attachment id"),
|
|
5684
|
+
bones: z3.array(z3.string()).min(1).optional().describe("candidate bones; default: the slot's bone + its parent + its children"),
|
|
5685
|
+
radius: z3.number().positive().optional().describe("falloff scale in world px, default 1; larger spreads influence across more bones, smaller binds tighter to the nearest one; no cutoff"),
|
|
5686
|
+
maxInfluences: z3.number().int().min(1).default(2).describe("bones per vertex")
|
|
4842
5687
|
},
|
|
4843
5688
|
async ({ path, id, bones, radius, maxInfluences }) => mutateWith(path, (d, say) => {
|
|
4844
5689
|
const att = meshOf(d, id);
|
|
@@ -4860,12 +5705,12 @@ tool(
|
|
|
4860
5705
|
"Replace a mesh's geometry arrays (only the given ones): `uvs` (2 per vertex, 0..1 in the asset image), `triangles` (3 indices each), `hull` (leading vertices that form the outline), and its rest shape as either `vertices` (2 per vertex, slot-bone local) or `weights` (skinned) \u2014 giving one drops the other. Deform `vertices` keys whose length no longer fits are dropped. For tracing a mesh from the image use rig_mesh_from_image.",
|
|
4861
5706
|
{
|
|
4862
5707
|
path: Path,
|
|
4863
|
-
id:
|
|
4864
|
-
vertices:
|
|
5708
|
+
id: z3.string().describe("mesh attachment id"),
|
|
5709
|
+
vertices: z3.array(z3.number()).optional(),
|
|
4865
5710
|
weights: WeightsIn.optional(),
|
|
4866
|
-
uvs:
|
|
4867
|
-
triangles:
|
|
4868
|
-
hull:
|
|
5711
|
+
uvs: z3.array(z3.number()).optional(),
|
|
5712
|
+
triangles: z3.array(z3.number().int().nonnegative()).optional(),
|
|
5713
|
+
hull: z3.number().int().nonnegative().optional()
|
|
4869
5714
|
},
|
|
4870
5715
|
async ({ path, id, ...patch }) => mutateWith(path, (d, say) => {
|
|
4871
5716
|
const { doc, dropped } = setMesh(d, meshOf(d, id).id, patch);
|
|
@@ -4876,7 +5721,7 @@ tool(
|
|
|
4876
5721
|
tool(
|
|
4877
5722
|
"rig_set_weights",
|
|
4878
5723
|
"Replace a mesh's skinning by hand: one influence list per vertex (uv order), each {bone, x, y, w} holding the vertex in that bone's local space; a list's weights should sum to 1. Drops the mesh's unweighted `vertices` and any deform `vertices` keys whose length no longer fits. rig_auto_weights computes these from bone distance.",
|
|
4879
|
-
{ path: Path, id:
|
|
5724
|
+
{ path: Path, id: z3.string().describe("mesh attachment id"), weights: WeightsIn },
|
|
4880
5725
|
async ({ path, id, weights }) => mutateWith(path, (d, say) => {
|
|
4881
5726
|
const { doc, dropped } = setWeights(d, meshOf(d, id).id, weights);
|
|
4882
5727
|
if (dropped.length) say(`dropped deform tracks: ${dropped.join(", ")}`);
|
|
@@ -4886,19 +5731,19 @@ tool(
|
|
|
4886
5731
|
tool(
|
|
4887
5732
|
"rig_remove_attachment",
|
|
4888
5733
|
"Remove an attachment; slots showing it become hidden, keys naming it become null.",
|
|
4889
|
-
{ path: Path, id:
|
|
5734
|
+
{ path: Path, id: z3.string() },
|
|
4890
5735
|
async ({ path, id }) => mutate(path, (d) => removeAttachment(d, id))
|
|
4891
5736
|
);
|
|
4892
5737
|
tool(
|
|
4893
5738
|
"rig_remove_slot",
|
|
4894
5739
|
"Remove a slot and its tracks.",
|
|
4895
|
-
{ path: Path, id:
|
|
5740
|
+
{ path: Path, id: z3.string() },
|
|
4896
5741
|
async ({ path, id }) => mutate(path, (d) => removeSlot(d, id), true)
|
|
4897
5742
|
);
|
|
4898
5743
|
tool(
|
|
4899
5744
|
"rig_set_draw_order",
|
|
4900
5745
|
"Setup draw order in one call: `order` lists EVERY slot back-to-front (the same order a drawOrder key uses); each slot gets z = its index. For an animated reorder key the drawOrder track with rig_set_keys instead.",
|
|
4901
|
-
{ path: Path, order:
|
|
5746
|
+
{ path: Path, order: z3.array(z3.string()).min(1) },
|
|
4902
5747
|
async ({ path, order }) => mutate(path, (d) => setDrawOrder(d, order))
|
|
4903
5748
|
);
|
|
4904
5749
|
function namesId(v, id) {
|
|
@@ -4910,7 +5755,7 @@ function namesId(v, id) {
|
|
|
4910
5755
|
tool(
|
|
4911
5756
|
"rig_rename",
|
|
4912
5757
|
"Rename a bone, slot, attachment, asset or constraint and rewrite EVERY reference to it (parents, slot bones, chains and targets, mesh/path influences, slot attachments, key values, drawOrder keys, tracks). Roles stay. Templates in presets.json are a separate file and are not rewritten \u2014 re-save them if they name the old id. An animation's own preset.params (rig_apply_preset) is left alone too \u2014 the reply names any that still refer to the old id, since a blind substitution across differently-shaped params would be unsafe.",
|
|
4913
|
-
{ path: Path, kind:
|
|
5758
|
+
{ path: Path, kind: z3.enum(["bone", "slot", "attachment", "asset", "constraint"]), id: z3.string(), to: z3.string() },
|
|
4914
5759
|
async ({ path, kind, id, to }) => mutateWith(path, async (d, say) => {
|
|
4915
5760
|
if (await exists2(libraryFor(path))) say(`note: ${libraryFor(path)} is not rewritten \u2014 templates that name "${id}" by id still do`);
|
|
4916
5761
|
const next = rename2(d, kind, id, to);
|
|
@@ -4920,25 +5765,27 @@ tool(
|
|
|
4920
5765
|
return next;
|
|
4921
5766
|
}, true)
|
|
4922
5767
|
);
|
|
4923
|
-
var ConstraintBones =
|
|
4924
|
-
var ConstraintOrder =
|
|
4925
|
-
var PathMixes = { mixRotate:
|
|
4926
|
-
var Mixes = { ...PathMixes, mixScaleX:
|
|
5768
|
+
var ConstraintBones = z3.array(z3.string()).min(1).optional().describe("bones the constraint drives (replaces the whole list); required when it is created");
|
|
5769
|
+
var ConstraintOrder = z3.number().int().optional().describe("solver order, lowest first (default 0); equal orders keep document order");
|
|
5770
|
+
var PathMixes = { mixRotate: z3.number().optional(), mixX: z3.number().optional(), mixY: z3.number().optional() };
|
|
5771
|
+
var Mixes = { ...PathMixes, mixScaleX: z3.number().optional(), mixScaleY: z3.number().optional(), mixShearY: z3.number().optional() };
|
|
4927
5772
|
var CONSTRAINT_DOC = "Constraints are solved after the bone tree, in `order`, and OVERWRITE the bones they drive: keying such a bone directly does nothing visible \u2014 key the target bone (or this constraint's mix, an ordinary number track) instead. Ids are shared by the three kinds and a constraint's kind is fixed once created; rig_remove_constraint drops it together with its tracks.";
|
|
4928
5773
|
tool(
|
|
4929
5774
|
"rig_set_ik",
|
|
4930
|
-
"Create or patch an IK constraint: bend a chain of
|
|
5775
|
+
"Create or patch an IK constraint: bend a chain of bones so its tip reaches a TARGET BONE \u2014 move the target, the limb follows. `bones` is the chain parent\u2192child (1\u20132 bones: analytic solve; 3+: iterative CCD), `target` is a BONE, normally a marker parented to the root so the limb reaches a place in the world. `bendPositive` picks which way a two-bone chain buckles (knee forward or backward), `softness` eases the last px before full extension, `compress`/`stretch` let the chain shorten/lengthen to reach, `mix` 0..1 fades the whole constraint. Fields are merged, so {id, mix} patches only the mix. To rig a limb from scratch call rig_add_ik_chain instead: it creates the target bone at the current tip and derives `bendPositive` from the pose. " + CONSTRAINT_DOC,
|
|
4931
5776
|
{
|
|
4932
5777
|
path: Path,
|
|
4933
|
-
id:
|
|
5778
|
+
id: z3.string(),
|
|
4934
5779
|
bones: ConstraintBones,
|
|
4935
|
-
target:
|
|
4936
|
-
mix:
|
|
4937
|
-
softness:
|
|
4938
|
-
bendPositive:
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
5780
|
+
target: z3.string().optional().describe("BONE the chain reaches for"),
|
|
5781
|
+
mix: z3.number().optional(),
|
|
5782
|
+
softness: z3.number().optional(),
|
|
5783
|
+
bendPositive: z3.boolean().optional(),
|
|
5784
|
+
iterations: z3.number().int().min(1).max(1e3).optional(),
|
|
5785
|
+
tolerance: z3.number().positive().optional(),
|
|
5786
|
+
compress: z3.boolean().optional(),
|
|
5787
|
+
stretch: z3.boolean().optional(),
|
|
5788
|
+
uniform: z3.boolean().optional(),
|
|
4942
5789
|
order: ConstraintOrder
|
|
4943
5790
|
},
|
|
4944
5791
|
async ({ path, id, ...patch }) => mutate(path, (d) => setConstraint(d, "ik", id, patch))
|
|
@@ -4948,17 +5795,17 @@ tool(
|
|
|
4948
5795
|
"Create or patch a transform constraint: copy a TARGET BONE's transform onto the driven bones, channel by channel (mixRotate/mixX/mixY/mixScaleX/mixScaleY/mixShearY, 0..1, each keyable), with constant offsets (rotation/x/y/scaleX/scaleY/shearY) added on top. `local` copies in the bones' local space instead of world; `relative` ADDS the target's transform to the bone's own instead of replacing it. Use it to make parts follow one control: a cape following the torso, a mirrored limb, a bone that inherits half the rotation. Fields are merged; `bones` and `target` (a BONE) are required when it is created. " + CONSTRAINT_DOC,
|
|
4949
5796
|
{
|
|
4950
5797
|
path: Path,
|
|
4951
|
-
id:
|
|
5798
|
+
id: z3.string(),
|
|
4952
5799
|
bones: ConstraintBones,
|
|
4953
|
-
target:
|
|
4954
|
-
local:
|
|
4955
|
-
relative:
|
|
4956
|
-
rotation:
|
|
4957
|
-
x:
|
|
4958
|
-
y:
|
|
4959
|
-
scaleX:
|
|
4960
|
-
scaleY:
|
|
4961
|
-
shearY:
|
|
5800
|
+
target: z3.string().optional().describe("BONE whose transform is copied"),
|
|
5801
|
+
local: z3.boolean().optional(),
|
|
5802
|
+
relative: z3.boolean().optional(),
|
|
5803
|
+
rotation: z3.number().optional(),
|
|
5804
|
+
x: z3.number().optional(),
|
|
5805
|
+
y: z3.number().optional(),
|
|
5806
|
+
scaleX: z3.number().optional(),
|
|
5807
|
+
scaleY: z3.number().optional(),
|
|
5808
|
+
shearY: z3.number().optional(),
|
|
4962
5809
|
...Mixes,
|
|
4963
5810
|
order: ConstraintOrder
|
|
4964
5811
|
},
|
|
@@ -4969,15 +5816,15 @@ tool(
|
|
|
4969
5816
|
"Create or patch a path constraint: place and orient bones along the curve of a path attachment (a tail following a spline, a chain crawling along a road). `target` is a SLOT whose visible attachment is a path \u2014 build the curve with rig_set_path_attachment, then show it with rig_set_slot {attachment}. positionMode fixed|percent (position in px along the curve, or 0..1 of its length), spacingMode length|fixed|percent|proportional (how the bones are spread along it), rotateMode tangent|chain|chainScale. `position` and `spacing` slide the bones along the curve and are the usual things to key. Fields are merged; `bones` and `target` are required when it is created. " + CONSTRAINT_DOC,
|
|
4970
5817
|
{
|
|
4971
5818
|
path: Path,
|
|
4972
|
-
id:
|
|
5819
|
+
id: z3.string(),
|
|
4973
5820
|
bones: ConstraintBones,
|
|
4974
|
-
target:
|
|
4975
|
-
positionMode:
|
|
4976
|
-
spacingMode:
|
|
4977
|
-
rotateMode:
|
|
4978
|
-
rotation:
|
|
4979
|
-
position:
|
|
4980
|
-
spacing:
|
|
5821
|
+
target: z3.string().optional().describe("SLOT showing a path attachment"),
|
|
5822
|
+
positionMode: z3.enum(["fixed", "percent"]).optional(),
|
|
5823
|
+
spacingMode: z3.enum(["length", "fixed", "percent", "proportional"]).optional(),
|
|
5824
|
+
rotateMode: z3.enum(["tangent", "chain", "chainScale"]).optional(),
|
|
5825
|
+
rotation: z3.number().optional(),
|
|
5826
|
+
position: z3.number().optional(),
|
|
5827
|
+
spacing: z3.number().optional(),
|
|
4981
5828
|
...PathMixes,
|
|
4982
5829
|
order: ConstraintOrder
|
|
4983
5830
|
},
|
|
@@ -4986,7 +5833,7 @@ tool(
|
|
|
4986
5833
|
tool(
|
|
4987
5834
|
"rig_remove_constraint",
|
|
4988
5835
|
"Remove a constraint of any kind (ik / transform / path) by id, together with its animation tracks; the bones it drove fall back to their keyed or rest transforms.",
|
|
4989
|
-
{ path: Path, id:
|
|
5836
|
+
{ path: Path, id: z3.string() },
|
|
4990
5837
|
async ({ path, id }) => mutate(path, (d) => removeConstraint(d, id))
|
|
4991
5838
|
);
|
|
4992
5839
|
tool(
|
|
@@ -4994,13 +5841,14 @@ tool(
|
|
|
4994
5841
|
"Rig a limb in ONE call \u2014 the easy path to IK. Give the chain's LAST bone (shin, forearm): the chain is [parent, bone] when that parent has exactly this one child, otherwise just the bone. The tool creates a target bone (role ik_target) at the chain's current tip AS A CHILD OF THE ROOT BONE \u2014 never inside the chain, where the IK would drag its own target \u2014 and an IK constraint `<bone>_ik` whose `bendPositive` is derived from the CURRENT pose, so adding it does not move the rig. It is solved last (highest order). When the two-bone chain would reach up to the root itself, it falls back to the single bone (which just aims at the target) and says so. Afterwards pose the limb by moving or keying the TARGET bone (or fade the constraint with its mix), not the chain's rotations.",
|
|
4995
5842
|
{
|
|
4996
5843
|
path: Path,
|
|
4997
|
-
bone:
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5844
|
+
bone: z3.string().describe("the chain's tip bone, e.g. shin or forearm; it needs a parent"),
|
|
5845
|
+
bones: z3.array(z3.string()).min(1).optional().describe("explicit parent-to-tip chain for long IK"),
|
|
5846
|
+
target: z3.string().optional().describe("id for the new target bone; default <bone>_ik"),
|
|
5847
|
+
mix: z3.number().default(1),
|
|
5848
|
+
softness: z3.number().default(0)
|
|
5001
5849
|
},
|
|
5002
|
-
async ({ path, bone, target, mix, softness }) => mutateWith(path, (d, say) => {
|
|
5003
|
-
const r = addIkChain(d, bone, { target, mix, softness });
|
|
5850
|
+
async ({ path, bone, target, mix, softness, bones }) => mutateWith(path, (d, say) => {
|
|
5851
|
+
const r = addIkChain(d, bone, { target, mix, softness, bones });
|
|
5004
5852
|
say(`ik "${bone}_ik": chain ${r.chain.join(",")}${r.note ? ` (${r.note})` : ""} \u2192 new target bone "${r.target}" (bend ${r.bendPositive ? "+" : "-"}); move or key "${r.target}" to pose the limb`);
|
|
5005
5853
|
return r.doc;
|
|
5006
5854
|
}, true)
|
|
@@ -5010,12 +5858,12 @@ tool(
|
|
|
5010
5858
|
"Create or replace a PATH attachment. Two forms, exactly one per call (both need at least 2 anchors, 3 when closed): `points` \u2014 anchors in DOCUMENT coordinates \u2014 rebuilds the curve with fresh Catmull-Rom tangents, so it DESTROYS a skinned path's weights and any authored tangents; the form for drawing a path from scratch. `vertices` / `weights` \u2014 raw control points [cPrev, p, cNext] per anchor, in the slot bone's space (or skinned: `weights` gives one influence list per control point, {bone,x,y,w} like rig_set_weights) \u2014 the ONLY form that preserves weights and authored tangents; use it to edit an existing path, and rig_path_anchor to add or remove an anchor without a full replace. Show it with rig_set_slot {id: <slot>, attachment: <id>}, then drive bones along it with rig_set_path. `closed` keeps its current value when omitted on an existing path (false on creation). Deform `vertices` keys whose length no longer fits the new geometry are dropped \u2014 and a `points` rebuild that happens to keep the same count keeps them too, now deforming a DIFFERENT curve, which is the other reason to edit through the raw form. `lengths` are always re-measured, so an imported path's stored values move by up to ~0.5 % even when the shape is byte-identical; that is nothing to act on, because every path here has `constantSpeed: true` (all of ross's do, and no tool can turn it off) and the runtime then measures the posed curve itself and never reads them.",
|
|
5011
5859
|
{
|
|
5012
5860
|
path: Path,
|
|
5013
|
-
id:
|
|
5014
|
-
slot:
|
|
5015
|
-
points:
|
|
5016
|
-
vertices:
|
|
5861
|
+
id: z3.string().describe("path attachment id"),
|
|
5862
|
+
slot: z3.string(),
|
|
5863
|
+
points: z3.array(z3.tuple([z3.number(), z3.number()])).min(2).optional().describe("[[x, y], \u2026] anchors in document space"),
|
|
5864
|
+
vertices: z3.array(z3.number()).optional(),
|
|
5017
5865
|
weights: WeightsIn.optional(),
|
|
5018
|
-
closed:
|
|
5866
|
+
closed: z3.boolean().optional()
|
|
5019
5867
|
},
|
|
5020
5868
|
async ({ path, id, slot, points, vertices, weights, closed }) => mutateWith(path, (d, say) => {
|
|
5021
5869
|
const { doc: next, dropped } = setPathAttachment(d, id, slot, { points, vertices, weights, closed });
|
|
@@ -5030,10 +5878,10 @@ tool(
|
|
|
5030
5878
|
"Insert or remove one anchor of a path attachment. `insert` splits the curve AFTER anchor `index` at `t` (strictly between 0 and 1, default 0.5) using de Casteljau, so the curve keeps its exact shape in EVERY pose, not just at rest, and a weighted path keeps its skinning (each new control point is the c*w-weighted blend of the split segment's influences, which is an identity in the bone matrices). It also keeps the attachment's DEFORM tracks: the same coefficients blend the four source control points' offsets, so the ANIMATED curve is unchanged too \u2014 an insert loses nothing. `remove` drops the anchor's three control points, which does change the shape, and with it any deform key whose length no longer fits (reported in the reply).",
|
|
5031
5879
|
{
|
|
5032
5880
|
path: Path,
|
|
5033
|
-
id:
|
|
5034
|
-
op:
|
|
5035
|
-
index:
|
|
5036
|
-
t:
|
|
5881
|
+
id: z3.string().describe("path attachment id"),
|
|
5882
|
+
op: z3.enum(["insert", "remove"]),
|
|
5883
|
+
index: z3.number().int().nonnegative().describe("anchor index, 0-based (anchors are the vertices at 1, 4, 7, \u2026)"),
|
|
5884
|
+
t: z3.number().gt(0).lt(1).optional().describe("insert only: where in the segment, strictly between 0 and 1 (an endpoint is the anchor that is already there), default 0.5")
|
|
5037
5885
|
},
|
|
5038
5886
|
async ({ path, id, op, index, t }) => mutateWith(path, (d, say) => {
|
|
5039
5887
|
const { doc, dropped } = pathAnchorOp(d, id, op, index, t);
|
|
@@ -5047,9 +5895,9 @@ tool(
|
|
|
5047
5895
|
"Generate an animation from a parametric preset (replaces the animation with the same id). Built-in presets bind to bones by `role`; template presets (rig_save_as_preset) come from the library and match bones by role, then id. Re-tune by re-applying with different params.\nCharacter: idle_breathing (bulges the torso image, neck/waist pinned), weight_shift (feet stay planted), win_bounce, anticipation_shake, blink, mouth_talk. Symbol: symbol_idle, symbol_win, dim (state pose, use as base).\n" + presetHelp,
|
|
5048
5896
|
{
|
|
5049
5897
|
path: Path,
|
|
5050
|
-
preset:
|
|
5051
|
-
params:
|
|
5052
|
-
id:
|
|
5898
|
+
preset: z3.string().describe("built-in name or a template name from the library"),
|
|
5899
|
+
params: z3.record(z3.unknown()).default({}).describe("partial override of defaults"),
|
|
5900
|
+
id: z3.string().optional().describe("animation id for templates (default: template name)"),
|
|
5053
5901
|
library: Library
|
|
5054
5902
|
},
|
|
5055
5903
|
async ({ path, preset, params, id, library }) => mutate(path, async (d) => {
|
|
@@ -5062,7 +5910,7 @@ tool(
|
|
|
5062
5910
|
tool(
|
|
5063
5911
|
"rig_save_as_preset",
|
|
5064
5912
|
'Turn a finished animation into a reusable template: tracks stored relative to rest and keyed by bone role, params amplitude (all relative values), speed (time) and optional named groups of source tracks (`{"turn": ["bone.root.rotation"]}`) that become their own multipliers. Apply on any rig with rig_apply_preset.',
|
|
5065
|
-
{ path: Path, animation:
|
|
5913
|
+
{ path: Path, animation: z3.string(), name: z3.string().describe("snake_case template name"), groups: z3.record(z3.array(z3.string())).optional(), library: Library },
|
|
5066
5914
|
async ({ path, animation, name, groups, library }) => {
|
|
5067
5915
|
try {
|
|
5068
5916
|
const tpl = extractTemplate(await load(path), animation, name, groups);
|
|
@@ -5094,7 +5942,7 @@ ${tpls.length ? tpls.map(describeTemplate).join("\n") : " none"}`);
|
|
|
5094
5942
|
tool(
|
|
5095
5943
|
"rig_get_preset",
|
|
5096
5944
|
"One preset as JSON: {builtin: true, name, defaults} for a built-in, or the library template (tracks, params). For editors that preview parameter changes locally.",
|
|
5097
|
-
{ path: Path, preset:
|
|
5945
|
+
{ path: Path, preset: z3.string(), library: Library },
|
|
5098
5946
|
async ({ path, preset, library }) => {
|
|
5099
5947
|
try {
|
|
5100
5948
|
if (preset in presets) {
|
|
@@ -5112,9 +5960,9 @@ tool(
|
|
|
5112
5960
|
tool(
|
|
5113
5961
|
"rig_set_keys",
|
|
5114
5962
|
"Set the keys of ONE track (creates the animation if missing; manual keys drop the animation's preset link). Replaces all keys unless merge=true, which replaces only keys at the given times and keeps the rest. duration/loop are optional and persist once set. " + KEY_DOC,
|
|
5115
|
-
{ path: Path, animation:
|
|
5116
|
-
async ({ path, animation, target, id, prop, keys, merge, duration, loop }) => mutate(path, (d) => {
|
|
5117
|
-
let n = setKeys(d, animation, { target, id, prop }, keys.map(roundKey), duration, { merge });
|
|
5963
|
+
{ path: Path, animation: z3.string(), ...TrackRef, keys: z3.array(KeyIn), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
|
|
5964
|
+
async ({ path, animation, target, id, prop, interpolation, keys, merge, duration, loop }) => mutate(path, (d) => {
|
|
5965
|
+
let n = setKeys(d, animation, { target, id, prop, interpolation }, keys.map(roundKey), duration, { merge });
|
|
5118
5966
|
if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
|
|
5119
5967
|
return n;
|
|
5120
5968
|
})
|
|
@@ -5122,10 +5970,10 @@ tool(
|
|
|
5122
5970
|
tool(
|
|
5123
5971
|
"rig_set_tracks",
|
|
5124
5972
|
"Set several tracks of one animation in a single call (same semantics as rig_set_keys for each). " + KEY_DOC,
|
|
5125
|
-
{ path: Path, animation:
|
|
5973
|
+
{ path: Path, animation: z3.string(), tracks: z3.array(z3.object({ ...TrackRef, keys: z3.array(KeyIn) })).min(1), merge: z3.boolean().default(false), duration: z3.number().optional(), loop: z3.boolean().optional() },
|
|
5126
5974
|
async ({ path, animation, tracks, merge, duration, loop }) => mutate(path, (d) => {
|
|
5127
5975
|
let n = d;
|
|
5128
|
-
for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop }, t.keys.map(roundKey), duration, { merge });
|
|
5976
|
+
for (const t of tracks) n = setKeys(n, animation, { target: t.target, id: t.id, prop: t.prop, interpolation: t.interpolation }, t.keys.map(roundKey), duration, { merge });
|
|
5129
5977
|
if (loop !== void 0) n = { ...n, animations: n.animations.map((a) => a.id === animation ? { ...a, loop } : a) };
|
|
5130
5978
|
return n;
|
|
5131
5979
|
})
|
|
@@ -5135,11 +5983,11 @@ tool(
|
|
|
5135
5983
|
"Put a bone at a point in DOCUMENT coordinates \u2014 the same coordinates rig_get_pose reports and rig_render_preview draws. Without `animation` it moves the setup pose; with one it keys x and y at `t`, creating the animation if it does not exist yet (`t` and `ease` without an `animation` are an error, not a no-op). If the bone is driven by a LIVE ik constraint the edit is redirected to that constraint's TARGET BONE, whose origin is then what lands on the point (the answer says so), because writing the bone itself would be overwritten by the solver. For a rig_add_ik_chain limb that target sits at the chain's tip, so AT FULL MIX the tip reaches the point while the named bone only turns \u2014 a goal on the chain's FIRST bone does not move that bone's origin at all, and a partly mixed chain lands the target but leaves the tip short. A live transform or path constraint refuses instead: nothing can be written there, so key its mix to 0 at that time or edit the constraint. The point is converted using the pose AT THAT TIME, so a turned parent is accounted for. The result is re-evaluated before it is saved: if what was written does not actually land on the point (a target a second solver owns, or one parented inside the chain that aims it) the call fails and writes nothing. On success the answer gives the NAMED bone's world origin and tip after the edit.",
|
|
5136
5984
|
{
|
|
5137
5985
|
path: Path,
|
|
5138
|
-
bone:
|
|
5139
|
-
x:
|
|
5140
|
-
y:
|
|
5141
|
-
animation:
|
|
5142
|
-
t:
|
|
5986
|
+
bone: z3.string(),
|
|
5987
|
+
x: z3.number(),
|
|
5988
|
+
y: z3.number(),
|
|
5989
|
+
animation: z3.string().optional(),
|
|
5990
|
+
t: z3.number().min(0).optional(),
|
|
5143
5991
|
ease: Ease.optional()
|
|
5144
5992
|
},
|
|
5145
5993
|
async ({ path, bone, x, y, ...opts }) => mutateWith(path, (d, say) => {
|
|
@@ -5154,11 +6002,11 @@ tool(
|
|
|
5154
6002
|
"Copy the bone tracks of one set of bones onto another, pair by pair, optionally shifted in time and mirrored. `phase` is a fraction of the animation's duration (0.5 = half a cycle later, 1 wraps back to 0). `mirrorX` negates x, rotation, shearX and shearY, which mirrors the motion across the PARENT's y axis \u2014 that is a left/right mirror only when the destination's parent frame is itself the mirror image of the source's, which is not automatic: on a rig whose shoulders are not conjugate frames a mirrored arm copy points the SAME way in the document. Destination tracks are replaced per prop, not merged. Data can be lost: a track keyed at both 0 and `duration` with DIFFERENT values loses the segment into that last key when `phase` shifts it, and a non-looping animation has the segment between its last and first key split \u2014 both are reported in the answer. This is how the second leg of a cycle is made from the first \u2014 it is an operation, not a preset: nothing is stored or reused.",
|
|
5155
6003
|
{
|
|
5156
6004
|
path: Path,
|
|
5157
|
-
animation:
|
|
5158
|
-
from:
|
|
5159
|
-
to:
|
|
5160
|
-
phase:
|
|
5161
|
-
mirrorX:
|
|
6005
|
+
animation: z3.string(),
|
|
6006
|
+
from: z3.array(z3.string()).min(1),
|
|
6007
|
+
to: z3.array(z3.string()).min(1),
|
|
6008
|
+
phase: z3.number().min(0).max(1).default(0),
|
|
6009
|
+
mirrorX: z3.boolean().default(false)
|
|
5162
6010
|
},
|
|
5163
6011
|
async ({ path, animation, from, to, phase, mirrorX }) => mutateWith(path, (d, say) => {
|
|
5164
6012
|
const { doc, copied, empty, lossy } = copyTracks(d, animation, from, to, { phase, mirrorX });
|
|
@@ -5172,7 +6020,7 @@ tool(
|
|
|
5172
6020
|
tool(
|
|
5173
6021
|
"rig_adjust_track",
|
|
5174
6022
|
"Re-tune an existing track without retyping keys: amplitude scales its deviation from the rest value (0.5 = half as strong, 2 = twice; scale props as ratios), offset adds a constant, timeScale stretches its key times (duration grows to fit). Drops the preset link.",
|
|
5175
|
-
{ path: Path, animation:
|
|
6023
|
+
{ path: Path, animation: z3.string(), ...TrackRef, amplitude: z3.number().optional(), offset: z3.number().optional(), timeScale: z3.number().positive().optional() },
|
|
5176
6024
|
async ({ path, animation, target, id, prop, ...adj }) => mutate(path, (d) => adjustTrack(d, animation, { target, id, prop }, adj))
|
|
5177
6025
|
);
|
|
5178
6026
|
tool(
|
|
@@ -5180,47 +6028,108 @@ tool(
|
|
|
5180
6028
|
"Animation properties without touching keys: duration (must cover the last key/event), loop, events (full list), rename. Creates the animation if missing.",
|
|
5181
6029
|
{
|
|
5182
6030
|
path: Path,
|
|
5183
|
-
id:
|
|
5184
|
-
duration:
|
|
5185
|
-
loop:
|
|
5186
|
-
events:
|
|
5187
|
-
rename:
|
|
6031
|
+
id: z3.string(),
|
|
6032
|
+
duration: z3.number().positive().optional(),
|
|
6033
|
+
loop: z3.boolean().optional(),
|
|
6034
|
+
events: z3.array(z3.object({ t: z3.number().nonnegative(), name: z3.string(), payload: z3.record(z3.unknown()).optional() })).optional(),
|
|
6035
|
+
rename: z3.string().optional()
|
|
5188
6036
|
},
|
|
5189
6037
|
async ({ path, id, ...patch }) => mutate(path, (d) => setAnimation(d, id, patch))
|
|
5190
6038
|
);
|
|
5191
6039
|
tool(
|
|
5192
6040
|
"rig_remove_keys",
|
|
5193
6041
|
"Remove the keys of one track at the given times; an emptied track is dropped. Drops the preset link.",
|
|
5194
|
-
{ path: Path, animation:
|
|
6042
|
+
{ path: Path, animation: z3.string(), ...TrackRef, times: z3.array(z3.number()).min(1) },
|
|
5195
6043
|
async ({ path, animation, target, id, prop, times: times2 }) => mutate(path, (d) => removeKeys(d, animation, { target, id, prop }, times2))
|
|
5196
6044
|
);
|
|
5197
6045
|
tool(
|
|
5198
6046
|
"rig_remove_animation",
|
|
5199
6047
|
"Delete an animation.",
|
|
5200
|
-
{ path: Path, id:
|
|
6048
|
+
{ path: Path, id: z3.string() },
|
|
5201
6049
|
async ({ path, id }) => mutate(path, (d) => removeAnimation(d, id), true)
|
|
5202
6050
|
);
|
|
6051
|
+
tool(
|
|
6052
|
+
"rig_order_constraints",
|
|
6053
|
+
"Sort constraints by what they read and write, including target ancestors and skinned paths. Cycles are refused.",
|
|
6054
|
+
{ path: Path },
|
|
6055
|
+
async ({ path }) => mutate(path, orderConstraints)
|
|
6056
|
+
);
|
|
6057
|
+
tool(
|
|
6058
|
+
"rig_gradient_weights",
|
|
6059
|
+
"Skin a mesh with a gradient along ordered chain joints, preserving its setup geometry.",
|
|
6060
|
+
{ path: Path, id: z3.string(), bones: z3.array(z3.string()).min(2) },
|
|
6061
|
+
async ({ path, id, bones }) => mutate(path, (d) => gradientWeights(d, id, bones))
|
|
6062
|
+
);
|
|
6063
|
+
tool(
|
|
6064
|
+
"rig_smooth_weights",
|
|
6065
|
+
"Smooth weights over triangle neighbours. Locked vertex indices keep their weights. Deform tracks are dropped if the influence layout changes.",
|
|
6066
|
+
{ path: Path, id: z3.string(), iterations: z3.number().int().min(1).max(100).default(3), strength: z3.number().min(0).max(1).default(0.5), locked: z3.array(z3.number().int().nonnegative()).default([]) },
|
|
6067
|
+
async ({ path, id, iterations, strength, locked }) => mutate(path, (d) => smoothWeights(d, id, iterations, strength, locked))
|
|
6068
|
+
);
|
|
6069
|
+
tool(
|
|
6070
|
+
"rig_pin_weights",
|
|
6071
|
+
"Bind selected mesh vertices rigidly to one bone. Select by indices or document-space rectangle, e.g. the solid body of a prop while leaving its strap flexible.",
|
|
6072
|
+
{ path: Path, id: z3.string(), bone: z3.string(), vertices: z3.array(z3.number().int().nonnegative()).optional(), region: z3.tuple([z3.number(), z3.number(), z3.number().positive(), z3.number().positive()]).optional() },
|
|
6073
|
+
async ({ path, id, bone, vertices, region }) => mutate(path, (d) => pinWeights(d, id, bone, vertices, region))
|
|
6074
|
+
);
|
|
6075
|
+
tool(
|
|
6076
|
+
"rig_attach_to_bone",
|
|
6077
|
+
"At an animation time, switch a prop bone to follow a target bone without changing its evaluated world pose. Creates keyed transform constraints and computes offsets after IK. Author successive contacts in time order; key hand attachment swaps at the same time.",
|
|
6078
|
+
{ path: Path, animation: z3.string(), bone: z3.string(), target: z3.string(), t: z3.number().nonnegative() },
|
|
6079
|
+
async ({ path, animation, bone, target, t }) => mutate(path, (d) => attachToBone(d, animation, bone, target, t))
|
|
6080
|
+
);
|
|
6081
|
+
tool(
|
|
6082
|
+
"rig_bake_secondary",
|
|
6083
|
+
"Bake velocity-driven damped spring rotations into editable PCHIP tracks. Uses evaluated world rotation or x/y motion (including IK). A looping driver must close; loop state is settled before baking and starts at setup within maxAngle. No runtime physics is required.",
|
|
6084
|
+
{ path: Path, animation: z3.string(), driver: z3.string(), driverProp: z3.enum(["rotation", "x", "y"]).default("rotation"), bones: z3.array(z3.string()).min(1), frequency: z3.number().positive().default(2.5), damping: z3.number().positive().default(0.65), gain: z3.number().default(0.2), fps: z3.number().int().min(1).max(240).default(60), maxAngle: z3.number().positive().default(30) },
|
|
6085
|
+
async ({ path, animation, ...opts }) => mutate(path, (d) => bakeSecondary(d, animation, opts))
|
|
6086
|
+
);
|
|
5203
6087
|
tool(
|
|
5204
6088
|
"rig_render_preview",
|
|
5205
6089
|
"Render frames of an animation (or the rest pose) as a contact-sheet PNG, plus the rig_check findings for that animation (or a positive one-liner). Use it after every change to SEE the result. overlayBones=true draws the skeleton with ids; onionSkin=true ghosts the previous frame of the sheet under each frame (the first frame has no ghost); region=[x,y,w,h] in document px crops to a detail (a face) so small motion is legible at low token cost.",
|
|
5206
6090
|
{
|
|
5207
6091
|
path: Path,
|
|
5208
|
-
animation:
|
|
5209
|
-
fps:
|
|
5210
|
-
count:
|
|
5211
|
-
times:
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
6092
|
+
animation: z3.string().optional(),
|
|
6093
|
+
fps: z3.number().default(8),
|
|
6094
|
+
count: z3.number().int().optional(),
|
|
6095
|
+
times: z3.array(z3.number()).optional(),
|
|
6096
|
+
transparent: z3.boolean().default(false),
|
|
6097
|
+
background: z3.string().optional(),
|
|
6098
|
+
weightBone: z3.string().optional(),
|
|
6099
|
+
debugFillMasks: z3.record(z3.string()).optional(),
|
|
6100
|
+
framesDir: z3.string().optional(),
|
|
6101
|
+
overlayBones: z3.boolean().default(false),
|
|
6102
|
+
onionSkin: z3.boolean().default(false),
|
|
6103
|
+
region: z3.tuple([z3.number(), z3.number(), z3.number(), z3.number()]).optional(),
|
|
6104
|
+
scale: z3.number().default(0.5).describe("downscale to save tokens"),
|
|
6105
|
+
sheetColumns: z3.number().int().default(4),
|
|
6106
|
+
saveTo: z3.string().optional().describe("also write sheet PNG here")
|
|
5218
6107
|
},
|
|
5219
|
-
async ({ path, saveTo, ...opts }) => {
|
|
6108
|
+
async ({ path, saveTo, framesDir, ...opts }) => {
|
|
5220
6109
|
try {
|
|
5221
|
-
const
|
|
5222
|
-
const
|
|
5223
|
-
|
|
6110
|
+
const raw = await readFile9(path, "utf8");
|
|
6111
|
+
const doc = parse(JSON.parse(raw));
|
|
6112
|
+
const r = await renderPreview(doc, { assetsDir: dirname8(path), ...opts });
|
|
6113
|
+
if (saveTo) await writeFile7(saveTo, r.sheet);
|
|
6114
|
+
if (framesDir) {
|
|
6115
|
+
const { mkdir: mkdir7 } = await import("node:fs/promises");
|
|
6116
|
+
await mkdir7(framesDir, { recursive: true });
|
|
6117
|
+
const frames = r.frames.map((frame, i) => ({ file: `${String(i).padStart(6, "0")}.png`, sha256: sha256(frame) }));
|
|
6118
|
+
for (let i = 0; i < frames.length; i++) await writeFile7(join9(framesDir, frames[i].file), r.frames[i]);
|
|
6119
|
+
const manifest = {
|
|
6120
|
+
version: 1,
|
|
6121
|
+
rigFile: sha256(raw),
|
|
6122
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6123
|
+
identity: r.identity,
|
|
6124
|
+
animation: opts.animation ?? null,
|
|
6125
|
+
transparent: opts.transparent,
|
|
6126
|
+
scale: opts.scale,
|
|
6127
|
+
times: r.times,
|
|
6128
|
+
frames,
|
|
6129
|
+
render: { background: opts.background, overlayBones: opts.overlayBones, onionSkin: opts.onionSkin, weightBone: opts.weightBone, debugFillMasks: opts.debugFillMasks ?? {}, region: opts.region }
|
|
6130
|
+
};
|
|
6131
|
+
await writeFile7(join9(framesDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
6132
|
+
}
|
|
5224
6133
|
return { text: `${r.times.length} frames @ ${r.width}x${r.height}, t=[${r.times.join(", ")}]
|
|
5225
6134
|
${previewCheck(doc, opts.animation)}`, image: r.sheet };
|
|
5226
6135
|
} catch (e) {
|
|
@@ -5231,14 +6140,55 @@ ${previewCheck(doc, opts.animation)}`, image: r.sheet };
|
|
|
5231
6140
|
tool(
|
|
5232
6141
|
"rig_export",
|
|
5233
6142
|
"Export for a game: pack all PNG layers into <outDir>/atlas.png and write <outDir>/rig.json whose assets point into it (asset.frame). One texture per rig instead of one per layer.",
|
|
5234
|
-
{ path: Path, outDir:
|
|
6143
|
+
{ path: Path, outDir: z3.string().describe("absolute, or relative to rig.json dir"), padding: z3.number().int().default(2), maxSize: z3.number().int().default(4096) },
|
|
5235
6144
|
async ({ path, outDir, padding, maxSize }) => {
|
|
5236
6145
|
try {
|
|
5237
6146
|
const out = resolveFrom(path, outDir);
|
|
5238
|
-
const doc = await buildAtlas(await load(path),
|
|
5239
|
-
const [w, h] = pngSize(await
|
|
5240
|
-
return text(`\u2192 ${
|
|
5241
|
-
\u2192 ${
|
|
6147
|
+
const doc = await buildAtlas(await load(path), dirname8(path), out, { padding, maxSize });
|
|
6148
|
+
const [w, h] = pngSize(await readFile9(join9(out, "atlas.png")));
|
|
6149
|
+
return text(`\u2192 ${join9(out, "rig.json")}
|
|
6150
|
+
\u2192 ${join9(out, "atlas.png")} (${w}x${h}, ${doc.assets.length} frames)`);
|
|
6151
|
+
} catch (e) {
|
|
6152
|
+
return fail(e);
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
6155
|
+
);
|
|
6156
|
+
tool(
|
|
6157
|
+
"rig_export_video",
|
|
6158
|
+
"Export runtime playback to MP4 (60 fps by default). Requires ffmpeg. Duration is rounded to a whole frame and reported. Output has an opaque presentation background; use transparent preview frames for alpha QA.",
|
|
6159
|
+
{ path: Path, animation: z3.string(), outFile: z3.string(), fps: z3.number().int().min(1).max(240).default(60), scale: z3.number().positive().default(1), background: z3.string().optional(), weightBone: z3.string().optional(), debugFillMasks: z3.record(z3.string()).optional() },
|
|
6160
|
+
async ({ path, outFile, ...opts }) => {
|
|
6161
|
+
try {
|
|
6162
|
+
const result = await exportVideo(await load(path), { assetsDir: dirname8(path), outFile: resolveFrom(path, outFile), ...opts });
|
|
6163
|
+
return text(`${resolveFrom(path, outFile)}: ${result.frames} frames, ${result.fps} fps, ${result.duration}s`);
|
|
6164
|
+
} catch (e) {
|
|
6165
|
+
return fail(e);
|
|
6166
|
+
}
|
|
6167
|
+
}
|
|
6168
|
+
);
|
|
6169
|
+
tool(
|
|
6170
|
+
"rig_verify_render",
|
|
6171
|
+
"Fail if render inputs or outputs changed, frames are absent/extra, or the capture does not cover the explicit expected times. Run before image gates; a manifest alone is not an art-quality pass.",
|
|
6172
|
+
{ path: Path, framesDir: z3.string(), animation: z3.string().optional(), times: z3.array(z3.number().nonnegative()).min(1), transparent: z3.boolean().optional() },
|
|
6173
|
+
async ({ path, framesDir, ...expected }) => {
|
|
6174
|
+
try {
|
|
6175
|
+
const here = dirname8(fileURLToPath2(import.meta.url));
|
|
6176
|
+
const source = join9(here, "preview/harness.js"), bundled = join9(here, "harness.js");
|
|
6177
|
+
const manifest = await verifyRenderManifest(path, framesDir, { ...expected, renderer: await exists2(source) ? source : bundled });
|
|
6178
|
+
return text(`verified ${manifest.frames.length}/${expected.times.length} frames; inputs, renderer and frame hashes match`);
|
|
6179
|
+
} catch (e) {
|
|
6180
|
+
return fail(e);
|
|
6181
|
+
}
|
|
6182
|
+
}
|
|
6183
|
+
);
|
|
6184
|
+
tool(
|
|
6185
|
+
"rig_run_gates",
|
|
6186
|
+
"Run explicit motion, proportion/contact, flash/pose, animation-ID and real AnimationState transition gates. Report sampling, amplitudes, speed/acceleration/jerk and failures; thresholds are supplied by the brief.",
|
|
6187
|
+
{ path: Path, config: GateConfig },
|
|
6188
|
+
async ({ path, config }) => {
|
|
6189
|
+
try {
|
|
6190
|
+
const report = runGates(await load(path), config);
|
|
6191
|
+
return { text: JSON.stringify(report, null, 2), isError: !report.pass };
|
|
5242
6192
|
} catch (e) {
|
|
5243
6193
|
return fail(e);
|
|
5244
6194
|
}
|
|
@@ -5249,7 +6199,7 @@ tool(
|
|
|
5249
6199
|
var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".map": "application/json", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp" };
|
|
5250
6200
|
async function startEditorServer(opts) {
|
|
5251
6201
|
const root3 = resolve2(opts.root);
|
|
5252
|
-
const assets = resolve2(opts.assets ??
|
|
6202
|
+
const assets = resolve2(opts.assets ?? fileURLToPath3(new URL("../..", import.meta.url)));
|
|
5253
6203
|
const inside = (rel) => {
|
|
5254
6204
|
const abs = resolve2(root3, rel);
|
|
5255
6205
|
return abs === root3 || abs.startsWith(root3 + sep) ? abs : void 0;
|
|
@@ -5262,7 +6212,7 @@ async function startEditorServer(opts) {
|
|
|
5262
6212
|
return "missing";
|
|
5263
6213
|
}
|
|
5264
6214
|
};
|
|
5265
|
-
const readDoc = async (abs) => RigDocument.parse(JSON.parse(await
|
|
6215
|
+
const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile10(abs, "utf8")));
|
|
5266
6216
|
const readDocAt = async (abs) => {
|
|
5267
6217
|
let v = await version(abs);
|
|
5268
6218
|
for (let i = 0; i < 3; i++) {
|
|
@@ -5283,7 +6233,7 @@ async function startEditorServer(opts) {
|
|
|
5283
6233
|
const watchers = /* @__PURE__ */ new Map();
|
|
5284
6234
|
function ensureWatch(abs) {
|
|
5285
6235
|
if (watchers.has(abs)) return;
|
|
5286
|
-
const entry = { w: fsWatch(
|
|
6236
|
+
const entry = { w: fsWatch(dirname9(abs), { persistent: false }, (_e, file) => {
|
|
5287
6237
|
if (file && file !== basename5(abs)) return;
|
|
5288
6238
|
schedule();
|
|
5289
6239
|
}), last: "" };
|
|
@@ -5313,16 +6263,16 @@ data: ${JSON.stringify({ version: v })}
|
|
|
5313
6263
|
async function listRigs() {
|
|
5314
6264
|
const out = [];
|
|
5315
6265
|
const walk = async (dir) => {
|
|
5316
|
-
for (const e of await
|
|
6266
|
+
for (const e of await readdir4(dir, { withFileTypes: true })) {
|
|
5317
6267
|
if (e.isDirectory()) {
|
|
5318
|
-
if (!SKIP.has(e.name)) await walk(
|
|
6268
|
+
if (!SKIP.has(e.name)) await walk(join10(dir, e.name));
|
|
5319
6269
|
continue;
|
|
5320
6270
|
}
|
|
5321
6271
|
if (e.name !== "rig.json") continue;
|
|
5322
|
-
const abs =
|
|
6272
|
+
const abs = join10(dir, e.name);
|
|
5323
6273
|
let name = "?";
|
|
5324
6274
|
try {
|
|
5325
|
-
name = String(JSON.parse(await
|
|
6275
|
+
name = String(JSON.parse(await readFile10(abs, "utf8")).meta?.name ?? "?");
|
|
5326
6276
|
} catch {
|
|
5327
6277
|
}
|
|
5328
6278
|
out.push({ rig: relative3(root3, abs).split(sep).join("/"), name, mtimeMs: (await stat3(abs)).mtimeMs });
|
|
@@ -5332,9 +6282,9 @@ data: ${JSON.stringify({ version: v })}
|
|
|
5332
6282
|
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
5333
6283
|
}
|
|
5334
6284
|
async function listFiles(rigAbs) {
|
|
5335
|
-
const dir =
|
|
5336
|
-
for (const e of (await
|
|
5337
|
-
const fh = await open(
|
|
6285
|
+
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))) {
|
|
6287
|
+
const fh = await open(join10(dir, e.name));
|
|
5338
6288
|
try {
|
|
5339
6289
|
const head = Buffer.alloc(24);
|
|
5340
6290
|
const { bytesRead } = await fh.read(head, 0, 24, 0);
|
|
@@ -5369,13 +6319,13 @@ data: ${JSON.stringify({ version: v })}
|
|
|
5369
6319
|
}
|
|
5370
6320
|
if (url.pathname.startsWith("/api/")) return await api(url, req, res);
|
|
5371
6321
|
const STATIC = /* @__PURE__ */ new Set(["/editor.html", "/dist/editor.js", "/dist/editor.js.map", "/dist/editor.css", "/dist/editor.css.map"]);
|
|
5372
|
-
const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ?
|
|
6322
|
+
const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join10(assets, url.pathname) : void 0;
|
|
5373
6323
|
if (!abs) {
|
|
5374
6324
|
res.writeHead(403);
|
|
5375
6325
|
return res.end("forbidden");
|
|
5376
6326
|
}
|
|
5377
6327
|
try {
|
|
5378
|
-
const data = await
|
|
6328
|
+
const data = await readFile10(abs);
|
|
5379
6329
|
res.writeHead(200, { "content-type": TYPES[extname2(abs)] ?? "application/octet-stream" });
|
|
5380
6330
|
res.end(data);
|
|
5381
6331
|
} catch {
|
|
@@ -5451,8 +6401,8 @@ data: ${JSON.stringify({ version: v })}
|
|
|
5451
6401
|
const abs = inside(rig);
|
|
5452
6402
|
if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
|
|
5453
6403
|
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` });
|
|
5454
|
-
await
|
|
5455
|
-
const r = await callTool("rig_create", { path: abs, name: name ?? basename5(
|
|
6404
|
+
await mkdir6(dirname9(abs), { recursive: true });
|
|
6405
|
+
const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
|
|
5456
6406
|
ensureWatch(abs);
|
|
5457
6407
|
return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });
|
|
5458
6408
|
}
|
|
@@ -5467,7 +6417,7 @@ data: ${JSON.stringify({ version: v })}
|
|
|
5467
6417
|
const name = url.searchParams.get("name") ?? "";
|
|
5468
6418
|
if (!/^[A-Za-z0-9._-]+\.png$/i.test(name) || name.startsWith(".")) return json(res, 400, { isError: true, text: `ERROR: bad file name "${name}" \u2014 letters, digits, . _ - and a .png extension` });
|
|
5469
6419
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("image/png")) return json(res, 415, { isError: true, text: "ERROR: upload needs content-type: image/png" });
|
|
5470
|
-
const target =
|
|
6420
|
+
const target = join10(dirname9(abs), name);
|
|
5471
6421
|
if (!inside(relative3(root3, target))) return json(res, 403, { isError: true, text: "file outside root" });
|
|
5472
6422
|
if (await stat3(target).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${name} already exists next to the rig \u2014 rename the file` });
|
|
5473
6423
|
const data = await bytes(req, UPLOAD_MAX);
|