rig-parts 1.8.0 → 1.9.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 +4 -3
- package/cli.ts +26 -4
- package/docs/AUTHORING.md +20 -4
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/build.ts +14 -1
- package/src/check.ts +113 -10
- package/src/config.ts +75 -1
- package/src/propose.ts +111 -1
- package/src/rig.ts +112 -2
- package/src/turn.ts +99 -0
package/README.md
CHANGED
|
@@ -255,10 +255,11 @@ rig-parts build --config config.json --source painting.png --full layers/full --
|
|
|
255
255
|
At each step the config holds only what that step reads; the one table of what
|
|
256
256
|
that is, step by step, is [docs/AUTHORING.md §4](docs/AUTHORING.md#4-the-command-order).
|
|
257
257
|
The selftest runs this block in order, command by command, on each fetched example,
|
|
258
|
-
from a config holding only what the first step reads (`RL01`). On `
|
|
258
|
+
from a config holding only what the first step reads (`RL01`). On `demo` the
|
|
259
259
|
proposal pasted uncorrected stops at the last step, naming its `bottomwear` on
|
|
260
|
-
`CHECK_TIP_OVER_ROOT` — the
|
|
261
|
-
(issue #
|
|
260
|
+
`CHECK_TIP_OVER_ROOT` — the correction its tracked config records there, the two outer
|
|
261
|
+
skirt chains moved (issue #227, [its README](examples/demo/README.md)); on `scarf`,
|
|
262
|
+
since issue #262, it builds green.
|
|
262
263
|
|
|
263
264
|
`propose` is deliberately not a step of `build`: the proposal is a draft to correct
|
|
264
265
|
against its overlay, and a config with bones is `build`'s input. `rig`, `check` and
|
package/cli.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { makeInputs } from './src/inputs.ts';
|
|
|
26
26
|
import { idleKeysOf, isIdleKeys } from './src/rig.ts';
|
|
27
27
|
import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
|
|
28
28
|
import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
|
|
29
|
-
import { basisLines, checkProposal, compare, compareLines, drawLandmarks, FRINGE_STEP_PX, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, WRIST_ON_PART_PX } from './src/propose.ts';
|
|
29
|
+
import { basisLines, checkProposal, compare, compareLines, draftTurn, drawLandmarks, FRINGE_STEP_PX, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
|
|
30
30
|
import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
|
|
31
31
|
import { readPng, writePng } from './src/raster/png.ts';
|
|
32
32
|
import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
|
|
@@ -135,6 +135,20 @@ usage:
|
|
|
135
135
|
part to the one nearest part within <px>, said in a "wrist snap:" note
|
|
136
136
|
(with --keypoints, not with --from-config). Without it nothing changes.
|
|
137
137
|
|
|
138
|
+
rig-parts propose … [--full <dir | layers.json>] [--head <dir | layers.json>]
|
|
139
|
+
Draft the turn control (issue #251) into proposal.json's "turn": the
|
|
140
|
+
See-through runs the parts came from, read for each part's layer
|
|
141
|
+
depth_median (larger = further back). turn.depths holds every part's
|
|
142
|
+
depth, turn.pivot their mean weighted by opaque_px — the depth at which
|
|
143
|
+
the painted pixels' centroid stays put. The rig stage adds a bone "turn"
|
|
144
|
+
under root that nothing keys; each part travels (pivot - depth) /
|
|
145
|
+
(pivot - nearest depth) of its x, the nearest part all of it, a part
|
|
146
|
+
behind the pivot the other way. A note states the pivot and every
|
|
147
|
+
fraction with its depth; a painting patch has no depth, is not carried,
|
|
148
|
+
and is named in a note. A part from a run not given, a tag the run lacks
|
|
149
|
+
or a layer with no depth is refused by name. Without either flag nothing
|
|
150
|
+
changes.
|
|
151
|
+
|
|
138
152
|
rig-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
|
|
139
153
|
Draw the config's CURRENT bones instead (<out>/render/landmarks_config.png
|
|
140
154
|
and _head) and LINT them (by the joints too, given --keypoints), with the
|
|
@@ -573,7 +587,7 @@ function printLayerTable(set: LayerSet): void {
|
|
|
573
587
|
const TAKES = {
|
|
574
588
|
sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
|
|
575
589
|
rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
|
|
576
|
-
propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--canvas', '--keypoints', '--person', '--wrist-snap'], switches: ['--head-box'] },
|
|
590
|
+
propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap'], switches: ['--head-box'] },
|
|
577
591
|
compare: { valued: ['--left', '--right', '--map'], switches: [] },
|
|
578
592
|
check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements'], switches: ['--seam-pairs', '--keep-stills'] },
|
|
579
593
|
loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
|
|
@@ -854,7 +868,7 @@ function cmdPropose(args: string[]): number {
|
|
|
854
868
|
console.log(JSON.stringify({ head_box: r.head_box }));
|
|
855
869
|
return EXIT_OK;
|
|
856
870
|
}
|
|
857
|
-
|
|
871
|
+
if (flags.has('--canvas')) return usage('--canvas belongs to --head-box');
|
|
858
872
|
const partsArg = flags.get('--parts');
|
|
859
873
|
const source = flags.get('--source');
|
|
860
874
|
const out = flags.get('--out');
|
|
@@ -863,6 +877,8 @@ function cmdPropose(args: string[]): number {
|
|
|
863
877
|
if (out === undefined) return usage('propose needs --out <dir>');
|
|
864
878
|
if (flags.has('--from-config') && flags.has('--compare')) return usage('--from-config and --compare are two modes; give one');
|
|
865
879
|
if (flags.has('--person') && !flags.has('--keypoints')) return usage('--person names a person in the --keypoints file; give --keypoints <file>');
|
|
880
|
+
// Issue #251: the See-through runs the turn control's depths are read from; without them nothing is drafted.
|
|
881
|
+
if (flags.has('--from-config') && (flags.has('--full') || flags.has('--head'))) return usage('--full and --head draft the turn control while proposing; --from-config proposes nothing');
|
|
866
882
|
// Issue #199: a declared tolerance for a wrist on no handwear part. Whole px above the built-in one: the distance is
|
|
867
883
|
// counted in whole px, so a fraction would bind what its floor binds, and a value at or under the built-in binds nothing.
|
|
868
884
|
const snapArg = flags.get('--wrist-snap');
|
|
@@ -902,7 +918,13 @@ function cmdPropose(args: string[]): number {
|
|
|
902
918
|
}
|
|
903
919
|
const cmpPath = flags.get('--compare');
|
|
904
920
|
const cfg = cmpPath === undefined ? null : loadConfig(cmpPath);
|
|
905
|
-
const { proposal:
|
|
921
|
+
const { proposal: drafted, basis } = proposeWithBasis(P, joints, wristSnap);
|
|
922
|
+
const fullRun = flags.get('--full');
|
|
923
|
+
const headRun = flags.get('--head');
|
|
924
|
+
const prop =
|
|
925
|
+
fullRun === undefined && headRun === undefined
|
|
926
|
+
? drafted
|
|
927
|
+
: withTurn(drafted, draftTurn(P, { ...(fullRun === undefined ? {} : { full: readLayers(fullRun) }), ...(headRun === undefined ? {} : { head: readLayers(headRun) }) }));
|
|
906
928
|
// Emit only after green: a proposal the config loader would refuse is not written, nor a basis record that does not hold to it.
|
|
907
929
|
checkProposal(P, prop);
|
|
908
930
|
const bfile = basisFile(P, prop, basis, cfg === null || cmpPath === undefined ? null : { where: cmpPath, config: cfg });
|
package/docs/AUTHORING.md
CHANGED
|
@@ -102,6 +102,7 @@ read by no CPU stage).
|
|
|
102
102
|
| `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
|
|
103
103
|
| `motion.animations_from` | rig; check (named, not measured) | optional, never proposed (issue #183): a path, relative to the config file's directory, to a file in rig-c's motion-spec shape, `{"spec": "rigc-motion/1", "animations": {"<name>": {"duration": …, "tracks": [...]}, …}}` — an expression, a gesture, anything beside the idle. On every `rig` and `build` each of its animations is written into `rig/motion.json` after `idle`, as read: nothing added, re-keyed or judged here, and each is rigc's to accept or refuse at the rig stage's gate, in rigc's words. It may name what `motion.json` already holds — the bones `bones` declares, the easings `shut` and `open`, the groups `eyes` and `brows` when the blink writes them — and nothing else from the file reaches it. The loader refuses, every problem at once: no such file or one that cannot be read (`CONFIG_FILE_PRESENT`), text that is not JSON (`CONFIG_IS_JSON`), a file that is not an object, a `spec` other than `"rigc-motion/1"`, an `animations` that is not a table of objects (`CONFIG_FIELD_TYPE`), any other key — rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` included, which would be read and not written (`CONFIG_KEY_KNOWN`), an animation named `idle` (`CONFIG_ANIMATION_NAME_FREE`), and a key written twice in one object of the file, which JSON would fold into its last value without a word (`CONFIG_KEY_UNIQUE`). `check` measures the idle alone and prints one line naming the others (`beside the idle: …`). Absent, `motion.json` holds the idle alone, byte for byte as before |
|
|
104
104
|
| `constraints` | rig (handed to rig-c as `rig.json`'s `constraints`); `compare` and the coverage lines (roles) | **authored**, optional; `propose` writes none. A list of constraints in **rig-c's own rig-spec shape** (`RigConstraint`: `ik`, `transform`, `path`, `physics`, `slider` — rig-c's `src/rig.ts` documents every field), in the order rigc applies them. The loader checks only what this package owns: each entry is an object with a `type` rigc names (`CONFIG_CONSTRAINT_TYPE_KNOWN`) and a non-empty `name`, unique within its kind as rigc finds constraints (`CONFIG_CONSTRAINT_NAME_UNIQUE`; an ik and a transform may share one); every bone it names — `bones`, an ik's `target`, a transform's `source`, a physics or slider constraint's `bone` — is a bone `bones` declares, a chain link as `<chain><i>` (`CONFIG_NAME_RESOLVES`); the bone a constraint follows does not sit under a bone it drives (`CONFIG_CONSTRAINT_TARGET_DETACHED`). Every other field (`mix`, `softness`, `properties`, a path's `slot`, a slider's `animation`) and every value is rigc's to accept or refuse at the rig stage's gate, in rigc's words (`RIG_RIGC_GREEN`) — an ik's `bones` as a shape too: from rig-c 2.15.0 rigc refuses an ik over more than two bones, or over a pair whose second bone is not the first's child (issue #103; this loader refused both itself until then). A note and an `x-` record may ride on an entry; the rig stage leaves them out of what it hands rigc. Under the default `--idle-keys ctl` a two-bone ik whose child link the idle keys is split by that link's control, and rigc refuses the pair: run `rig --idle-keys direct` or `build --idle-keys direct` (the rig stage adds that sentence, naming the flag on the command that ran, to rigc's line; `build` forwards it to its rig stage, issue #95). Each bone a constraint follows is declared to rigc as `invariants.detached` from every bone it drives, which rigc's gate rule `A25` checks |
|
|
105
|
+
| `turn` | rig (adds the turn control) | **proposed** only with `propose --full`/`--head` (issue #251), then corrected: `{pivot, depths}`. `depths` holds, per part, the See-through layer's `depth_median` the part came from (larger = further back); a `painting:` patch has none and is not carried. `pivot` is the depth that stays put; `propose` drafts the mean of the parts' depths weighted by `opaque_px`, the one depth at which the painted pixels' centroid does not slide under the dial. The rig stage adds a bone `turn` under `root` at its origin that nothing keys — its local x is the dial, in rig px — and carries each part by `(pivot − depth) / (pivot − nearest depth)` of it: the nearest part all of it, a part behind the pivot the other way, a part at the pivot not at all. Each part rides leaf twins (`turn_<part>_<bone>`, an identity child of every bone its slot and weights bind) moved by one `transform` constraint (`turn_<part>`, after the config's own); the idle's keys, the `<bone>_ctl` controls and a physics constraint stay on the bones, and the dial moves none of them. At rest: with `turn` declared, no pixel differs by more than 1 level at dial 0 (not byte-identical; the cause is float noise in the identity child bones, and the constraints add nothing); without `turn`, byte-identical. Neither field has a default (`CONFIG_FIELD_PRESENT`); the pivot must lie behind the nearest part (`CONFIG_TURN_PIVOT_BEHIND_NEAREST`). Absent, no control is added and no byte of any output moves |
|
|
105
106
|
|
|
106
107
|
**A scene target is an ordinary bone.** A target the scene places — a point a hand
|
|
107
108
|
reaches for, a face something turns toward — is a single bone parented to `root`,
|
|
@@ -221,7 +222,7 @@ What a joint with a position places, as given — no ratio is applied over it:
|
|
|
221
222
|
| `sleeve_<s>` | `<s>_shoulder` → `<s>_elbow` (two links), tip at `<s>_wrist`, with the two-blob rule's first two amplitudes and its chest stub laid along the upper arm — when every handwear part over 500 px holds one such wrist within 15 px of its art (the larger of the x and y gaps, the LINT's own tolerance), or is bound one by `--wrist-snap` (below) | the sleeve rule places every sleeve chain, and each arm joint's note says why: for a wrist on no part, `… lies on the art of no handwear part within 15 px, so it does not say which sleeve it ends; the nearest is <part>, <d> px from its art by the larger of the x and y gaps (--wrist-snap <d> would bind it)` — `(beyond --wrist-snap <px>)` when the flag was given and fell short, and `the nearest are <a> and <b>, each <d> px …, so no tolerance says which` when two parts tie |
|
|
222
223
|
| `eye_<s>`, `head`, the face box | not moved: they are measured off the parts | the note prints the eye joint's distance from the eye bone, with no bar |
|
|
223
224
|
| `leg_<s>` or `leg` (issue #241) | on a `legwear` part over 500 px: `<s>_hip` → `<s>_knee` (two links), tip at `<s>_ankle`, parent `hip`, the part meshed on the chain and a `hip` stub laid along the thigh — when every such part holds the ankle of a leg whose three joints are given, within 15 px of its art. A part holding both ankles gets a chain per leg only when its art breaks along the line from `r_knee` to `l_knee` and along the line from `r_ankle` to `l_ankle` (the legs painted apart); otherwise one chain, `leg`, through the midpoints of the two hips, the two knees and the two ankles. No idle track: the chain is drafted whatever the pose, and whether the legs move is the scene's. A note on the part says which and why | no leg with all three joints: no chain, and the part is the trunk rule's region with the note it always had, byte for byte. A complete leg whose ankle lies on no legwear part, or on two: no chain, and a note `<parts>: no leg chain — <why>; the trunk rule places it` |
|
|
224
|
-
| the trunk parts' segments (issue #243) | on the `neck` (head run), `topwear` and `bottomwear` meshes, each `[bone, from, to]` segment the rule drafts down the screen is laid along neck → hip, the line the chest is placed on — `neck` + 18 px along it to `chest`; a topwear part's `chest` segment from its art's nearest reach toward the neck end to 20 px short of `hip`, and its `hip` segment from there to 100 px past `hip`; a bottomwear part's `chest` segment from 30 px short of `chest` to 20 px short of `hip` — and the bottomwear waist segment runs across that line, 5 px past `hip`, over 0.09 to 0.91 of the art's width across it. The `head` segment on the neck mesh and the one-blob sleeve rule's chest segment are not moved. A note, `trunk segments (<parts>): laid along neck -> hip …`, gives the line's angle | neither end a joint: the vertical draft, byte for byte. Neck and hip on one point: no line, the vertical draft, and a note says so. An upright line over boxes tight on their art writes the vertical draft's bytes |
|
|
225
|
+
| the trunk parts' segments (issue #243) | on the `neck` (head run), `topwear` and `bottomwear` meshes, each `[bone, from, to]` segment the rule drafts down the screen is laid along neck → hip, the line the chest is placed on — `neck` + 18 px along it to `chest`; a topwear part's `chest` segment from its art's nearest reach toward the neck end to 20 px short of `hip`, and its `hip` segment from there to 100 px past `hip`; a bottomwear part's `chest` segment, when it has one (issue #262: only when its art reaches above `chest`), from 30 px short of `chest` to 20 px short of `hip` — and the bottomwear waist segment runs across that line, 5 px past `hip`, over 0.09 to 0.91 of the art's width across it. The `head` segment on the neck mesh and the one-blob sleeve rule's chest segment are not moved. A note, `trunk segments (<parts>): laid along neck -> hip …`, gives the line's angle | neither end a joint: the vertical draft, byte for byte. Neck and hip on one point: no line, the vertical draft, and a note says so. An upright line over boxes tight on their art writes the vertical draft's bytes |
|
|
225
226
|
|
|
226
227
|
**`--wrist-snap <px>`** (issue #199), with `--keypoints` only, declares a tolerance
|
|
227
228
|
for a wrist that lies on no handwear part within the built-in 15 px — hands folded
|
|
@@ -301,6 +302,16 @@ Two rules read the figure rather than one tag, and each says so in a note:
|
|
|
301
302
|
(`no bottomwear: hip from 0.32 of figure height`). Neither published example
|
|
302
303
|
meets either branch: their hips sit at 0.436 (`reference-demo`) and 0.342 (`reference-sample`) of
|
|
303
304
|
their figures.
|
|
305
|
+
- **What a skirt hangs from** (issue #262). A `bottomwear` part whose art starts
|
|
306
|
+
below the `chest` bone's origin (along neck → hip where the joints give that line,
|
|
307
|
+
down the screen otherwise) hangs from the waist: its mesh takes the hip line and
|
|
308
|
+
its three skirt chains, and no `chest` segment, with the note `<part> (<from>):
|
|
309
|
+
its art starts … px below chest's origin, so it hangs from the waist — …`. With a
|
|
310
|
+
`chest` segment ending at the waist the waistband takes some `chest` weight and the
|
|
311
|
+
breath lifts the skirt's root while its hem, on the chains, stays: on `scarf` that
|
|
312
|
+
was `TIP_OVER_ROOT` 0.300 at the proposal's breath (upper half 0.492 rig px, hem
|
|
313
|
+
0.148), and 2.614 without the segment. A `bottomwear` that reaches above the chest —
|
|
314
|
+
the long robe of the hip rule above — has art on the chest and keeps the segment.
|
|
304
315
|
- **Clasped hands.** When every `handwear` part together is one blob (over
|
|
305
316
|
500 px) at most half the shoulders' width and centred within a quarter of it
|
|
306
317
|
of the eye axis, it is the hands clasped in front, not two sleeves: it rides
|
|
@@ -1699,6 +1710,8 @@ See-through with another seed.
|
|
|
1699
1710
|
| `CONFIG_CONSTRAINT_TYPE_KNOWN` | a `constraints` entry's `type` is not one of rig-c's five: `ik`, `transform`, `path`, `physics`, `slider` (spelled as rigc spells them; `IK` is not `ik`) | that entry's `type` |
|
|
1700
1711
|
| `CONFIG_CONSTRAINT_NAME_UNIQUE` | two `constraints` entries of one kind share a `name`; rigc finds a constraint by its kind and its name, so an ik and a transform may share one | rename one |
|
|
1701
1712
|
| `CONFIG_CONSTRAINT_TARGET_DETACHED` | the bone an ik follows (`target`) or a transform reads (`source`) is one of the bones that constraint drives, or sits under one (the detail gives the line of parents) — driving them would move what they follow. This package's refusal: rig-c 2.15.0 gates such a rig green when nothing declares the parentage (measured on the selftest's rig fixture, issue #103) | a bone beside the chain; a scene target is a single bone parented to `root` (§3) |
|
|
1713
|
+
| `CONFIG_TURN_PIVOT_BEHIND_NEAREST` | `turn.pivot` is at or in front of the nearest part's depth: the law `(pivot − depth) / (pivot − nearest)` has no value there, and in front of it every part travels against the dial | a pivot above the smallest depth in `turn.depths` |
|
|
1714
|
+
| `CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
|
|
1702
1715
|
|
|
1703
1716
|
### inputs
|
|
1704
1717
|
|
|
@@ -1750,6 +1763,7 @@ See-through with another seed.
|
|
|
1750
1763
|
| `PROPOSE_FACE_PRESENT` | no part comes from a `face` layer, and the face-less fallback cannot derive a face box either: it needs the head run's `neck` and at least one head-run `front hair`/`back hair` part whose top is above the neck's, and the message names the half that is missing. With both, `propose` does not refuse — it derives the box and says so in its first note (§5) | `assemble.plan`: take the face, or the head run's hair and neck |
|
|
1751
1764
|
| `PROPOSE_CUT_FROM_RECORDED` | two parts share one layer and none of them records `cut_from`: a `parts.json` an `assemble` before 1.6.0 wrote with `assemble.cuts`. The records are in draw order, so which is the plan part and which the piece cannot be read off the file; the message names the parts and the layer. Every other reader still takes the file | `--parts`: re-run assemble (1.6.0+) to record `cut_from` |
|
|
1752
1765
|
| `PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
|
|
1766
|
+
| `PROPOSE_TURN_RUN_GIVEN`, `PROPOSE_TURN_LAYER_PRESENT`, `PROPOSE_TURN_DEPTH_PRESENT` | with `--full`/`--head` (issue #251): a part came from a run whose layers were not given, the run given holds no layer of the part's tag, or its layer carries no `depth_median` (a PSD carries none) | give the run the parts were assembled from, as the wrapper's `layers.json` |
|
|
1753
1767
|
| `KEYPOINTS_FILE_PRESENT`, `KEYPOINTS_IS_JSON`, `KEYPOINTS_KEY_KNOWN`, `KEYPOINTS_FIELD_PRESENT`, `KEYPOINTS_FIELD_TYPE` | the `--keypoints` file is absent, not JSON, has a key this reader does not know, lacks one of `spec`, `space`, `width`, `height`, `source`, `people`, or holds a value of the wrong type (a size that is not a whole positive px count, an empty `source`, no people) | the keypoint file (§3, *A posed figure*) |
|
|
1754
1768
|
| `KEYPOINTS_SPEC_KNOWN`, `KEYPOINTS_SPACE_STATED` | the file is not `spine-parts-keypoints/1`, or states a space other than painting-px, origin top-left, y down | convert the file to that space; it is not read as if it were in it |
|
|
1755
1769
|
| `KEYPOINTS_JOINT_KNOWN`, `KEYPOINTS_JOINT_STATE`, `KEYPOINTS_POSITION_STATED`, `KEYPOINTS_POSITION_INSIDE` | a joint name outside body-18, a state outside observed / occluded / missing, an observed joint with no `at` or a missing one with one, or a position outside the `width`x`height` image | that joint |
|
|
@@ -1766,6 +1780,7 @@ See-through with another seed.
|
|
|
1766
1780
|
| `RIG_NAME_RESOLVES`, `RIG_SEGMENT_DEFINED`, `RIG_CHAIN_POINTS` | a segment names an undeclared bone, a bone with no `tip` and no next link, or a chain too short to make a link | that bone's `tip`, or write the segment out as `[bone, [x0,y0], [x1,y1]]` |
|
|
1767
1781
|
| `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
|
|
1768
1782
|
| `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
|
|
1783
|
+
| `RIG_TURN_NAME_FREE` | with `turn`: a twin (`turn_<part>_<bone>`), the control `turn` or a transform constraint `turn_<part>` the turn writes is a name the rig already holds | rename the declared bone or constraint |
|
|
1769
1784
|
| `RIG_KEY_FRAME_UNTURNED` | a `translate` key on a bone whose parent is a chain link turned along its chain, or a `scale`/`shear` key (a track, or the blink's `eyes`/`brows` group) on a turned link: the key would act along the link's axis, not the picture's; the detail names the track, the bone and the turn | key the chain's own parent, or key `rotate`; a link whose turn is 0 (a level chain) keeps every key |
|
|
1770
1785
|
| `RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
|
|
1771
1786
|
| `RIG_BLINK_HOLD_SPANS_A_FRAME` | the tree's `BLINK.hold` is shorter than one frame at `IDLE_FPS`, so for some `motion.blink.t` no idle frame — and no frame of the loop — shows the closed eye; the detail counts the phases that miss and names the first | nothing in the config: `BLINK.hold` in `src/motion.ts`, at least `1/IDLE_FPS` s (§3) |
|
|
@@ -1791,7 +1806,7 @@ See-through with another seed.
|
|
|
1791
1806
|
| `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
|
|
1792
1807
|
| `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
|
|
1793
1808
|
| `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
|
|
1794
|
-
| `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's art in the lower half of its box travels less than 1.4725 times as far as in the upper half, measured from the idle's posed geometry (rig px quoted) | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them), or its mode: on `scarf`'s short skirt the proposal's lattice
|
|
1809
|
+
| `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's art in the lower half of its box travels less than 1.4725 times as far as in the upper half, measured from the idle's posed geometry (rig px quoted) | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them), or its mode: on `scarf`'s short skirt the proposal's lattice read 1.207 and the same part in `contour` mode 1.738 with the same segments and idle (issue #118) — both carried the `chest` segment #262 removed, after which the lattice reads 3.487. When the detail says `the root travels with the breath (x px of y px): it is carried by \`<bone>\`` (issue #262) the chain is not the cause: a bone an idle `translate` track moves (the breath) moves the root more than the tip, and with that translate held still (the part posed again without it, in proportion to each vertex's weight on the bone and those under it) the ratio passes — the row adds `carried_by`, `breath_rig_px`, `root_without_breath_rig_px` and `tip_without_breath_rig_px`; take that bone's segment off the mesh, or bind the root to the bone the part hangs from |
|
|
1795
1810
|
| `CHECK_STILL_REGIONS_DARK` | a slot of the face's still set (the `face` slots and every slot drawn over the face that the head bone alone carries) moves relative to the bone the face's slot rides, or a `footwear` slot moves on the screen, by more than the arithmetic's own rounding — measured from the idle's posed geometry (the slot, its largest displacement in rig px, the idle frame and the tolerance quoted) | on the face: a face mesh with a weight on a bone other than the head (`segments`), or one deformed by another bone; on the feet: a mesh weighted to a swinging bone, or a region on the wrong bone. A bang swinging over the face is not this line's failure: its depth is reported (`crossing`), with no bar |
|
|
1796
1811
|
| `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other |
|
|
1797
1812
|
| `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR`, `CHECK_GEOMETRY_FILE` | rigc's render is not what its `frames.json` says, or its `geometry.json` is not a whole `rigc-geometry/1` export of the frames beside it (a frame count, a mesh whose vertex count differs from its rest entry; for the face half, another viewport than `frames.json`'s, other frame indices, the head bone or a feature's bone missing from a frame, a non-finite transform or time) | a rigc problem; report it |
|
|
@@ -1967,8 +1982,9 @@ The fields are now `root_rig_px` and `tip_rig_px`, rig px, beside `instrument:
|
|
|
1967
1982
|
"geometry"`, so an old and a new `check.json` are not read as one instrument. The bar
|
|
1968
1983
|
is the one set from the pixel instrument (half of reference-demo's `bottomwear` 2.945, by the
|
|
1969
1984
|
rule above, not chased); reference-demo's exact 3.222 clears it 2.19 times. The figures the two
|
|
1970
|
-
instruments give the examples, and `scarf` (whose `bottomwear`
|
|
1971
|
-
mode because its lattice
|
|
1985
|
+
instruments give the examples, and `scarf` (whose `bottomwear` was tracked in contour
|
|
1986
|
+
mode because its lattice failed the line, until issue #262 found the cause in a `chest`
|
|
1987
|
+
segment and the lattice read 3.487 — its README):
|
|
1972
1988
|
|
|
1973
1989
|
| part | pixel, `--max 640` (until #118) | geometry |
|
|
1974
1990
|
| --- | --- | --- |
|
package/docs/STABILITY.md
CHANGED
|
@@ -52,7 +52,7 @@ defaults are the help's too, and none is guessed from the art.
|
|
|
52
52
|
| `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll` |
|
|
53
53
|
| `assemble` | `--propose-plan` with `--source`, `--full`, `--head`, `--config`; or `--source`, `--full`, `--head`, `--config`, `--out`, optional `--seam` (near-white, silhouette), `--project` (core, visible) |
|
|
54
54
|
| `propose` | `--head-box` with `--full`, `--canvas` |
|
|
55
|
-
| `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`) |
|
|
55
|
+
| `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`) |
|
|
56
56
|
| `compare` | `--left`, `--right`; optional `--map` |
|
|
57
57
|
| `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
|
|
58
58
|
| `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills` |
|
|
@@ -94,12 +94,15 @@ rules that make the contract:
|
|
|
94
94
|
- **`constraints`** are rig-c's own rig-spec constraints (`RigConstraint` in
|
|
95
95
|
rig-c's `src/rig.ts`), handed to rigc as written; this loader checks their
|
|
96
96
|
`type`, their `name` and that every bone they name resolves.
|
|
97
|
+
- **`turn`** (issue #251) adds a control bone `turn` under `root` that nothing
|
|
98
|
+
keys, and carries each part in `depths` by `(pivot − depth) / (pivot − nearest
|
|
99
|
+
depth)` of its x. Without the field no byte of any output moves.
|
|
97
100
|
|
|
98
101
|
The fields, object by object (a bold path is where the object sits; `<part>` is a
|
|
99
102
|
part name, `[i]` an entry of a list):
|
|
100
103
|
|
|
101
104
|
<!-- stable:config -->
|
|
102
|
-
- **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `constraints`
|
|
105
|
+
- **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `constraints`, `turn`
|
|
103
106
|
- **generation** — required: `checkpoint`, `loras`, `trigger`, `identity`, `sampler`, `costume`, `negative_extra`, `style`, `negative_pose`, `latent`, `seed`; optional: `pose`, `control`
|
|
104
107
|
- **generation.loras[i]** — required: `name`, `strength`; optional: `strength_clip`
|
|
105
108
|
- **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
|
|
@@ -138,7 +141,9 @@ part name, `[i]` an entry of a list):
|
|
|
138
141
|
- **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
|
|
139
142
|
- **motion.blink.still.<part>** — required: `row`, `bone`
|
|
140
143
|
- **motion.animations_from**, the file it names — required: `spec`, `animations`
|
|
141
|
-
- **
|
|
144
|
+
- **turn** — required: `pivot`, `depths`
|
|
145
|
+
- **turn.depths.<part>** — a See-through depth
|
|
146
|
+
- **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `motion` under the rules above, `turn` with `--full`/`--head`, and `notes`
|
|
142
147
|
<!-- /stable:config -->
|
|
143
148
|
|
|
144
149
|
`TY14` holds the set of field names in backticks above to the set of names the
|
|
@@ -159,7 +164,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
|
|
|
159
164
|
| `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
|
|
160
165
|
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
|
|
161
166
|
| `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
|
|
162
|
-
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
|
|
167
|
+
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
|
|
163
168
|
| `basis.json` | `propose` | spec `spine-parts-basis/1` |
|
|
164
169
|
| the scene report | `compose` | spec `spine-parts-scene-report/1` |
|
|
165
170
|
| `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/build.ts
CHANGED
|
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, writeParts } from './parts.ts';
|
|
|
82
82
|
import { encodePngBytes, readPng, writePng } from './raster/png.ts';
|
|
83
83
|
import type { Raster } from './raster/types.ts';
|
|
84
84
|
import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
|
|
85
|
-
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
|
|
85
|
+
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
|
|
86
|
+
import { TURN_BONE } from './turn.ts';
|
|
86
87
|
import { pyRound } from './round.ts';
|
|
87
88
|
|
|
88
89
|
/** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
|
|
@@ -822,6 +823,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
822
823
|
);
|
|
823
824
|
if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
|
|
824
825
|
log(faceAxisLine(rig.faceAxis));
|
|
826
|
+
if (rig.turn !== null) log(turnLine(rig.turn));
|
|
825
827
|
log(
|
|
826
828
|
rig.idleKeys === 'ctl'
|
|
827
829
|
? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
|
|
@@ -971,6 +973,17 @@ export function faceAxisLine(f: FaceAxis): string {
|
|
|
971
973
|
return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}${tips}${up}`;
|
|
972
974
|
}
|
|
973
975
|
|
|
976
|
+
/**
|
|
977
|
+
* The rig stage's turn line (issue #251): the control, and per carried part its fraction, the depth it came from and
|
|
978
|
+
* how many twins carry it — a part at the pivot depth said to stay. Printed only when the config has `turn`.
|
|
979
|
+
*/
|
|
980
|
+
export function turnLine(rows: readonly TurnRow[]): string {
|
|
981
|
+
const carried = rows.filter((r) => r.twins.length > 0);
|
|
982
|
+
const twins = carried.reduce((n, r) => n + r.twins.length, 0);
|
|
983
|
+
const parts = rows.map((r) => (r.twins.length === 0 ? `${r.part} stays (depth ${r.depth}, the pivot's)` : `${r.part} ${r.fraction} (depth ${r.depth}, ${r.twins.length} twin(s))`));
|
|
984
|
+
return ` turn: control "${TURN_BONE}" under root, keyed by nothing; ${carried.length} part(s) carried by ${carried.length} transform constraint(s) over ${twins} twin bone(s), each part's x a fraction of the control's: ${parts.join(', ')}`;
|
|
985
|
+
}
|
|
986
|
+
|
|
974
987
|
/** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
|
|
975
988
|
export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
|
|
976
989
|
if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
|
package/src/check.ts
CHANGED
|
@@ -2450,10 +2450,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2450
2450
|
rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2451
2451
|
if (!ok) {
|
|
2452
2452
|
failed = true;
|
|
2453
|
+
// Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
|
|
2454
|
+
const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
|
|
2455
|
+
if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
|
|
2456
|
+
const why =
|
|
2457
|
+
carry === null
|
|
2458
|
+
? "the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain"
|
|
2459
|
+
: `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``;
|
|
2453
2460
|
problems.push({
|
|
2454
2461
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
2455
2462
|
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
2456
|
-
detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required —
|
|
2463
|
+
detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${why}`,
|
|
2457
2464
|
});
|
|
2458
2465
|
}
|
|
2459
2466
|
}
|
|
@@ -2465,6 +2472,110 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2465
2472
|
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2466
2473
|
}
|
|
2467
2474
|
|
|
2475
|
+
/**
|
|
2476
|
+
* The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
|
|
2477
|
+
* bone for an attachment that carries no weights.
|
|
2478
|
+
*/
|
|
2479
|
+
function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
|
|
2480
|
+
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
|
|
2481
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2482
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2483
|
+
const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
|
|
2484
|
+
const bound = new Set<string>();
|
|
2485
|
+
for (const a of atts) {
|
|
2486
|
+
if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
|
|
2487
|
+
if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
|
|
2488
|
+
}
|
|
2489
|
+
return bound;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/** The idle track properties that move a bone without turning it: the breath is one of these (issue #262). */
|
|
2493
|
+
const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* The bone a failing part's root travels with, when that is the breath's (issue #262). The breath is every idle
|
|
2497
|
+
* track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
|
|
2498
|
+
* `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
|
|
2499
|
+
* add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
|
|
2500
|
+
* region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
|
|
2501
|
+
* proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
|
|
2502
|
+
* them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and, held
|
|
2503
|
+
* still, the halves pass the bar: tip >= {@link TIP_RATIO_FLOOR} x root, the tip above 0 at the line's precision
|
|
2504
|
+
* (round3). The margin is the bar's own, so the line names the carrier exactly when the breath is what fails it;
|
|
2505
|
+
* the carrier named is the bone the part is bound to ({@link boundBones}) at or under `K` that no other such bone is
|
|
2506
|
+
* above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
|
|
2507
|
+
*/
|
|
2508
|
+
function breathCarrier(
|
|
2509
|
+
rig: Record<string, unknown>,
|
|
2510
|
+
motion: Record<string, unknown>,
|
|
2511
|
+
part: string,
|
|
2512
|
+
geometryPath: string,
|
|
2513
|
+
frames: number,
|
|
2514
|
+
m: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
|
|
2515
|
+
): { bone: string; keyed: string; breath: number; root: number; tip: number } | null {
|
|
2516
|
+
const anims = isRecord(motion.animations) ? motion.animations : {};
|
|
2517
|
+
const idle = isRecord(anims.idle) ? anims.idle : {};
|
|
2518
|
+
const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
|
|
2519
|
+
if (keyed.length === 0) return null;
|
|
2520
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2521
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2522
|
+
const setupAt = new Map(bones.map((b) => [String(b.name), [typeof b.x === 'number' ? b.x : 0, typeof b.y === 'number' ? b.y : 0] as const]));
|
|
2523
|
+
const atOrUnder = (b: string, anc: string): boolean => {
|
|
2524
|
+
const seen = new Set<string>();
|
|
2525
|
+
for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
2526
|
+
if (x === anc) return true;
|
|
2527
|
+
seen.add(x);
|
|
2528
|
+
}
|
|
2529
|
+
return false;
|
|
2530
|
+
};
|
|
2531
|
+
// The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
|
|
2532
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2533
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2534
|
+
const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
|
|
2535
|
+
const first = m.shown[0];
|
|
2536
|
+
if (atts.length !== 1 || first === null || first === undefined) return null;
|
|
2537
|
+
const att = atts[0];
|
|
2538
|
+
if (!isRecord(att) || !Array.isArray(att.weights) || att.weights.length * 2 !== first.rest.length) return null;
|
|
2539
|
+
const weights = att.weights.map((v) => (Array.isArray(v) ? v.filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') : []));
|
|
2540
|
+
const geo = readGeometry(geometryPath, frames);
|
|
2541
|
+
if (geo === null) return null;
|
|
2542
|
+
const world = (bs: unknown, name: string): BoneWorld | null => {
|
|
2543
|
+
const b = Array.isArray(bs) ? bs.filter(isRecord).find((x) => x.name === name) : undefined;
|
|
2544
|
+
if (b === undefined) return null;
|
|
2545
|
+
const f = [b.a, b.b, b.c, b.d, b.worldX, b.worldY];
|
|
2546
|
+
if (!f.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
|
|
2547
|
+
return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
|
|
2548
|
+
};
|
|
2549
|
+
const bound = [...boundBones(rig, part)];
|
|
2550
|
+
for (const k of keyed) {
|
|
2551
|
+
const under = bound.filter((b) => atOrUnder(b, k));
|
|
2552
|
+
const carrier = under.find((b) => !under.some((o) => o !== b && atOrUnder(b, o)));
|
|
2553
|
+
if (carrier === undefined) continue;
|
|
2554
|
+
// What the translate keys add to K's world origin, frame by frame, less frame 0's.
|
|
2555
|
+
const [lx, ly] = setupAt.get(k) ?? [0, 0];
|
|
2556
|
+
const pk = parent.get(k) ?? null;
|
|
2557
|
+
const added: Array<[number, number] | null> = geo.frameBones.map((bs) => {
|
|
2558
|
+
const w = world(bs, k);
|
|
2559
|
+
if (w === null) return null;
|
|
2560
|
+
const pw = pk === null ? { a: 1, b: 0, c: 0, d: 1, worldX: 0, worldY: 0 } : world(bs, pk);
|
|
2561
|
+
if (pw === null) return null;
|
|
2562
|
+
return [w.worldX - (pw.a * lx + pw.b * ly + pw.worldX), w.worldY - (pw.c * lx + pw.d * ly + pw.worldY)];
|
|
2563
|
+
});
|
|
2564
|
+
const a0 = added[0];
|
|
2565
|
+
if (a0 === null || added.some((d) => d === null)) continue;
|
|
2566
|
+
const delta = (added as Array<[number, number]>).map(([x, y]) => [x - a0[0], y - a0[1]] as const);
|
|
2567
|
+
const breath = Math.max(...delta.map(([x, y]) => Math.hypot(x, y)));
|
|
2568
|
+
if (!(breath > 0)) continue;
|
|
2569
|
+
const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
|
|
2570
|
+
const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
|
|
2571
|
+
const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
|
|
2572
|
+
if (t.root === null || t.tip === null) continue;
|
|
2573
|
+
if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0 || !(t.tip >= TIP_RATIO_FLOOR * t.root)) continue;
|
|
2574
|
+
return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip };
|
|
2575
|
+
}
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2468
2579
|
/**
|
|
2469
2580
|
* Which way the chain carrying a part runs (issue #231), for a part the scene
|
|
2470
2581
|
* declares `rises`: the bones its attachments are bound to in the default skin
|
|
@@ -2480,15 +2591,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2480
2591
|
export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
|
|
2481
2592
|
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2482
2593
|
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2483
|
-
const
|
|
2484
|
-
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2485
|
-
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2486
|
-
const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
|
|
2487
|
-
const bound = new Set<string>();
|
|
2488
|
-
for (const a of atts) {
|
|
2489
|
-
if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
|
|
2490
|
-
if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
|
|
2491
|
-
}
|
|
2594
|
+
const bound = boundBones(rig, part);
|
|
2492
2595
|
const under = (b: string, anc: string): boolean => {
|
|
2493
2596
|
const seen = new Set<string>();
|
|
2494
2597
|
for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
package/src/config.ts
CHANGED
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
* object the loader vouches for, and the rig stage leaves them out of what it
|
|
56
56
|
* hands rigc ({@link constraintForRig}).
|
|
57
57
|
*
|
|
58
|
+
* 🧭 **`turn` is this package's (issue #251, `src/turn.ts`).** An optional
|
|
59
|
+
* `{pivot, depths}`: each carried part's See-through `depth_median` and the
|
|
60
|
+
* depth that stays put, both stated — `propose --full/--head` drafts them —
|
|
61
|
+
* from which the rig stage derives every fraction ({@link checkTurn}).
|
|
62
|
+
*
|
|
58
63
|
* 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
|
|
59
64
|
* optional path, relative to the config file's directory, to a file in
|
|
60
65
|
* rig-c's motion-spec shape whose `animations` are written into `motion.json`
|
|
@@ -75,6 +80,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
|
|
|
75
80
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
76
81
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
77
82
|
import { acceptedTagNames, readTag } from './tags.ts';
|
|
83
|
+
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
78
84
|
|
|
79
85
|
export type Point = [number, number];
|
|
80
86
|
|
|
@@ -604,6 +610,8 @@ export interface CharacterConfig {
|
|
|
604
610
|
regions: Record<string, string>;
|
|
605
611
|
motion: Motion;
|
|
606
612
|
constraints?: ConfigConstraint[];
|
|
613
|
+
/** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
|
|
614
|
+
turn?: TurnSpec;
|
|
607
615
|
}
|
|
608
616
|
|
|
609
617
|
/** The fields of one constraint kind, as rig-c's interface for that kind declares them. */
|
|
@@ -885,7 +893,7 @@ export interface AssembleConfig {
|
|
|
885
893
|
assemble: Assemble;
|
|
886
894
|
}
|
|
887
895
|
|
|
888
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints'] as const;
|
|
896
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn'] as const;
|
|
889
897
|
|
|
890
898
|
/**
|
|
891
899
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1086,6 +1094,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1086
1094
|
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
|
|
1087
1095
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1088
1096
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1097
|
+
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
1089
1098
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1090
1099
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1091
1100
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
@@ -2281,3 +2290,68 @@ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<st
|
|
|
2281
2290
|
}
|
|
2282
2291
|
});
|
|
2283
2292
|
}
|
|
2293
|
+
|
|
2294
|
+
/** The parts `meshes` and `regions` attach, read off whatever they hold; `turn.depths` may name only these. */
|
|
2295
|
+
function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
2296
|
+
const out = new Set<string>();
|
|
2297
|
+
for (const v of [meshes, regions]) if (isPlainObject(v)) for (const k of Object.keys(v)) out.add(k);
|
|
2298
|
+
return out;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* `config.turn` (issue #251, `src/turn.ts`): `pivot`, the depth that stays put
|
|
2303
|
+
* when the control moves, and `depths`, each carried part's See-through
|
|
2304
|
+
* `depth_median` as `propose` read it. Neither has a default: a config without
|
|
2305
|
+
* `turn` gets no control, and one with it states both.
|
|
2306
|
+
*
|
|
2307
|
+
* - `CONFIG_FIELD_TYPE` / `CONFIG_FIELD_PRESENT` / `CONFIG_KEY_KNOWN`: not an
|
|
2308
|
+
* object; `pivot` or `depths` absent; another key (records and annotations
|
|
2309
|
+
* aside); a pivot or a depth that is not a finite number; `depths` not an
|
|
2310
|
+
* object or empty.
|
|
2311
|
+
* - `CONFIG_NAME_RESOLVES`: a `depths` key that names no part `meshes` or
|
|
2312
|
+
* `regions` attaches. Its keys are part names, so no annotation or record
|
|
2313
|
+
* door is open there.
|
|
2314
|
+
* - `CONFIG_TURN_PIVOT_BEHIND_NEAREST`: a pivot at or in front of the nearest
|
|
2315
|
+
* part's depth. The law divides by `pivot − nearest`, so at the nearest depth
|
|
2316
|
+
* it has no value, and in front of it every part would travel against the
|
|
2317
|
+
* dial — no part would be nearer than what stays put.
|
|
2318
|
+
* - `CONFIG_TURN_BONE_FREE`: `bones` declares a bone named {@link TURN_BONE},
|
|
2319
|
+
* the control the rig stage adds.
|
|
2320
|
+
*/
|
|
2321
|
+
function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
|
|
2322
|
+
const at = 'config.turn';
|
|
2323
|
+
const o = c.object(at, v, ['pivot', 'depths'], []);
|
|
2324
|
+
if (o === null) return;
|
|
2325
|
+
const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
|
|
2326
|
+
const depths: Record<string, number> = {};
|
|
2327
|
+
let depthsOk = false;
|
|
2328
|
+
if ('depths' in o) {
|
|
2329
|
+
const d = o.depths;
|
|
2330
|
+
if (!isPlainObject(d) || Object.keys(d).length === 0) {
|
|
2331
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.depths`, `is ${show(d)}; a non-empty object of part name -> See-through depth_median is required`);
|
|
2332
|
+
} else {
|
|
2333
|
+
depthsOk = true;
|
|
2334
|
+
for (const [part, depth] of Object.entries(d)) {
|
|
2335
|
+
if (!attached.has(part)) {
|
|
2336
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.depths.${part}`, `names "${part}", which neither config.meshes nor config.regions attaches; a part the rig draws is required — a depth for a part nobody draws carries nothing`);
|
|
2337
|
+
depthsOk = false;
|
|
2338
|
+
}
|
|
2339
|
+
if (c.number(`${at}.depths.${part}`, depth)) depths[part] = depth;
|
|
2340
|
+
else depthsOk = false;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (pivot !== null && depthsOk) {
|
|
2345
|
+
const nearest = turnNearest(depths);
|
|
2346
|
+
if (nearest !== null && !(pivot > nearest.depth)) {
|
|
2347
|
+
c.fail(
|
|
2348
|
+
'CONFIG_TURN_PIVOT_BEHIND_NEAREST',
|
|
2349
|
+
`${at}.pivot`,
|
|
2350
|
+
`is ${pivot}; the nearest part, "${nearest.part}", lies at depth ${nearest.depth}, and a pivot above that (further back) is required — each part travels (pivot − depth) / (pivot − ${nearest.depth}) of the control's offset, which has no value at the nearest depth and turns every part against the dial in front of it`,
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (bones.has(TURN_BONE)) {
|
|
2355
|
+
c.fail('CONFIG_TURN_BONE_FREE', `bone "${TURN_BONE}"`, `is the control the rig stage adds under root for config.turn, and config.bones already declares a bone of that name; rename it`);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
package/src/propose.ts
CHANGED
|
@@ -117,7 +117,7 @@ import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
|
117
117
|
import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
118
118
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
119
119
|
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
120
|
-
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
120
|
+
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
121
121
|
import { PAINTING_RUN, type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
122
122
|
import { connectedComponents } from './raster/components.ts';
|
|
123
123
|
import { dilate, morphGradient } from './raster/morph.ts';
|
|
@@ -130,6 +130,7 @@ import { IDLE_FPS } from './motion.ts';
|
|
|
130
130
|
import { pyRound as roundTo } from './round.ts';
|
|
131
131
|
import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
|
|
132
132
|
import { configRoles, type RoleOf } from './structure.ts';
|
|
133
|
+
import { TURN_BONE, turnFractions, turnNearest, turnPivot, type TurnSpec } from './turn.ts';
|
|
133
134
|
|
|
134
135
|
// ---------------------------------------------------------------------------
|
|
135
136
|
// the output shape — config-shaped, key order as the reference writes it
|
|
@@ -212,6 +213,8 @@ export interface Proposal {
|
|
|
212
213
|
/** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
|
|
213
214
|
blink?: ProposedBlink;
|
|
214
215
|
};
|
|
216
|
+
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
217
|
+
turn?: TurnSpec;
|
|
215
218
|
notes: string[];
|
|
216
219
|
}
|
|
217
220
|
|
|
@@ -368,6 +371,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
|
|
|
368
371
|
meshes: p.meshes,
|
|
369
372
|
regions: p.regions,
|
|
370
373
|
motion: p.motion,
|
|
374
|
+
...(p.turn === undefined ? {} : { turn: p.turn }),
|
|
371
375
|
});
|
|
372
376
|
}
|
|
373
377
|
|
|
@@ -376,6 +380,98 @@ export function serializeProposal(p: Proposal): string {
|
|
|
376
380
|
return `${JSON.stringify(p, null, 2)}\n`;
|
|
377
381
|
}
|
|
378
382
|
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// the turn control (issue #251)
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/** The See-through runs `propose --full/--head` reads depths from, by run. */
|
|
388
|
+
export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Draft `turn` (issue #251, `src/turn.ts`) from the parts and the See-through
|
|
392
|
+
* runs they came from: each part's depth is its layer's `depth_median` — the
|
|
393
|
+
* layer of its run named by its tag, a cut's piece its base's — and the pivot
|
|
394
|
+
* is {@link turnPivot} over the parts' `opaque_px`. A `painting:` patch has no
|
|
395
|
+
* layer and so no depth: it is not carried, and a note names it. What a depth
|
|
396
|
+
* cannot be read for is refused by name, every one at once:
|
|
397
|
+
*
|
|
398
|
+
* - `PROPOSE_TURN_RUN_GIVEN`: a part from a run whose layers were not given.
|
|
399
|
+
* - `PROPOSE_TURN_LAYER_PRESENT`: the run given holds no layer of the part's tag.
|
|
400
|
+
* - `PROPOSE_TURN_DEPTH_PRESENT`: the layer carries no depth (a PSD carries none).
|
|
401
|
+
*
|
|
402
|
+
* With no part to carry, or every carried part at one depth (nothing nearer
|
|
403
|
+
* than the pivot), no `turn` is drafted and a note says why. Otherwise one note
|
|
404
|
+
* states the pivot and its rule, the law, and every part's fraction with the
|
|
405
|
+
* depth it came from.
|
|
406
|
+
*/
|
|
407
|
+
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
408
|
+
const problems: Problem[] = [];
|
|
409
|
+
const rows: Array<{ part: string; depth: number; area: number }> = [];
|
|
410
|
+
const patches: string[] = [];
|
|
411
|
+
const unread = new Map<string, string[]>();
|
|
412
|
+
for (const r of P.recs) {
|
|
413
|
+
const [run, tag] = splitFrom(r.from);
|
|
414
|
+
if (run === PAINTING_RUN) {
|
|
415
|
+
patches.push(r.name);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const set = run === 'full' || run === 'head' ? runs[run] : undefined;
|
|
419
|
+
if (set === undefined) {
|
|
420
|
+
unread.set(run, [...(unread.get(run) ?? []), r.name]);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const layer = set.layers.find((l) => l.name === tag);
|
|
424
|
+
if (layer === undefined) {
|
|
425
|
+
problems.push({ code: 'PROPOSE_TURN_LAYER_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `came from the ${run} run's "${tag}" layer, and ${set.source} holds no layer of that name; the run its parts were assembled from is required` });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (layer.depth === null) {
|
|
429
|
+
problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `its layer in ${set.source} carries no depth_median (a ${set.form} input); the turn orders parts by See-through's per-layer depth, so a run that carries one is required` });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
rows.push({ part: r.name, depth: layer.depth, area: r.opaque_px });
|
|
433
|
+
}
|
|
434
|
+
for (const [run, names] of unread) {
|
|
435
|
+
problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
|
|
436
|
+
}
|
|
437
|
+
refuseIfAny(problems);
|
|
438
|
+
const notes: string[] = [];
|
|
439
|
+
if (patches.length > 0) {
|
|
440
|
+
notes.push(`turn: painting patch(es) ${patches.map((n) => `"${n}"`).join(', ')} carry no See-through depth and are not carried — each moves only as its bone does, as the pivot depth does; a value in config.turn.depths carries one`);
|
|
441
|
+
}
|
|
442
|
+
const pivot = turnPivot(rows);
|
|
443
|
+
const depths = Object.create(null) as Record<string, number>;
|
|
444
|
+
for (const r of rows) depths[r.part] = r.depth;
|
|
445
|
+
const nearest = turnNearest(depths);
|
|
446
|
+
if (pivot === null || nearest === null) {
|
|
447
|
+
notes.push('turn: no part carries a See-through depth with any opaque pixel, so no turn control is drafted');
|
|
448
|
+
return { turn: null, notes };
|
|
449
|
+
}
|
|
450
|
+
if (!(pivot > nearest.depth)) {
|
|
451
|
+
notes.push(`turn: every carried part lies at depth ${nearest.depth}, so none is nearer than the pivot and no turn control is drafted`);
|
|
452
|
+
return { turn: null, notes };
|
|
453
|
+
}
|
|
454
|
+
const turn: TurnSpec = { pivot, depths };
|
|
455
|
+
const fr = turnFractions(turn, rows.map((r) => r.part));
|
|
456
|
+
notes.push(
|
|
457
|
+
`turn: control "${TURN_BONE}" under root, keyed by nothing — its x is the dial. Pivot depth ${pivot}: the mean of the ${rows.length} carried parts' See-through depth_median weighted by opaque_px, the one depth at which the painted pixels' centroid does not slide (correct it in turn.pivot). ` +
|
|
458
|
+
`Each part travels (pivot - depth) / (pivot - ${nearest.depth}) of the control's x, "${nearest.part}" (nearest) all of it: ${fr.map((f) => `${f.part} ${f.fraction} (depth ${f.depth})`).join(', ')}`,
|
|
459
|
+
);
|
|
460
|
+
notes.push(...TURN_DEPTH_CAVEAT);
|
|
461
|
+
return { turn, notes };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The notes beside every drafted turn (issue #251): what a See-through depth is, where it misleads, and that the two runs' axes are not reconciled. */
|
|
465
|
+
export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
466
|
+
"turn: depth is the See-through layer's median and each part takes its own run's axis; a seated or forward-reaching limb can read further back than it is (trailer legwear 0.770 sits behind the pivot)",
|
|
467
|
+
"turn: the two runs' depth axes are not reconciled (parts#271)",
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
471
|
+
export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
|
|
472
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, motion: p.motion, ...(draft.turn === null ? {} : { turn: draft.turn }), notes: [...p.notes, ...draft.notes] };
|
|
473
|
+
}
|
|
474
|
+
|
|
379
475
|
// ---------------------------------------------------------------------------
|
|
380
476
|
// the parts, as masks
|
|
381
477
|
// ---------------------------------------------------------------------------
|
|
@@ -1762,6 +1858,20 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1762
1858
|
['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
|
|
1763
1859
|
];
|
|
1764
1860
|
}
|
|
1861
|
+
// Issue #262: a skirt whose art starts below the chest bone hangs from the waist. Its root is the hip line's,
|
|
1862
|
+
// and the chest segment goes: with it the breath carries the waistband (a vertex near the chest segment's
|
|
1863
|
+
// lower end takes chest weight), so the root travels with the breath while the hem, on the skirt's chains,
|
|
1864
|
+
// does not. A bottomwear reaching up past the chest (the long robe the hip rule above reads the waist for) has
|
|
1865
|
+
// art on the chest and keeps the segment. Read along the trunk where there is one, down the screen otherwise.
|
|
1866
|
+
const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
|
|
1867
|
+
const artTop = artExtent(mask, p, hip, down)[0];
|
|
1868
|
+
const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
|
|
1869
|
+
if (artTop >= chestAt) {
|
|
1870
|
+
segs = segs.filter((s) => typeof s === 'string' || s[0] !== 'chest');
|
|
1871
|
+
notes.push(
|
|
1872
|
+
`${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1765
1875
|
for (const [tag, f, ph] of [
|
|
1766
1876
|
['skirt_r', 0.3, 0.3],
|
|
1767
1877
|
['skirt_c', 0.5, 0.34],
|
package/src/rig.ts
CHANGED
|
@@ -103,6 +103,11 @@
|
|
|
103
103
|
* bone a constraint follows is declared detached from the bones it drives
|
|
104
104
|
* ({@link detachedRules}, `invariants.detached`, rigc's `A25`). Without the
|
|
105
105
|
* field, no byte of any output moves.
|
|
106
|
+
* - **The turn control**, only when the config has `turn` (issue #251,
|
|
107
|
+
* `src/turn.ts`, {@link applyTurn}): a bone `turn` under `root` nothing
|
|
108
|
+
* keys, a leaf twin of every bone a carried part binds, and one transform
|
|
109
|
+
* constraint per part carrying its twins `fraction · turn.x` in world x,
|
|
110
|
+
* after the config's own constraints. Without the field nothing is added.
|
|
106
111
|
*
|
|
107
112
|
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
108
113
|
* before it is meshed or placed, and the padded image is what `images/`
|
|
@@ -155,6 +160,7 @@ import { EYE_GROUP_TAGS } from './propose.ts';
|
|
|
155
160
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
156
161
|
import { pyRound } from './round.ts';
|
|
157
162
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
|
+
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
158
164
|
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
|
|
159
165
|
|
|
160
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -441,6 +447,104 @@ export function detachedRules(constraints: readonly ConfigConstraint[]): Detache
|
|
|
441
447
|
return out;
|
|
442
448
|
}
|
|
443
449
|
|
|
450
|
+
/** One carried part of the turn control (issue #251): its depth, its fraction, and the twins that carry it. */
|
|
451
|
+
export interface TurnRow {
|
|
452
|
+
part: string;
|
|
453
|
+
depth: number;
|
|
454
|
+
fraction: number;
|
|
455
|
+
/** Each bone the part's slots and weights bind to, with the leaf twin that now carries it; empty when the fraction is 0 — the part stays with the pivot. */
|
|
456
|
+
twins: Array<{ bone: string; twin: string }>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The turn control (issue #251, `src/turn.ts`), added to a built rig in place:
|
|
461
|
+
* the control {@link TURN_BONE} under `root` at its origin, nothing keying it;
|
|
462
|
+
* for each part `config.turn.depths` lists, in parts.json order, a leaf twin of
|
|
463
|
+
* every bone the part's slots bind (its region's bone, a blink.still piece's
|
|
464
|
+
* bone, a mesh's slot bone and every bone its weights name) — an identity child,
|
|
465
|
+
* x 0, y 0, no rotation, so its world transform is its bone's exactly and every
|
|
466
|
+
* idle key, physics step or constraint on the bone reaches it — the part's
|
|
467
|
+
* slots and weights moved onto the twins, and one transform constraint per part
|
|
468
|
+
* that adds `fraction · turn.x` to each twin's world x (`localSource`, read
|
|
469
|
+
* off the control's local x; world target; `additive`).
|
|
470
|
+
*
|
|
471
|
+
* Why its own twins and not the `<bone>_ctl` controls: a control is a parent,
|
|
472
|
+
* keyed by the idle, and carries every part on its bone and every bone under
|
|
473
|
+
* it, so it cannot hold two parts at two depths; a leaf twin per part and bone
|
|
474
|
+
* moves that part and nothing else, and nothing else keys or constrains it. A
|
|
475
|
+
* mesh moves as a whole because its weights sum to 1 and every twin it binds
|
|
476
|
+
* moves by the same world offset.
|
|
477
|
+
*
|
|
478
|
+
* At rest the control's x is 0, so each constraint adds `0 · fraction` to
|
|
479
|
+
* world x. At rest: with `turn` declared, no pixel differs by more than 1
|
|
480
|
+
* level at dial 0 (not byte-identical; the cause is float noise in the
|
|
481
|
+
* identity child bones, and the constraints add nothing); without `turn`,
|
|
482
|
+
* byte-identical. The constraints come
|
|
483
|
+
* after the config's own, so a physics constraint on a bone has run before its
|
|
484
|
+
* twin is moved. A part at the pivot depth (fraction 0) gets no twin and no
|
|
485
|
+
* constraint. A name the turn needs that is taken is refused by name
|
|
486
|
+
* (`RIG_TURN_NAME_FREE`).
|
|
487
|
+
*/
|
|
488
|
+
function applyTurn(
|
|
489
|
+
turn: TurnSpec,
|
|
490
|
+
order: readonly string[],
|
|
491
|
+
bones: RigBone[],
|
|
492
|
+
slots: RigSpec['slots'],
|
|
493
|
+
skin: RigSpec['skins']['default'],
|
|
494
|
+
constraints: readonly ConfigConstraint[],
|
|
495
|
+
): { constraints: Array<Record<string, unknown>>; detached: DetachedRule[]; rows: TurnRow[] } {
|
|
496
|
+
const problems: Problem[] = [];
|
|
497
|
+
const taken = new Set(bones.map((b) => b.name));
|
|
498
|
+
const transforms = new Set(constraints.filter((c) => c.type === 'transform').map((c) => c.name));
|
|
499
|
+
if (taken.has(TURN_BONE)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${TURN_BONE}"`, detail: 'is the control the rig stage adds for config.turn, and the rig already has a bone of that name; rename it' });
|
|
500
|
+
const rows: TurnRow[] = [];
|
|
501
|
+
for (const f of turnFractions(turn, order)) {
|
|
502
|
+
const row: TurnRow = { ...f, twins: [] };
|
|
503
|
+
rows.push(row);
|
|
504
|
+
if (f.fraction === 0) continue;
|
|
505
|
+
const mine = slots.filter((sl) => sl.name === f.part || sl.name === `${f.part}${STILL_SUFFIX}`);
|
|
506
|
+
const bound: string[] = [];
|
|
507
|
+
const bind = (b: string): void => {
|
|
508
|
+
if (!bound.includes(b)) bound.push(b);
|
|
509
|
+
};
|
|
510
|
+
for (const sl of mine) {
|
|
511
|
+
bind(sl.bone);
|
|
512
|
+
for (const att of Object.values(skin[sl.name] ?? {})) if ('type' in att) for (const v of att.weights) for (const w of v) bind(w.bone);
|
|
513
|
+
}
|
|
514
|
+
for (const b of bound) {
|
|
515
|
+
const twin = turnTwin(f.part, b);
|
|
516
|
+
if (taken.has(twin)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${twin}"`, detail: `is the twin that carries part "${f.part}" on "${b}" under config.turn, and the rig already has a bone of that name; rename it` });
|
|
517
|
+
taken.add(twin);
|
|
518
|
+
row.twins.push({ bone: b, twin });
|
|
519
|
+
}
|
|
520
|
+
const cname = turnConstraintName(f.part);
|
|
521
|
+
if (transforms.has(cname)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `transform constraint "${cname}"`, detail: `is the constraint that carries part "${f.part}" under config.turn, and config.constraints already names a transform constraint so; rename it` });
|
|
522
|
+
}
|
|
523
|
+
refuseIfAny(problems);
|
|
524
|
+
bones.push({ name: TURN_BONE, parent: ROOT_BONE, x: 0, y: 0 });
|
|
525
|
+
const out: Array<Record<string, unknown>> = [];
|
|
526
|
+
const detached: DetachedRule[] = [];
|
|
527
|
+
for (const row of rows) {
|
|
528
|
+
if (row.twins.length === 0) continue;
|
|
529
|
+
const twinOf = new Map(row.twins.map((t) => [t.bone, t.twin]));
|
|
530
|
+
for (const t of row.twins) bones.push({ name: t.twin, parent: t.bone, x: 0, y: 0 });
|
|
531
|
+
for (const sl of slots) {
|
|
532
|
+
if (sl.name !== row.part && sl.name !== `${row.part}${STILL_SUFFIX}`) continue;
|
|
533
|
+
sl.bone = twinOf.get(sl.bone) as string;
|
|
534
|
+
const atts = skin[sl.name] ?? {};
|
|
535
|
+
for (const [k, att] of Object.entries(atts)) {
|
|
536
|
+
if ('type' in att) atts[k] = { ...att, weights: att.weights.map((v) => v.map((w) => ({ ...w, bone: twinOf.get(w.bone) as string }))) };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const cname = turnConstraintName(row.part);
|
|
540
|
+
out.push({ type: 'transform', name: cname, bones: row.twins.map((t) => t.twin), source: TURN_BONE, localSource: true, additive: true, properties: { x: { to: { x: { scale: row.fraction } } } } });
|
|
541
|
+
for (const t of row.twins) {
|
|
542
|
+
detached.push({ bone: TURN_BONE, notUnder: t.twin, why: `rig-parts: "${TURN_BONE}" is the source of transform constraint "${cname}" (config.turn, issue #251), and "${t.twin}" is a bone it drives; under it, driving it would move what it follows` });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { constraints: out, detached, rows };
|
|
546
|
+
}
|
|
547
|
+
|
|
444
548
|
/**
|
|
445
549
|
* Where the idle's keys on a mesh-driving bone go (`rig --idle-keys`).
|
|
446
550
|
*
|
|
@@ -693,6 +797,8 @@ export interface RigOutput {
|
|
|
693
797
|
autoMotion: AutoMotionCase[];
|
|
694
798
|
/** The eye line the face bones were turned to, or why there is none (issue #239). */
|
|
695
799
|
faceAxis: FaceAxis;
|
|
800
|
+
/** The turn control's parts (issue #251), in parts.json order; null when the config has no `turn`. */
|
|
801
|
+
turn: TurnRow[] | null;
|
|
696
802
|
}
|
|
697
803
|
|
|
698
804
|
/**
|
|
@@ -1637,6 +1743,9 @@ export function buildRig(
|
|
|
1637
1743
|
});
|
|
1638
1744
|
}
|
|
1639
1745
|
refuseIfAny(problems);
|
|
1746
|
+
// ---- the turn control (issue #251) -------------------------------------
|
|
1747
|
+
// Only when the config has `turn`: every other build adds nothing here, so no byte moves.
|
|
1748
|
+
const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, slots, skin, cfg.constraints ?? []);
|
|
1640
1749
|
|
|
1641
1750
|
const name = `${cfg.key}_painting`;
|
|
1642
1751
|
const rig: RigSpec = {
|
|
@@ -1649,7 +1758,8 @@ export function buildRig(
|
|
|
1649
1758
|
skins: { default: skin },
|
|
1650
1759
|
};
|
|
1651
1760
|
if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
|
|
1652
|
-
|
|
1761
|
+
if (turned !== null) rig.constraints = [...(rig.constraints ?? []), ...turned.constraints];
|
|
1762
|
+
const detached = [...detachedRules(cfg.constraints ?? []), ...(turned?.detached ?? [])];
|
|
1653
1763
|
if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
|
|
1654
1764
|
if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
|
|
1655
1765
|
// issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
|
|
@@ -1658,7 +1768,7 @@ export function buildRig(
|
|
|
1658
1768
|
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1659
1769
|
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1660
1770
|
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1661
|
-
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
|
|
1771
|
+
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
|
|
1662
1772
|
}
|
|
1663
1773
|
|
|
1664
1774
|
/**
|
package/src/turn.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn control (issue #251): one bone nothing keys, and each part carried
|
|
3
|
+
* sideways a fraction of that bone's offset, the fraction ordered by the part's
|
|
4
|
+
* See-through depth — nearest furthest, the pivot depth staying put.
|
|
5
|
+
*
|
|
6
|
+
* 🧭 **The law is a turn's, not a chosen curve.** A figure turned by a small
|
|
7
|
+
* angle θ about a vertical axis standing at depth `pivot`, seen without
|
|
8
|
+
* perspective, moves each point sideways by `sin θ · (pivot − depth)` in depth
|
|
9
|
+
* units: linear in depth, zero at the pivot, opposite signs on either side of
|
|
10
|
+
* it. The depth unit's size in pixels is nothing See-through states, so the
|
|
11
|
+
* dial is normalised by the nearest part instead:
|
|
12
|
+
*
|
|
13
|
+
* fraction(part) = (pivot − depth(part)) / (pivot − depth(nearest))
|
|
14
|
+
*
|
|
15
|
+
* The nearest part (the smallest `depth_median`, larger being further back)
|
|
16
|
+
* travels exactly the control's offset, the pivot depth 0, a part behind the
|
|
17
|
+
* pivot the other way. The dial's unit is therefore the nearest part's travel
|
|
18
|
+
* in rig px, and how far a consumer turns it is the consumer's.
|
|
19
|
+
*
|
|
20
|
+
* ⚖️ **The pivot `propose` drafts** ({@link turnPivot}) is the mean of the
|
|
21
|
+
* carried parts' depths weighted by their `opaque_px`: the one depth at which
|
|
22
|
+
* `Σ opaque_px · fraction = 0`, so the painted pixels' centroid does not slide
|
|
23
|
+
* under the dial — the figure turns about its own middle rather than drifting
|
|
24
|
+
* with it. It is a config field (`turn.pivot`), so a person corrects it there;
|
|
25
|
+
* the rig stage recomputes every fraction from it.
|
|
26
|
+
*
|
|
27
|
+
* Depths are See-through's per-layer `depth_median` only. Issue #250 measured
|
|
28
|
+
* that the depth maps do not carry relief inside a layer, so a part moves as
|
|
29
|
+
* one piece. Pure: no file, no clock.
|
|
30
|
+
*/
|
|
31
|
+
import { pyRound } from './round.ts';
|
|
32
|
+
|
|
33
|
+
/** The control bone the rig stage adds under `root` when the config has `turn`: nothing keys it, and its local x is the dial. */
|
|
34
|
+
export const TURN_BONE = 'turn';
|
|
35
|
+
|
|
36
|
+
/** The places a pivot and a fraction are written to. */
|
|
37
|
+
export const TURN_PLACES = 6;
|
|
38
|
+
|
|
39
|
+
/** `config.turn`: the pivot depth and each carried part's See-through `depth_median`. */
|
|
40
|
+
export interface TurnSpec {
|
|
41
|
+
pivot: number;
|
|
42
|
+
depths: Record<string, number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One carried part: its depth and the fraction of the control's offset it travels. */
|
|
46
|
+
export interface TurnFraction {
|
|
47
|
+
part: string;
|
|
48
|
+
depth: number;
|
|
49
|
+
fraction: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The nearest part of a turn: the smallest depth, the first such part in `depths`' key order. */
|
|
53
|
+
export function turnNearest(depths: Readonly<Record<string, number>>): { part: string; depth: number } | null {
|
|
54
|
+
let best: { part: string; depth: number } | null = null;
|
|
55
|
+
for (const [part, depth] of Object.entries(depths)) if (best === null || depth < best.depth) best = { part, depth };
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `(pivot − depth) / (pivot − nearest)`, to {@link TURN_PLACES} places, with a
|
|
61
|
+
* negative zero written as 0. The caller holds `pivot > nearest` (the loader
|
|
62
|
+
* refuses anything else, `CONFIG_TURN_PIVOT_BEHIND_NEAREST`).
|
|
63
|
+
*/
|
|
64
|
+
export function turnFraction(pivot: number, nearest: number, depth: number): number {
|
|
65
|
+
const f = pyRound((pivot - depth) / (pivot - nearest), TURN_PLACES);
|
|
66
|
+
return f === 0 ? 0 : f;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every carried part's fraction, in `order` (parts.json's draw order); a name in `order` the spec does not list is not carried. */
|
|
70
|
+
export function turnFractions(spec: TurnSpec, order: readonly string[]): TurnFraction[] {
|
|
71
|
+
const nearest = turnNearest(spec.depths);
|
|
72
|
+
if (nearest === null) return [];
|
|
73
|
+
return order.filter((p) => Object.hasOwn(spec.depths, p)).map((part) => ({ part, depth: spec.depths[part], fraction: turnFraction(spec.pivot, nearest.depth, spec.depths[part]) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The pivot `propose` drafts: `Σ area · depth / Σ area` over the carried
|
|
78
|
+
* parts, to {@link TURN_PLACES} places — the depth at which the painted
|
|
79
|
+
* pixels' centroid does not move under the dial. Null with no area.
|
|
80
|
+
*/
|
|
81
|
+
export function turnPivot(rows: ReadonlyArray<{ depth: number; area: number }>): number | null {
|
|
82
|
+
let a = 0;
|
|
83
|
+
let ad = 0;
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
a += r.area;
|
|
86
|
+
ad += r.area * r.depth;
|
|
87
|
+
}
|
|
88
|
+
return a > 0 ? pyRound(ad / a, TURN_PLACES) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The leaf bone that carries `part`'s share of `bone` under the turn: an identity child of `bone`, so it follows everything `bone` does. */
|
|
92
|
+
export function turnTwin(part: string, bone: string): string {
|
|
93
|
+
return `${TURN_BONE}_${part}_${bone}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transform constraint that carries `part`. */
|
|
97
|
+
export function turnConstraintName(part: string): string {
|
|
98
|
+
return `${TURN_BONE}_${part}`;
|
|
99
|
+
}
|