@rig-c/pose 0.2.1 → 0.4.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 CHANGED
@@ -24,7 +24,7 @@ It prints the gate's verdict and where every mapped bone landed, then writes `wa
24
24
  rig-pose retarget --rig out/rig --map map.json --pose dance.pose.json --out motion.json --name dance
25
25
  ```
26
26
 
27
- For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip, one key per pose frame.
27
+ For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip, one key per pose frame — and, when the map lists alternate drawings, an `attachment` track per slot they switch (and a `translate` track per bone a set offsets).
28
28
 
29
29
  Nothing is written until every check passes:
30
30
 
@@ -39,10 +39,28 @@ Nothing is written until every check passes:
39
39
  and what the run cannot decide for you it lists:
40
40
 
41
41
  - `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
42
+ - `SHORT` — a segment shorter in the picture than `--short` of its median length (with `--short <fraction>`; off by default) has its angle there interpolated, not read; one line per span, with the chain, the segment, the frames and the lowest fraction. Below.
43
+ - `END-ON` — a `SHORT` run lasting at least 0.15 s: the limb points at the camera, a pose the rig cannot draw.
42
44
  - `FLIP` — a segment that turns more than `--flip` degrees in one frame (with `--flip <deg>`; off by default) is spread over the frames around the jump; one line per spread, with the chain, the segment, the jump, the frames and h. Below.
43
45
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
44
46
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
45
47
  - `POSTURE` — when the map has a pelvis chain (joints `r_hip`, `l_hip`) and a `translate`: the hip line's tilt range, and the sway — the x-range of mid_hip − mid_shoulder — in rig units and in shoulder widths, the pose's beside the posed rig's. A reading, not a gate.
