@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The motion amplitude of an automatic part (issue #126 Q2, rig-c 2.26.0 /
3
+ * 2.29.0 `motionAmplitude`): what rig-c's two weight-aware allocation rows —
4
+ * `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD` — read. The tracks are derived
5
+ * from the motion this package already declares and never written by the
6
+ * author; the gradation is the author's number or null. rig-c's contract
7
+ * (docs/MESH_REDUCTION.md §8, *Implemented — the allocation rows* and *G —
8
+ * what it derives from*) types it as
9
+ *
10
+ * { tracks: [{ track, pairs: [{ bones, theta }], epsilon }], gradation?: number | null }
11
+ *
12
+ * and every derived term is derived here or refused by name; none is guessed.
13
+ *
14
+ * ## The terms
15
+ *
16
+ * - **A track** is one track of the idle as the rig stage writes it
17
+ * (`MotionSpec`, after the control bones took their keys), named
18
+ * `<bone or group>.<property>#<index>`, the index its place in the idle.
19
+ * - **The pairs** are every unordered pair of the bones the part's source
20
+ * weights bind (sorted), declared in every track that moves at least one of
21
+ * them relative to another — so no pair a share can move between is left
22
+ * undeclared, which would leave both rows `not-measurable` (§8: "a pair no
23
+ * track declares makes both rows not-measurable"). A track moves bone `b`
24
+ * when a bone it keys is `b` or an ancestor of `b`: Spine bones inherit
25
+ * their parent's rotation and scale (the rig writes no inherit mode), so a
26
+ * pair the track moves together — both under one keyed bone — or not at all
27
+ * keeps its relative linear part, θ 0 (§8: "a bone that does not move is
28
+ * declared with θ 0"); a pair it moves on one side only gets the track's θ.
29
+ * A pair moved by two different bones of one group (the blink's `eyes`) has a
30
+ * relative part that depends on the two bones' frames, which this module does
31
+ * not compose: it is refused by name.
32
+ * - **θ** = ‖M − I‖ of the pair's relative linear part over the track (§8),
33
+ * bounded from the track's keys: rig-c's formulas — 2 sin(α / 2) for a
34
+ * rotation by α, |s − 1| for a scale by s — at the largest |value| (rotate,
35
+ * degrees, a value Spine adds to the setup rotation) or the value furthest
36
+ * from 1 (scale, a factor on the setup scale, which the rig writes as 1) over
37
+ * every key value and every Bézier control value of the track. A cubic Bézier
38
+ * lies inside the hull of its control points, so this bounds the curve
39
+ * between keys (it may exceed the sine's true peak by the handles' overshoot;
40
+ * it never falls short); a named easing (`shut`, `open`) runs between its two
41
+ * key values. A scale along one axis is a diagonal map, and |s − 1| is its
42
+ * norm. A translation leaves the linear part unchanged: θ 0 by the contract's
43
+ * definition. ⚠️ So a pair a translation separates carries no load in rig-c's
44
+ * reading — the contract's θ has no lever term (§8's correction names it) —
45
+ * and that is rig-c's definition, not a figure measured here. A shear is
46
+ * refused: the contract gives no formula for it and this module invents none.
47
+ * - **ε**, drawing px, above 0: the chord error a track tolerates. The one
48
+ * number the part declares for how far motion may carry art is its motion
49
+ * bound, `meshes.<part>.auto.motion.maxLocalDeformation` (rig px = drawing
50
+ * px: the rig's images are the drawing, `pageScale` 1), and it is read as
51
+ * every track's ε. A bound of 0 is not above 0 and is refused by name.
52
+ * - **G**, `gradation`, px per px: how fast the mesh may coarsen away from
53
+ * something that needs density (§8, read by `MQ_ALLOCATION_CONTRAST` only).
54
+ * rig-c measured that nothing a rig declares fixes it (rigc#1291, *G — what
55
+ * it derives from*), so it is **the author's**:
56
+ * `meshes.<part>.auto.motion.gradation`, sent as written, and `null` when
57
+ * the author left it out — rig-c 2.29.0 then measures `MQ_DEFORM_LOAD`, which
58
+ * never read G, and reads `MQ_ALLOCATION_CONTRAST` `not-measurable` naming
59
+ * the gradation. No value is derived or defaulted here.
60
+ * - **Constraints** move bones by what they solve, not by a key: a rig with
61
+ * any constraint is refused by name, because no declared amplitude bounds
62
+ * what a constraint does.
63
+ *
64
+ * Pure: no clock, no randomness, nothing read or written.
65
+ */
66
+ import type { MeshReductionInput, MotionAmplitude, TrackAmplitude } from 'rig-c/mesh';
67
+ import type { MotionKey, MotionSpec } from './motion.ts';
68
+
69
+ /**
70
+ * What the derivation reads off a rig, besides the part: the rig's bones (name and parent), the idle as the rig stage
71
+ * writes it, and the rig's constraint count. The rig stage takes them from the rig it is building; a caller that
72
+ * prepares the stage's reduction input outside it (`tools/auto_matrix.ts`) reads them off a build of the same config
73
+ * — `rig.json`'s bones, `motion.json`, the config's constraints — so both build the one input `reductionKey` matches.
74
+ */
75
+ export interface AmplitudeBasis {
76
+ bones: ReadonlyArray<{ name: string; parent?: string }>;
77
+ motion: MotionSpec;
78
+ constraints: number;
79
+ }
80
+
81
+ /**
82
+ * The amplitude of one automatic part as the rig stage derives it: the bones its source `weights` bind, ε the part's
83
+ * `motion.maxLocalDeformation` (NaN, refused by name, when the spec has no motion block), G its `motion.gradation`
84
+ * or null. The one derivation both the stage and any caller that builds the stage's input use.
85
+ */
86
+ export function partAmplitude(basis: AmplitudeBasis, weights: ReadonlyArray<ReadonlyArray<{ bone: string }>>, motion: { maxLocalDeformation: number; gradation?: number | null } | undefined): AmplitudeDerivation {
87
+ return deriveMotionAmplitude({
88
+ bones: basis.bones,
89
+ motion: basis.motion,
90
+ constraints: basis.constraints,
91
+ bound: weights.flatMap((v) => v.map((e) => e.bone)),
92
+ epsilon: motion?.maxLocalDeformation ?? Number.NaN,
93
+ gradation: motion?.gradation ?? null,
94
+ });
95
+ }
96
+
97
+ /** The reduction input with the derived amplitude on it, or unchanged when the derivation stopped (no key is added). */
98
+ export function withAmplitude(input: MeshReductionInput, d: AmplitudeDerivation): MeshReductionInput {
99
+ return 'amplitude' in d ? { ...input, motionAmplitude: d.amplitude } : input;
100
+ }
101
+
102
+ /** The relative linear part's norm for one track: the largest over its keys' and handles' values, or why it has none. */
103
+ export function trackTheta(property: string, keys: readonly MotionKey[]): { theta: number } | { refused: string } {
104
+ const values: number[] = [];
105
+ for (const k of keys) {
106
+ values.push(...k.v);
107
+ if (k.curve !== undefined) values.push(k.curve[1], k.curve[3]);
108
+ }
109
+ if (property === 'rotate') {
110
+ const deg = Math.max(0, ...values.map((v) => Math.abs(v)));
111
+ return { theta: 2 * Math.sin((Math.min(deg, 180) * Math.PI) / 360) };
112
+ }
113
+ if (property === 'scale' || property === 'scalex' || property === 'scaley') return { theta: Math.max(0, ...values.map((v) => Math.abs(v - 1))) };
114
+ if (property === 'translate' || property === 'translatex' || property === 'translatey') return { theta: 0 };
115
+ return { refused: `property "${property}" has no θ in rig-c's contract (rotation 2 sin(α/2), scale |s − 1|, translation 0), and none is invented` };
116
+ }
117
+
118
+ /** Why one term of the amplitude could not be derived: the term's path and the sentence. */
119
+ export interface AmplitudeStop {
120
+ term: string;
121
+ detail: string;
122
+ }
123
+
124
+ /** The derivation: the amplitude to send, or every term that stopped it (the tracks derived so far kept for the record). */
125
+ export type AmplitudeDerivation = { amplitude: MotionAmplitude } | { stops: AmplitudeStop[]; tracks: TrackAmplitude[] };
126
+
127
+ /**
128
+ * The amplitude of a part bound to `bound` (module header). `bones` is the
129
+ * rig's bone list (name and parent), `motion` the idle as written,
130
+ * `constraints` the rig's constraint count, `epsilon` the part's
131
+ * `motion.maxLocalDeformation`, and `gradation` the author's
132
+ * `motion.gradation`, or null when it was left out — sent as given, never a
133
+ * reason to stop (rig-c 2.29.0 admits null).
134
+ */
135
+ export function deriveMotionAmplitude(args: {
136
+ bones: ReadonlyArray<{ name: string; parent?: string }>;
137
+ motion: MotionSpec;
138
+ constraints: number;
139
+ bound: readonly string[];
140
+ epsilon: number;
141
+ gradation: number | null;
142
+ }): AmplitudeDerivation {
143
+ const stops: AmplitudeStop[] = [];
144
+ const parent = new Map(args.bones.map((b) => [b.name, b.parent ?? null]));
145
+ const under = (bone: string, k: string): boolean => {
146
+ for (let b: string | null = bone; b !== null; b = parent.get(b) ?? null) if (b === k) return true;
147
+ return false;
148
+ };
149
+ const bound = [...new Set(args.bound)].sort();
150
+ if (args.constraints > 0) {
151
+ stops.push({ term: 'tracks', detail: `the rig carries ${args.constraints} constraint(s), which move bones by what they solve and not by a key; no declared amplitude bounds that motion` });
152
+ }
153
+ if (!(args.epsilon > 0)) {
154
+ stops.push({ term: 'tracks[].epsilon', detail: `motion.maxLocalDeformation is ${args.epsilon}; ε is the chord error a track tolerates, above 0, and the part declares no other` });
155
+ }
156
+ const tracks: TrackAmplitude[] = [];
157
+ args.motion.animations.idle.tracks.forEach((t, i) => {
158
+ const keyed = t.bone !== undefined ? [t.bone] : (args.motion.groups[t.group ?? ''] ?? []);
159
+ const name = `${t.bone ?? t.group ?? '?'}.${t.property}#${i}`;
160
+ const moverOf = (b: string): string[] => keyed.filter((k) => under(b, k));
161
+ if (!bound.some((b) => moverOf(b).length > 0)) return;
162
+ const th = trackTheta(t.property, t.keys);
163
+ if ('refused' in th) {
164
+ stops.push({ term: `tracks[${name}].pairs[].theta`, detail: th.refused });
165
+ return;
166
+ }
167
+ const pairs: TrackAmplitude['pairs'] = [];
168
+ for (let a = 0; a < bound.length; a++) {
169
+ for (let b = a + 1; b < bound.length; b++) {
170
+ const ma = moverOf(bound[a]);
171
+ const mb = moverOf(bound[b]);
172
+ if (ma.length > 0 && mb.length > 0 && !ma.some((k) => mb.includes(k))) {
173
+ stops.push({ term: `tracks[${name}].pairs[${bound[a]}, ${bound[b]}].theta`, detail: `the track moves "${bound[a]}" by [${ma.join(', ')}] and "${bound[b]}" by [${mb.join(', ')}], two keyed bones of one group; their relative linear part depends on the two bones' frames, which is not derived here` });
174
+ continue;
175
+ }
176
+ const moved = (ma.length > 0) !== (mb.length > 0);
177
+ pairs.push({ bones: [bound[a], bound[b]], theta: moved ? th.theta : 0 });
178
+ }
179
+ }
180
+ tracks.push({ track: name, pairs, epsilon: args.epsilon });
181
+ });
182
+ if (stops.length > 0) return { stops, tracks };
183
+ return { amplitude: { tracks, gradation: args.gradation } };
184
+ }
185
+
186
+ /** What the row says about the amplitude: whether it was sent, and every term that stopped it. */
187
+ export interface AmplitudeRow {
188
+ sent: boolean;
189
+ stops: AmplitudeStop[];
190
+ }
191
+
192
+ export function amplitudeRow(d: AmplitudeDerivation): AmplitudeRow {
193
+ return 'amplitude' in d ? { sent: true, stops: [] } : { sent: false, stops: d.stops };
194
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The skinning envelope of an automatic part (issue #126 Q3–Q5, rig-c 2.31.0
3
+ * `targets.skinning`, rigc#1294/#1295): what rig-c's `MQ_SKINNING_RESIDUAL`
4
+ * reads, and, when the author sets `meshes.<part>.auto.motion.residual`, what
5
+ * every removal, boundary run and post-pass of `reduceMesh` is held to. Its
6
+ * own module rather than inside `src/autoamplitude.ts`: the amplitude is a
7
+ * pair-wise θ per track and stops the whole rig on any constraint, while the
8
+ * envelope is per bone, composed by rig-c's helper, and stops only the parts
9
+ * whose chains a constraint drives. Both read the same idle.
10
+ *
11
+ * The contract (rig-c docs/MESH_REDUCTION.md §7, *Mechanism 1 — implemented,
12
+ * the measurement half* and *the reducer half*) types it as
13
+ *
14
+ * { reference, bones: [{ bone, linear, pivot, translation }] }
15
+ *
16
+ * and every entry comes from rig-c's `skinningEnvelopeBone({ referenceChain,
17
+ * chain })` — rig-c's one definition of `linear` (Q3) — over ranges read off
18
+ * the idle this package writes. Nothing is estimated here.
19
+ *
20
+ * ## The terms
21
+ *
22
+ * - **The reference** (issue #165) is the lowest common ancestor of the
23
+ * slot's bone — the bone the rig stage writes on the part's slot,
24
+ * `segments[0]`'s — and every bone the part's source weights bind: the
25
+ * deepest bone on every one of their ancestries ({@link lowestCommonAncestor},
26
+ * {@link slotEnvelope}). When every bound bone hangs below the slot's bone
27
+ * that is the slot's bone itself (Q4), and the derivation is the one this
28
+ * module made before #165, call for call. When a bound bone hangs in a
29
+ * sibling subtree (a skirt link beside the chest) the reference is the bone
30
+ * they share — read off the rig as written, never chosen.
31
+ * - **The bones** are every bone the part's source weights bind, other than
32
+ * the reference, sorted — "every bone either mesh binds, other than
33
+ * `reference`, once each"; a reduced mesh binds no bone its source does not
34
+ * (survivors keep their shares, an insertion interpolates them). The slot's
35
+ * bone is one of them exactly when it is not the reference and the weights
36
+ * bind it; when they do not, it is not sent (rig-c refuses a bone the
37
+ * meshes do not bind, `SKINNING_BONE_UNKNOWN`).
38
+ * - **The chains**: `referenceChain` is the rig's ancestry from the root down
39
+ * to the reference; `chain` from the reference's child down to the bone,
40
+ * parents first — read off the rig's bones as written, controls included.
41
+ * Against a common ancestor every bound bone has such a chain. The check
42
+ * stays in {@link deriveEnvelope}, which takes any reference: a bound bone
43
+ * that is not below the reference it is handed has no chain the helper
44
+ * composes, and the part's residual is not measurable, naming the bone
45
+ * (`ENVELOPE_BONE_NOT_BELOW_REFERENCE`). Inside one skeleton — one root —
46
+ * {@link slotEnvelope} never hands it such a reference, so the stop cannot
47
+ * fire there; it fires only on a bone list with two roots, which is not a
48
+ * skeleton, where no common ancestor exists and the slot's bone is kept as
49
+ * the reference so that every bone with no chain to it is named.
50
+ * - **Each bone's range** (`BoneMotionRange`) from the idle's tracks that key
51
+ * it — its own track or a group's that names it: `rotate` the [min, max]
52
+ * of every key value and every Bézier control value (a cubic Bézier lies
53
+ * inside the hull of its control points; a named easing runs between its
54
+ * two key values, and both easings this package writes stay within
55
+ * [0, 1]), `scaleX` / `scaleY` the same over `scalex` / `scaley`, and
56
+ * `translate` the length of (max |translatex|, max |translatey|) — each a
57
+ * value Spine adds to (rotation, translation) or multiplies (scale) the
58
+ * setup by; an unkeyed property is [0, 0], [1, 1] or 0. `pivot` is the
59
+ * bone's setup joint in the part's drawing frame (crop px less the part's
60
+ * padded origin, y down). `setup` is the rig's as written: this package
61
+ * writes no bone scale, shear or inherit mode, so `{ 1, 1, 0, 0, 'normal' }`.
62
+ * A shear key has no field in `BoneMotionRange`, so a bone keyed by one
63
+ * makes the residual not measurable (`ENVELOPE_SHEAR_KEYED`); a property
64
+ * the idle writes and none of the above names is refused the same way.
65
+ * - **Constraints**: a bone a constraint drives — an ik's, a transform's or a
66
+ * path's `bones`, a physics constraint's `bone` — moves by what the
67
+ * constraint solves, not by a key. Its range is handed to rig-c with the
68
+ * constraint's kind as its `source`, and rig-c refuses it by name
69
+ * (`SKINNING_RANGE_UNSUPPORTED`): rig-c certifies only ranges declared by
70
+ * keys and exposes no physics range off a posed walk (Q3), so none is
71
+ * invented here. A slider drives the bones of an animation this module does
72
+ * not read, so a rig with a slider makes every part's residual not
73
+ * measurable (`ENVELOPE_SLIDER_UNREAD`).
74
+ *
75
+ * A part with any stop is **not measurable**: no `targets.skinning` is sent,
76
+ * the veto is not applied, and the motion comparison accepts or refuses the
77
+ * part alone, as it does without the field. Every stop is named — bone, code
78
+ * and rig-c's own words where rig-c refused.
79
+ *
80
+ * Pure: no clock, no randomness, nothing read or written.
81
+ */
82
+ import { type BoneMotionRange, type EnvelopeBoneRanges, MeshReductionError, type MeshQualityReport, type MeshReductionInput, type SkinningEnvelope, type SkinningEnvelopeBone, skinningEnvelopeBone } from 'rig-c/mesh';
83
+ import type { ConfigConstraint } from './config.ts';
84
+ import type { MotionKey, MotionSpec } from './motion.ts';
85
+
86
+ /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
87
+ export interface EnvelopeBasis {
88
+ bones: ReadonlyArray<{ name: string; parent?: string }>;
89
+ joints: ReadonlyMap<string, readonly [number, number]>;
90
+ motion: MotionSpec;
91
+ constraints: readonly ConfigConstraint[];
92
+ }
93
+
94
+ /** One stop: the bone (or constraint) it names, the code, and the sentence — rig-c's own for a refusal of its helper. */
95
+ export interface EnvelopeStop {
96
+ bone: string;
97
+ code: string;
98
+ detail: string;
99
+ }
100
+
101
+ /** The envelope to send, or every stop that leaves the part's residual not measurable. */
102
+ export type EnvelopeDerivation = { envelope: SkinningEnvelope } | { stops: EnvelopeStop[] };
103
+
104
+ /** Where a range comes from: the bone's keys, or the kind of the constraint that drives it — which rig-c refuses by name. */
105
+ type RangeSource = 'keys' | 'ik' | 'transform' | 'path' | 'physics';
106
+
107
+ /** A `BoneMotionRange` whose `source` may name a constraint kind: rig-c reads `source` at run time and certifies `'keys'` alone. */
108
+ type DeclaredRange = Omit<BoneMotionRange, 'source'> & { source: RangeSource };
109
+
110
+ /**
111
+ * rig-c's helper over ranges whose `source` may be a constraint kind. rig-c's type admits only the one value it
112
+ * certifies; it reads the field at run time and refuses any other by name (`SKINNING_RANGE_UNSUPPORTED`), which is the
113
+ * refusal this package wants carried rather than re-implemented — so the wider value is handed through as data.
114
+ */
115
+ function envelopeEntry(ranges: { referenceChain: DeclaredRange[]; chain: DeclaredRange[] }): SkinningEnvelopeBone {
116
+ return skinningEnvelopeBone(ranges as unknown as EnvelopeBoneRanges);
117
+ }
118
+
119
+ /** The bones a constraint drives, by kind (rig-c's shapes, `CONSTRAINT_BONE_FIELDS` in `src/config.ts`): a slider's are an animation's, read as null. */
120
+ export function constraintDriven(c: ConfigConstraint): string[] | null {
121
+ if (c.type === 'slider') return null;
122
+ if (c.type === 'physics') return typeof c.bone === 'string' ? [c.bone] : [];
123
+ return Array.isArray(c.bones) ? c.bones.filter((b): b is string => typeof b === 'string') : [];
124
+ }
125
+
126
+ /** Every value a track's curve can reach: each key's values and each Bézier control value (module header). */
127
+ function trackValues(keys: readonly MotionKey[]): number[] {
128
+ const out: number[] = [];
129
+ for (const k of keys) {
130
+ out.push(...k.v);
131
+ if (k.curve !== undefined) out.push(k.curve[1], k.curve[3]);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ const SETUP: BoneMotionRange['setup'] = { scaleX: 1, scaleY: 1, shearX: 0, shearY: 0, inherit: 'normal' };
137
+
138
+ /** One bone's declared range off the idle, or the stop that names why it has none. */
139
+ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [number, number], driven: ReadonlyMap<string, RangeSource>): DeclaredRange | EnvelopeStop {
140
+ const joint = basis.joints.get(bone);
141
+ if (joint === undefined) return { bone, code: 'ENVELOPE_JOINT_UNKNOWN', detail: `the rig declares no setup joint for "${bone}"` };
142
+ let rotate: [number, number] = [0, 0];
143
+ let scaleX: [number, number] = [1, 1];
144
+ let scaleY: [number, number] = [1, 1];
145
+ let tx = 0;
146
+ let ty = 0;
147
+ for (const t of basis.motion.animations.idle.tracks) {
148
+ const keyed = t.bone !== undefined ? t.bone === bone : (basis.motion.groups[t.group ?? ''] ?? []).includes(bone);
149
+ if (!keyed) continue;
150
+ const vs = trackValues(t.keys);
151
+ const span: [number, number] = vs.length === 0 ? [0, 0] : [Math.min(...vs), Math.max(...vs)];
152
+ const most = Math.max(0, ...vs.map((v) => Math.abs(v)));
153
+ if (t.property === 'rotate') rotate = span;
154
+ else if (t.property === 'scalex') scaleX = span;
155
+ else if (t.property === 'scaley') scaleY = span;
156
+ else if (t.property === 'translatex') tx = most;
157
+ else if (t.property === 'translatey') ty = most;
158
+ else if (t.property === 'shearx' || t.property === 'sheary') {
159
+ return { bone, code: 'ENVELOPE_SHEAR_KEYED', detail: `the idle keys ${t.property} on "${bone}"; rig-c's BoneMotionRange declares rotation, scale and translation only, so the bone's motion has no declared range and none is invented` };
160
+ } else {
161
+ return { bone, code: 'ENVELOPE_PROPERTY_UNREAD', detail: `the idle keys "${t.property}" on "${bone}", a property this derivation reads no range from` };
162
+ }
163
+ }
164
+ return {
165
+ bone,
166
+ source: driven.get(bone) ?? 'keys',
167
+ pivot: [joint[0] - origin[0], joint[1] - origin[1]],
168
+ rotate,
169
+ scaleX,
170
+ scaleY,
171
+ translate: Math.hypot(tx, ty),
172
+ setup: { ...SETUP },
173
+ };
174
+ }
175
+
176
+ /**
177
+ * The envelope of one part against a given `reference` (module header; the rig stage hands it the common ancestor,
178
+ * {@link slotEnvelope}), `bound` the bones its source weights bind, `origin` the part's padded origin in crop px (the
179
+ * drawing frame's (0, 0)). Every bone is tried, so one derivation names every stop; a bound bone not below `reference`
180
+ * is one (`ENVELOPE_BONE_NOT_BELOW_REFERENCE`).
181
+ */
182
+ export function deriveEnvelope(basis: EnvelopeBasis, reference: string, bound: readonly string[], origin: readonly [number, number]): EnvelopeDerivation {
183
+ const stops: EnvelopeStop[] = [];
184
+ const parent = new Map(basis.bones.map((b) => [b.name, b.parent ?? null]));
185
+ const driven = new Map<string, RangeSource>();
186
+ for (const c of basis.constraints) {
187
+ const kind = c.type;
188
+ if (kind === 'slider') {
189
+ stops.push({ bone: `constraint "${c.name}"`, code: 'ENVELOPE_SLIDER_UNREAD', detail: `a slider drives the bones of the animation it names, which this derivation does not read; no bone's range is certain while it runs` });
190
+ continue;
191
+ }
192
+ for (const b of constraintDriven(c) ?? []) if (!driven.has(b)) driven.set(b, kind);
193
+ }
194
+ const ancestry = (bone: string): string[] => {
195
+ const up: string[] = [];
196
+ for (let b: string | null = bone; b !== null; b = parent.get(b) ?? null) up.push(b);
197
+ return up.reverse();
198
+ };
199
+ const ranges = new Map<string, DeclaredRange | EnvelopeStop>();
200
+ const rangeOf = (b: string): DeclaredRange | EnvelopeStop => {
201
+ const got = ranges.get(b) ?? boneRange(basis, b, origin, driven);
202
+ ranges.set(b, got);
203
+ return got;
204
+ };
205
+ const isStop = (r: DeclaredRange | EnvelopeStop): r is EnvelopeStop => 'code' in r;
206
+ if (!parent.has(reference)) stops.push({ bone: reference, code: 'ENVELOPE_BONE_UNKNOWN', detail: `the slot's bone "${reference}" is not a bone of the rig` });
207
+ const referenceChain = parent.has(reference) ? ancestry(reference) : [];
208
+ const entries: SkinningEnvelopeBone[] = [];
209
+ for (const bone of [...new Set(bound)].filter((b) => b !== reference).sort()) {
210
+ if (!parent.has(bone)) {
211
+ stops.push({ bone, code: 'ENVELOPE_BONE_UNKNOWN', detail: `the part's weights bind "${bone}", which is not a bone of the rig` });
212
+ continue;
213
+ }
214
+ const up = ancestry(bone);
215
+ const at = up.indexOf(reference);
216
+ if (at < 0) {
217
+ stops.push({ bone, code: 'ENVELOPE_BONE_NOT_BELOW_REFERENCE', detail: `the part's weights bind "${bone}", which does not hang below the slot's bone "${reference}" (its ancestry: ${up.join(' > ')}); rig-c's skinningEnvelopeBone composes a chain from the reference's child down to the bone, and no other composition is derived here` });
218
+ continue;
219
+ }
220
+ const chainNames = up.slice(at + 1);
221
+ const refRanges = referenceChain.map(rangeOf);
222
+ const chainRanges = chainNames.map(rangeOf);
223
+ const own = [...refRanges, ...chainRanges].filter(isStop);
224
+ if (own.length > 0) {
225
+ for (const s of own) if (!stops.some((x) => x.bone === s.bone && x.code === s.code)) stops.push(s);
226
+ continue;
227
+ }
228
+ try {
229
+ entries.push(envelopeEntry({ referenceChain: refRanges.filter((r): r is DeclaredRange => !isStop(r)), chain: chainRanges.filter((r): r is DeclaredRange => !isStop(r)) }));
230
+ } catch (err) {
231
+ if (!(err instanceof MeshReductionError)) throw err;
232
+ stops.push({ bone, code: err.code, detail: err.message });
233
+ }
234
+ }
235
+ if (stops.length > 0) return { stops };
236
+ return { envelope: { reference, bones: entries } };
237
+ }
238
+
239
+ /** A bone as the reference's derivation reads it: a name and its parent. */
240
+ export interface TreeBone {
241
+ name: string;
242
+ parent?: string | null;
243
+ }
244
+
245
+ /** The bone's ancestry from its root down to the bone, or null when the list does not hold it (or its parents loop). */
246
+ export function ancestry(bones: readonly TreeBone[], name: string): string[] | null {
247
+ const parent = new Map(bones.map((b) => [b.name, b.parent ?? null]));
248
+ if (!parent.has(name)) return null;
249
+ const up: string[] = [];
250
+ const seen = new Set<string>();
251
+ for (let b: string | null = name; b !== null; b = parent.get(b) ?? null) {
252
+ if (seen.has(b)) return null;
253
+ seen.add(b);
254
+ up.push(b);
255
+ }
256
+ return up.reverse();
257
+ }
258
+
259
+ /**
260
+ * The lowest common ancestor of the slot's bone and every bound bone the list holds (issue #165): the deepest bone on
261
+ * every one of their ancestries. In one skeleton every ancestry starts at the one root, so it always exists; a list
262
+ * with two roots (a degenerate input, not a skeleton) has none, and the bone that shares no ancestor with the slot's
263
+ * bone is named under `ENVELOPE_BONE_NOT_BELOW_REFERENCE`, the stop {@link deriveEnvelope} gives a bone with no chain
264
+ * to the reference. A bound bone the list does not hold is left to {@link deriveEnvelope}, which names it; an unknown
265
+ * slot's bone stops by its name. Measured first in Stage A (`tools/auto_envelope_survey.ts`, which reads this one).
266
+ */
267
+ export function lowestCommonAncestor(bones: readonly TreeBone[], slot: string, bound: readonly string[]): { reference: string } | { stop: EnvelopeStop } {
268
+ const own = ancestry(bones, slot);
269
+ if (own === null) return { stop: { bone: slot, code: 'ENVELOPE_BONE_UNKNOWN', detail: `the slot's bone "${slot}" is not a bone of the rig` } };
270
+ let common = own;
271
+ for (const b of [...new Set(bound)].sort()) {
272
+ const up = ancestry(bones, b);
273
+ if (up === null) continue;
274
+ let k = 0;
275
+ while (k < common.length && k < up.length && common[k] === up[k]) k++;
276
+ if (k === 0) {
277
+ return {
278
+ stop: {
279
+ bone: b,
280
+ code: 'ENVELOPE_BONE_NOT_BELOW_REFERENCE',
281
+ detail: `the part's weights bind "${b}", which shares no ancestor with the slot's bone "${slot}" (its ancestry: ${up.join(' > ')}; the slot's: ${own.join(' > ')}); one skeleton has one root, so this bone list is not one skeleton and no reference is derived`,
282
+ },
283
+ };
284
+ }
285
+ common = common.slice(0, k);
286
+ }
287
+ return { reference: common[common.length - 1] };
288
+ }
289
+
290
+ /** The envelope of one part as the rig stage derives it: the reference it was derived against, and the derivation. */
291
+ export interface SlotEnvelope {
292
+ reference: string;
293
+ derivation: EnvelopeDerivation;
294
+ }
295
+
296
+ /**
297
+ * The envelope of one part (module header, issue #165): `slot` the slot's bone, `bound` the bones its source weights
298
+ * bind, `origin` the part's padded origin in crop px. The reference is {@link lowestCommonAncestor}; the bound bones
299
+ * are handed to {@link deriveEnvelope} as they are, so the slot's bone enters with its own range exactly when the
300
+ * weights bind it and it is not the reference, and is not sent otherwise. When the reference is the slot's bone this
301
+ * is `deriveEnvelope(basis, slot, bound, origin)`, the call made before #165. Where no common ancestor exists — an
302
+ * unknown slot's bone, or a bone list with two roots, neither a skeleton the rig stage writes — the slot's bone is
303
+ * kept as the reference and {@link deriveEnvelope} names every bone with no chain to it: no reference is invented.
304
+ */
305
+ export function slotEnvelope(basis: EnvelopeBasis, slot: string, bound: readonly string[], origin: readonly [number, number]): SlotEnvelope {
306
+ const lca = lowestCommonAncestor(basis.bones, slot, bound);
307
+ const reference = 'reference' in lca ? lca.reference : slot;
308
+ return { reference, derivation: deriveEnvelope(basis, reference, bound, origin) };
309
+ }
310
+
311
+ /** The reduction input with `targets.skinning` on it — the envelope and the author's bound — or unchanged when nothing is sent. */
312
+ export function withSkinning(input: MeshReductionInput, d: EnvelopeDerivation | null, maxResidual: number | undefined): MeshReductionInput {
313
+ if (d === null || maxResidual === undefined || !('envelope' in d)) return input;
314
+ return { ...input, targets: { ...input.targets, skinning: { envelope: d.envelope, maxResidual } } };
315
+ }
316
+
317
+ const RULE_TAIL =
318
+ 'a pose-free bound on how far the candidate draws a UV from where the source draws it, under that envelope only — it certifies no orientation, stretch or squash, and it is not the motion verdict: the motion comparison still runs and decides; ' +
319
+ "rig-c's report does not count the steps the veto refused (tools/veto_tally.ts counts them)";
320
+
321
+ /** What the row and the build line say the residual is, in so many words — on a part whose reference is the slot's bone. */
322
+ export const RESIDUAL_RULE =
323
+ "MQ_SKINNING_RESIDUAL (rig-c 2.31.0, rigc#1295): every removal, boundary run and post-pass of the reduction is held to it against the original source, under the envelope derived from the idle with the slot's bone as reference; " +
324
+ RULE_TAIL;
325
+
326
+ /** The same, on a part whose reference is not the slot's bone (issue #165): the row's `reference` names the ancestor. */
327
+ export const RESIDUAL_RULE_COMMON_ANCESTOR =
328
+ "MQ_SKINNING_RESIDUAL (rig-c 2.31.0, rigc#1295): every removal, boundary run and post-pass of the reduction is held to it against the original source, under the envelope derived from the idle with the lowest common ancestor of the slot's bone and every bound bone as reference (issue #165), the slot's bone sent as a bone when the weights bind it; " +
329
+ RULE_TAIL;
330
+
331
+ /** The written mesh's `MQ_SKINNING_RESIDUAL` as its own measurement reports it. */
332
+ export interface ResidualReading {
333
+ state: string;
334
+ value: number | null;
335
+ bound: { op: '<=' | '>='; value: number } | null;
336
+ /** The worst sample (its UV or art pixel) and the value's two sums there; null when the row has no worst sample. */
337
+ worst: { uv?: [number, number]; pixel?: [number, number]; covariance: number | null; lever: number | null } | null;
338
+ /** Samples carried by both meshes. */
339
+ samples: number | null;
340
+ reason: string | null;
341
+ }
342
+
343
+ /** `mesh_report.json`'s `skinning_residual`: written only on a part whose author set `motion.residual`. */
344
+ export interface ResidualRow {
345
+ rule: string;
346
+ /** The author's bound, drawing px. */
347
+ max_residual: number;
348
+ /** Whether `targets.skinning` was sent — the veto applied. False exactly when the envelope stopped. */
349
+ sent: boolean;
350
+ /** The bone the envelope was derived against: the slot's bone, or the lowest common ancestor of it and every bound bone (issue #165). */
351
+ reference: string;
352
+ /** The envelope sent, per bone `linear` (dimensionless), `pivot` and `translation` (drawing px); null when nothing was sent. */
353
+ envelope: SkinningEnvelopeBone[] | null;
354
+ /** Why the residual is not measurable for this part, each naming its bone and code; empty when sent. */
355
+ stops: EnvelopeStop[];
356
+ /** The written mesh's reading; null when nothing was sent. */
357
+ measured: ResidualReading | null;
358
+ }
359
+
360
+ /**
361
+ * The row for one written mesh: the derivation, and the residual row of the report that mesh came with. `slot` is the
362
+ * slot's bone when the caller knows it: a `reference` other than it is the common ancestor, and the row's `rule` says
363
+ * so; left out, or equal, the rule is the slot's-bone rule the row has always carried.
364
+ */
365
+ export function residualRow(maxResidual: number, reference: string, d: EnvelopeDerivation, report: MeshQualityReport, slot?: string): ResidualRow {
366
+ const rule = slot === undefined || slot === reference ? RESIDUAL_RULE : RESIDUAL_RULE_COMMON_ANCESTOR;
367
+ if (!('envelope' in d)) return { rule, max_residual: maxResidual, sent: false, reference, envelope: null, stops: d.stops, measured: null };
368
+ const r = (report.candidates[0]?.geometry?.rows ?? []).find((x) => x.code === 'MQ_SKINNING_RESIDUAL' && x.object.region === null);
369
+ const at = r?.worst?.at;
370
+ const sk = r?.skinning;
371
+ const measured: ResidualReading =
372
+ r === undefined
373
+ ? { state: 'absent', value: null, bound: null, worst: null, samples: null, reason: 'the measurement of the written mesh carries no MQ_SKINNING_RESIDUAL row' }
374
+ : {
375
+ state: r.state,
376
+ value: r.value,
377
+ bound: r.bound,
378
+ worst: at === undefined ? null : { ...(at.uv === undefined ? {} : { uv: at.uv }), ...(at.pixel === undefined ? {} : { pixel: at.pixel }), covariance: sk?.worst?.covariance ?? null, lever: sk?.worst?.lever ?? null },
379
+ samples: sk?.samples.measured ?? null,
380
+ reason: r.reason,
381
+ };
382
+ return {
383
+ rule,
384
+ max_residual: maxResidual,
385
+ sent: true,
386
+ reference,
387
+ envelope: d.envelope.bones.map((b) => ({ bone: b.bone, linear: b.linear, pivot: [b.pivot[0], b.pivot[1]], translation: b.translation })),
388
+ stops: [],
389
+ measured,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * The build line's clause: empty without `motion.residual`, so a config that leaves it out prints the line it always
395
+ * printed. Otherwise `; residual <value> <= <bound> (a pose-free bound, not the motion verdict)`, or, when nothing was
396
+ * sent, `; residual not measurable: <bone> <code>, … (veto not applied; the motion comparison decides)`.
397
+ */
398
+ export function residualClause(row: ResidualRow | undefined): string {
399
+ if (row === undefined) return '';
400
+ if (!row.sent) return `; residual not measurable: ${row.stops.map((s) => `${s.bone} ${s.code}`).join(', ')} (veto not applied; the motion comparison decides)`;
401
+ const m = row.measured;
402
+ const reading = m === null || m.value === null || m.bound === null ? (m?.state ?? 'absent') : `${m.value} ${m.state === 'fail' ? '>' : m.bound.op} ${m.bound.value}`;
403
+ return `; residual ${reading} (a pose-free bound, not the motion verdict)`;
404
+ }