@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.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- 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
|
+
}
|