48
+ - `SWITCH` — with `alternates` in the map ([below](#alternate-drawings-alternates)): each span on which a set of alternate drawings shows, its slots, its frames, how far the pose was from the set's key skeleton, and how far each bone with a `drawn` angle was turned from it.
49
+ - `UNCOVERED` — with `alternates` in the map: every fold on a frame where no switch took the folding mesh off screen. This is the list of poses still to paint.
50
+ - `RANGE` — a chain's `range` ([below](#a-joints-range-range)): a bone whose rotate key the pose asks past its range, outside every span of a set depicting its chain, is clamped at the limit; one line per run, with the frames, the seconds and the largest overflow. The demand for an alternate.
51
+ - `ART-RANGE` — inside a set's span: a bone with both a `drawn` angle in the set and a `range`, turned from its drawn angle past that range. Not clamped; the demand for another drawing of that set.
52
+ - `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
53
+
54
+ `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance` and `switchMin` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, and the demand lists:
55
+
56
+ - `switches: [{ set, chains, slots, hides, offsets, from, to, frames: [first, last], distance: { max, median }, artTurn: { <bone>: { max, median } } }]` — one per span shown; `from` is the first frame's time, `to` the time the base art comes back (the animation's end when the span runs to it), `distance` in degrees over the frames shown, `artTurn` |wrap(target − drawn)| in degrees per bone the set has a `drawn` angle for.
57
+ - `uncovered: [{ slot, attachment, chains, times }]` — every entry of `folds` on the times its slot still shows that mesh, with the map's chains that move it (the deepest: the arm, not the trunk it hangs from). `rigc render --folds` reads only what the pose shows, so a mesh swapped out or hidden by a switch is not read on those frames at all; `uncovered` is the fold times less the switched ones.
58
+ - `endOn` — above, under the short hold.
59
+ - `turns: [{ line, joints, frames, seconds, minFraction, reversed }]` — `line` is `hip`, `shoulder` or `eye`.
60
+ - `range: [{ chain, bone, frames, seconds, maxOverflow, key, limits }]` — `key` is the rotate key the pose asked for on the frame of the largest overflow, before the clamp.
61
+ - `artRange: [{ set, chain, bone, frames, seconds, maxOverflow, turn, limits }]` — `turn` is the signed art turn on that frame.
62
+
63
+ Runs in `turns`, `range` and `artRange` merge across a gap of one frame, as `endOn` does; `seconds` runs from the first frame's key time to the end of the last frame.
46
64
 
47
65
  `--smooth <n>` smooths each joint's positions, after its holds, with a Savitzky–Golay filter: at every frame, the least-squares cubic over the *n* frames around it (clipped at the ends), read at that frame. It is centred, so it moves no motion in time, and a cubic keeps the peaks a moving average flattens. `1` (the default) is none; otherwise *n* is odd and at least 5, since a cubic through 3 points passes through all three.
48
66
 
@@ -54,6 +72,17 @@ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/
54
72
 
55
73
  Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
56
74
 
75
+ **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds and smoothing, and before the flip guard, every limb segment the map aims is measured on every frame (not a `reach`, and not a line across the body from a left joint to its right twin, such as the hip line or the eye line: those shorten when the body or head turns, which is a different thing, and the cutoff below was measured on limbs only) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction).
76
+
77
+ The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/issues/22)), over both upper arms, forearms, thighs and shins. On the 13 s dance (24 fps, 2,504 segment-frames) the fraction's 0.5th percentile is 0.51, its 5th 0.82, and 7 segment-frames (0.28%) are under 0.35; on a 16 s second sequence (30 fps, 3,936) they are 0.27 and 0.75, and 34 (0.86%). Every frame under 0.35 was looked at, and was one of two kinds:
78
+
79
+ - **end-on, lasting several frames** — the limb really points at the camera: on the second sequence the right upper arm folded across the chest (frames 395–401, 0.09–0.25), and the right thigh and shin of a knee raised at the camera (365–370, 0.16–0.34); on the dance the left upper arm with the elbow at the camera (frames 30–37, 0.27–0.35, S2 below);
80
+ - **blur collapse, lasting 1–3 frames** — a fast forearm whose elbow and wrist the detector both puts on the blurred hand (eleven spans on the second sequence).
81
+
82
+ Both have a meaningless direction; only the first is a pose the rig cannot draw. **0.35** is the smallest round value that takes in every end-on frame (the highest is 0.35, on the dance) without reaching normal motion (the 5th percentiles are 0.75 and 0.82). A run under the cutoff — spans merged across a gap of one frame — lasting **≥ 0.15 s** (3.6 frames at 24 fps, 4.5 at 30) is the end-on kind: it excludes every blur collapse above (at most 3 frames, 0.10 s at 30 fps) and keeps every end-on span (6–8 frames). The run prints it as `END-ON` and the report lists it under `endOn` (chain, bone, segment, frames, seconds, the lowest fraction): it is the list of moments that need an alternate drawing ([#6](https://github.com/rig-c/pose/issues/6)).
83
+
84
+ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. A first implementation also held the lines across the body. It listed the eye line (head in profile) and the hip line (body side-on) as end-on, and holding the hip line narrowed the rig's hip tilt range to 39.3° against the pose's 125.2°. The cutoff was never measured on those lines, so they are excluded; with them out the rig reads 125.2° again. The default stays off: a default needs its own criterion and a third sequence.
85
+
57
86
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
58
87
 
59
88
  ## The pose: `rig-pose-sequence/1`
@@ -103,6 +132,45 @@ Which bones carry which joints. You write it for your rig:
103
132
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
104
133
  - `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
105
134
 
135
+ ### A joint's range: `range`
136
+
137
+ A rig whose limbs are cut at the joints reads well only within some range of each joint's angle. A chain may state it per bone, in rotate-key degrees (the key, relative to the bone's setup rotation and to the turn of the mapped bone above it):
138
+
139
+ ```json
140
+ { "name": "arm_r", "bones": ["sleeve_r0", "sleeve_r1"], "joints": ["r_shoulder", "r_elbow", "r_wrist"],
141
+ "range": { "sleeve_r0": [-10, 5], "sleeve_r1": [-10, 10] } }
142
+ ```
143
+
144
+ Outside every span of a set depicting that chain, a key past its range is clamped at the limit: the bone's target becomes the clamped world angle, and the bones below are keyed from it as usual, so they still point where the pose puts them. Bones are clamped parents first, and a `reach` below a clamped bone is aimed again from where the clamped bones put its origin. The landing check measures against the clamped targets. Every overflow is listed as `RANGE` demand. A key on its limit to within 0.001° (the writer's rounding of a key; the rest angles come from rig-c's float32 model) does not count. Inside a set's span the bone is not clamped: the set's art was painted for that pose. Its `drawn` angle, if the set gives one, is read against the range instead (`ART-RANGE`). Each name is refused if it is not a bone of the chain (`MAP_RANGE`), as is a range that is not `[min, max]` with min ≤ max.
145
+
146
+ ### Alternate drawings: `alternates`
147
+
148
+ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward the camera, hands crossed in front of the body. The answer is a different drawing for that pose, swapped in. One key pose repaints every slot it moves (both arms, and the torso when the pose pulls it), so an alternate is a **set** of slots switched together. The map says which sets the rig has and the pose each was painted from; the run decides when each shows:
149
+
150
+ ```json
151
+ "alternates": [
152
+ { "name": "K1_hands_behind_head",
153
+ "slots": { "upperarm_r_under": "K1_upperarm_r", "forearm_r_back": "K1_forearm_r", "topwear": "K1_topwear" },
154
+ "hides": ["upperarm_r", "forearm_r"],
155
+ "chains": ["arm_r", "arm_l"],
156
+ "pose": { "r_shoulder": [450, 640], "r_elbow": [350, 500], "r_wrist": [510, 480],
157
+ "l_shoulder": [610, 640], "l_elbow": [720, 500], "l_wrist": [560, 480] },
158
+ "drawn": { "sleeve_r0": 117.8, "sleeve_r1": 30.4 },
159
+ "offsets": { "sleeve_r1": [4, -2] } } ]
160
+ ```
161
+
162
+ - `slots`: what each slot of the set shows while it shows; each attachment must be in that slot under some skin of the rig, as rig-c built it. `hides`: the slots that are empty while it shows. A slot's place in the draw order is the rig's own, so a drawing that must sit in front of the torso is an attachment in a slot that is: a switch is attachment keys only, and no draw-order key is written.
163
+ - `chains`: the map's chains whose pose the set depicts; it is selected by them. `pose`: the key skeleton the art was painted from, body-18 (or `mid_hip`/`mid_shoulder`) in picture px, y down, any scale and place — only its directions are read; every joint the listed chains use must be in it.
164
+ - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key: while a set shows, a bone is keyed to the pose as always, and the art turns with it. What it gives is a figure: the art turn, how far each bone is from the angle its art was painted at (`artTurn`, and `ART-RANGE` past the bone's range).
165
+ - `offsets` (optional): a translate per bone, in its parent's axes, held while the set shows and 0 elsewhere (stepped keys), so the joints as painted are the pivots. A bone `translate` already keys (the hip) cannot take one.
166
+ - Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN` and `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains).
167
+
168
+ **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, smoothing, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a run of one set shorter than `--switch-min` (default 0.25 s) is dropped and the base art shows there. Both figures were fixed before a run ([#6](https://github.com/rig-c/pose/issues/6)). Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
169
+
170
+ **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
171
+
172
+ **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds and smoothing, held and smoothed as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
173
+
106
174
  ## Requirements
107
175
 
108
176
  [Bun](https://bun.sh) ≥ 1.2 on `PATH` — rig-pose, like rig-c, runs its TypeScript sources directly. `rig-c` comes as a dependency. With `@esotericsoftware/spine-core` installed beside it, rig-c's gate runs the spine-core round trip; without it, rig-c's own gate, and the gate's report says which.
@@ -119,7 +187,7 @@ A flat painting turned by its bones matches each limb's direction, not its depth
119
187
  | S4 | 7.7–10.9 | hands crossed in front | which forearm is in front |
120
188
  | S5 | 11.1–13.0 | an arm thrust at the camera, then a V sign | foreshortening (0.63) and the shape of the hand; the arm mesh folds |
121
189
 
122
- The answer is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. 0.2 will have rig-pose choose when to show it, key the switch in the motion, and list every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6)).
190
+ The answer is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. rig-pose chooses when to show it, keys the switch in the motion, and lists every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6), [above](#alternate-drawings-alternates)).
123
191
 
124
192
  ## What 0.1.0 does not do
125
193
 
package/cli.ts CHANGED
@@ -38,17 +38,31 @@ retarget
38
38
  --flip <deg>|off a segment that turns more than <deg> degrees in one frame of the pose is
39
39
  spread over the frames around the jump, centred on it, and listed as FLIP
40
40
  (default: off, every turn kept as the pose gives it; 90 is the measured setting)
41
+ --short <f>|off a segment whose picture length on a frame is under <f> of its median length
42
+ (seen end-on, or collapsed by blur) has its angle there interpolated from the
43
+ frames either side, before the flip guard, and listed as SHORT; a run under <f>
44
+ lasting ≥ 0.15 s is also listed as END-ON (default: off; 0.35 is the measured
45
+ setting, f in (0, 1))
41
46
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
42
47
  the run (default), or is straightened on that frame and listed
43
48
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
49
+ --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
50
+ every segment of its chains is within d degrees of its key skeleton (default: 25)
51
+ --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
44
52
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
45
53
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
46
54
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
47
- --report <file> write the run's measurements (holds, flips, bends, landings, folds) as JSON
55
+ --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
56
+ switches, uncovered, turns, range, artRange) as JSON
48
57
 
49
58
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
50
59
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
51
60
  \`rigc render --folds\` is a reading: the run lists every mesh that folds, and still writes.
61
+ With "alternates" in the map, it keys attachment switches beside the bone keys (SWITCH lines)
62
+ and lists every fold no switch covers (UNCOVERED lines). A chain's "range" clamps a rotate key
63
+ outside the spans of a set depicting it (RANGE lines); inside one, a bone with a "drawn" angle
64
+ turned past its range is listed (ART-RANGE). A line across the body (hips, shoulders, eyes)
65
+ under 0.5 of its median picture length, or reversed, for ≥ 0.15 s is listed (TURN).
52
66
 
53
67
  convert
54
68
  --coco <file> coco17-sequence/1: { format, width, height, source, frames: [{ t, keypoints }] },
@@ -104,8 +118,13 @@ function flipOf(f: Map<string, string>): number | 'off' {
104
118
  return num(f, 'flip', 90, (v) => v > 0 && v < 180, 'off, or degrees in (0, 180) — a one-frame turn is never more than 180°');
105
119
  }
106
120
 
121
+ function shortOf(f: Map<string, string>): number | 'off' {
122
+ if (!f.has('short') || f.get('short') === 'off') return 'off';
123
+ return num(f, 'short', 0.35, (v) => v > 0 && v < 1, 'off, or a fraction in (0, 1) of the segment\'s median picture length');
124
+ }
125
+
107
126
  function retargetArgs(argv: string[]): RetargetArgs {
108
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
127
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'profile', 'build', 'render', 'report'], ['coco']);
109
128
  const bends = f.get('bends') ?? 'refuse';
110
129
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
111
130
  const profile = f.get('profile') ?? 'spine';
@@ -124,8 +143,11 @@ function retargetArgs(argv: string[]): RetargetArgs {
124
143
  minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
125
144
  smooth: smoothOf(f),
126
145
  flip: flipOf(f),
146
+ short: shortOf(f),
127
147
  bends,
128
148
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
149
+ switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
150
+ switchMin: num(f, 'switch-min', 0.25, (n) => n >= 0, 'seconds ≥ 0'),
129
151
  profile,
130
152
  build: f.get('build'),
131
153
  render: f.get('render'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/map.ts CHANGED
@@ -37,6 +37,20 @@
37
37
  * a length measured on the rig (between two points: a bone's origin, `name:tip`
38
38
  * the end of a bone's x axis, or `a+b`) to the same length measured in the pose
39
39
  * (its median over the frames where both points are good).
40
+ *
41
+ * A chain may carry `range: { "<bone>": [min, max] }`: the rotate key, degrees
42
+ * relative to the bone's setup rotation, within which its cut art still reads
43
+ * well. Outside a set's span the retarget clamps the key there, and lists the
44
+ * overflow as demand (`RANGE`).
45
+ *
46
+ * `alternates` lists the rig's alternate drawings as sets: `{ name, slots,
47
+ * hides, chains, pose, drawn?, offsets? }`. While a set shows, every slot in
48
+ * `slots` shows its attachment and every slot in `hides` is empty; `pose` is the
49
+ * key skeleton it is selected by (picture px, y down, any scale — only its
50
+ * directions are read), holding every joint its `chains` use; `drawn` is each
51
+ * bone's world angle as painted (a reading: `artTurn`); `offsets` is a
52
+ * translate per bone, in its parent's axes, held while the set shows.
53
+ * `switch.ts` decides when each one shows.
40
54
  */
41
55
  import type { RawBone } from 'rig-c/core';
42
56
  import { POINT_NAMES } from './joints.ts';
@@ -54,16 +68,42 @@ export interface Chain {
54
68
  bend: Bend;
55
69
  /** Aim from the bone's posed origin at the pose's end point, anchored at the translated bone. */
56
70
  reach: boolean;
71
+ /** Per bone of the chain: the rotate key's range, degrees relative to setup, [min, max]. */
72
+ range: Record<string, [number, number]>;
57
73
  }
58
74
  export interface Translate {
59
75
  bone: string;
60
76
  joint: string;
61
77
  scale: { pose: [string, string]; rig: [string, string] };
62
78
  }
79
+ /** An alternate drawing: a set of slots switched together, and the key skeleton it is selected by. */
80
+ export interface AlternateSet {
81
+ name: string;
82
+ /** Slot → the attachment it shows while the set shows. */
83
+ slots: Record<string, string>;
84
+ /** Slots that are empty while the set shows. */
85
+ hides: string[];
86
+ /** Names of the map's chains whose pose the drawing depicts. */
87
+ chains: string[];
88
+ /** The key skeleton the drawing was painted from: picture px, y down, any scale. */
89
+ pose: Record<string, [number, number]>;
90
+ /** Bone → its world angle as painted, Spine degrees. */
91
+ drawn: Record<string, number>;
92
+ /** Bone → a translate, in its parent's axes, held while the set shows. */
93
+ offsets: Record<string, [number, number]>;
94
+ }
63
95
  export interface JointMap {
64
96
  chains: Chain[];
65
97
  translate: Translate | null;
98
+ alternates: AlternateSet[];
99
+ }
100
+
101
+ /** What a slot of the built rig shows at setup, and every attachment any skin gives it. */
102
+ export interface RigSlot {
103
+ setup: string | null;
104
+ attachments: ReadonlySet<string>;
66
105
  }
106
+ export type RigSlots = ReadonlyMap<string, RigSlot>;
67
107
 
68
108
  /** The rig as the map is resolved against it: each bone's setup pose, by rig-c's poser. */
69
109
  export interface RestBone extends RawBone {
@@ -80,7 +120,7 @@ export function readMap(text: string, where: string): JointMap {
80
120
  p.throwIfAny();
81
121
  throw new Error('unreachable');
82
122
  }
83
- refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate']);
123
+ refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates']);
84
124
  if (doc.spec !== MAP_SPEC) p.add('MAP_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${MAP_SPEC}"`);
85
125
  const chains: Chain[] = [];
86
126
  if (!Array.isArray(doc.chains) || doc.chains.length === 0) {
@@ -90,10 +130,10 @@ export function readMap(text: string, where: string): JointMap {
90
130
  doc.chains.forEach((c: unknown, i: number) => {
91
131
  const cw = `${where} chains[${i}]`;
92
132
  if (!isObject(c)) {
93
- p.add('MAP_CHAIN_SHAPE', `${cw} is ${typeName(c)}; a chain is { name, bones, joints, aim?, bend?, reach? }`);
133
+ p.add('MAP_CHAIN_SHAPE', `${cw} is ${typeName(c)}; a chain is { name, bones, joints, aim?, bend?, reach?, range? }`);
94
134
  return;
95
135
  }
96
- refuseUnknownKeys(p, 'MAP_KEYS', cw, c, ['name', 'bones', 'joints', 'aim', 'bend', 'reach']);
136
+ refuseUnknownKeys(p, 'MAP_KEYS', cw, c, ['name', 'bones', 'joints', 'aim', 'bend', 'reach', 'range']);
97
137
  const name = typeof c.name === 'string' && c.name !== '' ? c.name : null;
98
138
  if (name === null) p.add('MAP_CHAIN_NAME', `${cw} "name" is ${typeName(c.name)}; it must be a non-empty string`);
99
139
  else if (names.has(name)) p.add('MAP_CHAIN_NAME', `${cw} "name" "${name}" names two chains`);
@@ -128,7 +168,19 @@ export function readMap(text: string, where: string): JointMap {
128
168
  if (typeof c.reach !== 'boolean') p.add('MAP_REACH', `${label} "reach" is ${typeName(c.reach)}; it must be true or false`);
129
169
  else reach = c.reach;
130
170
  }
131
- chains.push({ name: name ?? cw, bones: bones ?? [], joints: joints ?? [], aim, bend, reach });
171
+ const range: Record<string, [number, number]> = {};
172
+ if ('range' in c) {
173
+ if (!isObject(c.range)) p.add('MAP_RANGE', `${label} "range" is ${typeName(c.range)}; it is { "<bone>": [min, max] }, the rotate key's range in degrees relative to setup, per bone of the chain`);
174
+ else {
175
+ for (const [b, v] of Object.entries(c.range)) {
176
+ if (bones !== null && !bones.includes(b)) p.add('MAP_RANGE', `${label} "range" names bone "${b}", which is not a bone of the chain; its bones are ${bones.join(', ')}`);
177
+ else if (!Array.isArray(v) || v.length !== 2 || !v.every((x) => typeof x === 'number' && Number.isFinite(x)) || (v[0] as number) > (v[1] as number)) {
178
+ p.add('MAP_RANGE', `${label} "range" bone "${b}" is ${JSON.stringify(v)}; it must be [min, max], two finite numbers of degrees with min ≤ max`);
179
+ } else range[b] = [v[0] as number, v[1] as number];
180
+ }
181
+ }
182
+ }
183
+ chains.push({ name: name ?? cw, bones: bones ?? [], joints: joints ?? [], aim, bend, reach, range });
132
184
  });
133
185
  }
134
186
  let translate: Translate | null = null;
@@ -167,8 +219,121 @@ export function readMap(text: string, where: string): JointMap {
167
219
  p.add('MAP_REACH', `${label} has "reach" and starts at joint "${c.joints[0]}"; the reach is measured from the translated joint, so its first joint must be "${translate.joint}"`);
168
220
  }
169
221
  }
222
+ const alternates = 'alternates' in doc ? readAlternates(p, where, doc.alternates, chains, translate) : [];
170
223
  p.throwIfAny();
171
- return { chains, translate };
224
+ return { chains, translate, alternates };
225
+ }
226
+
227
+ const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] } }';
228
+
229
+ /** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
230
+ function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
231
+ const out: AlternateSet[] = [];
232
+ if (!Array.isArray(raw)) {
233
+ p.add('MAP_ALT_SHAPE', `${where} "alternates" is ${typeName(raw)}; it is an array of sets, ${SET_SHAPE}`);
234
+ return out;
235
+ }
236
+ const names = new Set<string>();
237
+ const chainNames = chains.map((c) => c.name);
238
+ raw.forEach((a: unknown, i: number) => {
239
+ const aw = `${where} alternates[${i}]`;
240
+ if (!isObject(a)) {
241
+ p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
242
+ return;
243
+ }
244
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets']);
245
+ const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
246
+ if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
247
+ else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
248
+ if (name !== null) names.add(name);
249
+ const label = name === null ? aw : `set "${name}"`;
250
+ let ok = name !== null;
251
+
252
+ const slots: Record<string, string> = {};
253
+ if (!isObject(a.slots)) {
254
+ p.add('MAP_ALT_SLOT', `${label} "slots" is ${typeName(a.slots)}; it is { "<slot>": "<attachment>" }, what each slot of the set shows while it shows`);
255
+ ok = false;
256
+ } else {
257
+ for (const [s, att] of Object.entries(a.slots)) {
258
+ if (typeof att !== 'string' || att === '') {
259
+ p.add('MAP_ALT_ATTACHMENT', `${label} "slots" slot "${s}" is ${typeName(att)}; it must name an attachment of that slot`);
260
+ ok = false;
261
+ } else slots[s] = att;
262
+ }
263
+ }
264
+ let hides: string[] = [];
265
+ if ('hides' in a) {
266
+ const h = strings(a.hides);
267
+ if (h === null) {
268
+ p.add('MAP_ALT_HIDES', `${label} "hides" is ${typeName(a.hides)}; it must be an array of slot names`);
269
+ ok = false;
270
+ } else hides = h;
271
+ }
272
+ for (const h of hides) if (h in slots) p.add('MAP_ALT_HIDES', `${label} hides slot "${h}", which "slots" shows "${slots[h]}" in; a slot of a set either shows or hides`);
273
+ if (isObject(a.slots) && Object.keys(slots).length + hides.length === 0) p.add('MAP_ALT_SLOT', `${label} shows no slot and hides none; a set switches at least one slot`);
274
+
275
+ const cs = strings(a.chains);
276
+ if (cs === null || cs.length === 0) {
277
+ p.add('MAP_ALT_CHAIN', `${label} "chains" is ${typeName(a.chains)}; it must be a non-empty array of the map's chain names (${chainNames.join(', ')})`);
278
+ ok = false;
279
+ }
280
+ for (const c of cs ?? []) {
281
+ if (!chainNames.includes(c)) p.add('MAP_ALT_CHAIN', `${label} names chain "${c}", which the map does not have; its chains are ${chainNames.join(', ')}`);
282
+ }
283
+ if (cs !== null && new Set(cs).size !== cs.length) p.add('MAP_ALT_CHAIN', `${label} "chains" names a chain twice: ${cs.join(', ')}`);
284
+ const own = chains.filter((c) => (cs ?? []).includes(c.name));
285
+ const ownBones = own.flatMap((c) => c.bones);
286
+ const ownList = own.length === 0 ? 'none' : own.map((c) => `${c.name}: ${c.bones.join(', ')}`).join('; ');
287
+
288
+ const pose: Record<string, [number, number]> = {};
289
+ if (!isObject(a.pose)) {
290
+ p.add('MAP_ALT_POSE', `${label} "pose" is ${typeName(a.pose)}; it is the key skeleton the set was painted from, { "<joint>": [x, y], … } in picture px, y down`);
291
+ ok = false;
292
+ } else {
293
+ for (const [j, at] of Object.entries(a.pose)) {
294
+ if (!POINT_NAMES.includes(j)) p.add('MAP_ALT_JOINT', `${label} "pose" names joint "${j}", which is not a point this package reads; they are ${POINT_NAMES.join(', ')}`);
295
+ else if (!Array.isArray(at) || at.length !== 2 || !at.every((v) => typeof v === 'number' && Number.isFinite(v))) p.add('MAP_ALT_POSE', `${label} "pose" joint "${j}" is ${JSON.stringify(at)}; it must be [x, y], two finite numbers`);
296
+ else pose[j] = [at[0] as number, at[1] as number];
297
+ }
298
+ for (const c of own) {
299
+ const missing = c.joints.filter((j) => !(j in (a.pose as Record<string, unknown>)));
300
+ if (missing.length > 0) p.add('MAP_ALT_JOINT', `${label} "pose" has no ${missing.map((j) => `"${j}"`).join(', ')}; chain "${c.name}" uses ${c.joints.join(', ')}, and every one of them must be in the key skeleton`);
301
+ else {
302
+ c.bones.forEach((_, k) => {
303
+ const f = pose[c.joints[k]!];
304
+ const t = pose[c.joints[k + 1]!];
305
+ if (f !== undefined && t !== undefined && Math.hypot(t[0] - f[0], t[1] - f[1]) < 1e-9) p.add('MAP_ALT_POSE', `${label} "pose" puts ${c.joints[k]} and ${c.joints[k + 1]} at the same point; chain "${c.name}"'s segment between them has no direction`);
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ const drawn: Record<string, number> = {};
312
+ if ('drawn' in a) {
313
+ if (!isObject(a.drawn)) p.add('MAP_ALT_DRAWN', `${label} "drawn" is ${typeName(a.drawn)}; it is { "<bone>": degrees }, each bone's world angle as painted`);
314
+ else {
315
+ for (const [b, v] of Object.entries(a.drawn)) {
316
+ if (!ownBones.includes(b)) p.add('MAP_ALT_DRAWN', `${label} "drawn" names bone "${b}", which is not a bone of its chains (${ownList})`);
317
+ else if (typeof v !== 'number' || !Number.isFinite(v)) p.add('MAP_ALT_DRAWN', `${label} "drawn" bone "${b}" is ${typeName(v)}; it must be a finite number of degrees`);
318
+ else drawn[b] = v;
319
+ }
320
+ }
321
+ }
322
+ const offsets: Record<string, [number, number]> = {};
323
+ if ('offsets' in a) {
324
+ if (!isObject(a.offsets)) p.add('MAP_ALT_OFFSET', `${label} "offsets" is ${typeName(a.offsets)}; it is { "<bone>": [dx, dy] }, a translate in the bone's parent's axes`);
325
+ else {
326
+ for (const [b, v] of Object.entries(a.offsets)) {
327
+ if (translate !== null && b === translate.bone) p.add('MAP_ALT_OFFSET', `${label} "offsets" names bone "${b}", which "translate" already keys; a bone carries one translate track`);
328
+ else if (!ownBones.includes(b)) p.add('MAP_ALT_OFFSET', `${label} "offsets" names bone "${b}", which is not a bone of its chains (${ownList})`);
329
+ else if (!Array.isArray(v) || v.length !== 2 || !v.every((x) => typeof x === 'number' && Number.isFinite(x))) p.add('MAP_ALT_OFFSET', `${label} "offsets" bone "${b}" is ${JSON.stringify(v)}; it must be [dx, dy], two finite numbers`);
330
+ else offsets[b] = [v[0] as number, v[1] as number];
331
+ }
332
+ }
333
+ }
334
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets });
335
+ });
336
+ return out;
172
337
  }
173
338
 
174
339
  /** A rig point as the map spells it: `name`, `name:tip`, or `a+b`, the midpoint of two such. */
@@ -253,7 +418,7 @@ export function rigPoint(rest: Rest, spec: string): [number, number] | null {
253
418
  }
254
419
 
255
420
  /** Check every name the map gives against the rig; every miss is named. */
256
- export function resolveMap(map: JointMap, rest: Rest, where: string): void {
421
+ export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigSlots = new Map()): void {
257
422
  const p = new Problems();
258
423
  const all = [...rest.keys()];
259
424
  const listed = all.length <= 40 ? all.join(', ') : `${all.slice(0, 40).join(', ')} … (${all.length} in all)`;
@@ -320,6 +485,29 @@ export function resolveMap(map: JointMap, rest: Rest, where: string): void {
320
485
  p.add('MAP_REACH', `chain "${c.name}" — bone "${b}" reaches from its posed origin, but it does not descend from the translated bone "${t.bone}"; its origin would not travel with the anchor`);
321
486
  }
322
487
  }
488
+ resolveAlternates(p, map.alternates, slots);
323
489
  if (p.any) p.list.unshift({ code: 'MAP_RESOLVES', text: `${where} does not resolve against the rig` });
324
490
  p.throwIfAny();
325
491
  }
492
+
493
+ /** Every slot and attachment a set names, against the built rig: each slot exists, each attachment is in its slot under some skin, each hidden slot exists, once. */
494
+ function resolveAlternates(p: Problems, alternates: readonly AlternateSet[], slots: RigSlots): void {
495
+ const all = [...slots.keys()];
496
+ const listed = all.length === 0 ? 'none' : all.length <= 40 ? all.join(', ') : `${all.slice(0, 40).join(', ')} … (${all.length} in all)`;
497
+ for (const a of alternates) {
498
+ for (const [slot, att] of Object.entries(a.slots)) {
499
+ const s = slots.get(slot);
500
+ if (s === undefined) p.add('MAP_ALT_SLOT', `set "${a.name}" — slot "${slot}" is not in the rig; its slots are ${listed}`);
501
+ else if (!s.attachments.has(att)) {
502
+ const has = [...s.attachments];
503
+ p.add('MAP_ALT_ATTACHMENT', `set "${a.name}" — slot "${slot}" has no attachment "${att}" under any skin; it has ${has.length === 0 ? 'none' : has.join(', ')}`);
504
+ }
505
+ }
506
+ const seen = new Set<string>();
507
+ for (const h of a.hides) {
508
+ if (!slots.has(h)) p.add('MAP_ALT_HIDES', `set "${a.name}" hides slot "${h}", which is not in the rig; its slots are ${listed}`);
509
+ else if (seen.has(h)) p.add('MAP_ALT_HIDES', `set "${a.name}" hides slot "${h}" twice`);
510
+ seen.add(h);
511
+ }
512
+ }
513
+ }