@rig-c/parts 1.32.0 → 1.33.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/docs/AUTHORING.md +21 -2
- package/docs/STABILITY.md +2 -1
- package/package.json +2 -2
- package/src/build.ts +9 -6
- package/src/config.ts +126 -4
- package/src/parts.ts +1 -0
- package/src/propose.ts +1 -1
- package/src/rig.ts +19 -2
- package/src/turn.ts +302 -45
package/docs/AUTHORING.md
CHANGED
|
@@ -108,7 +108,7 @@ read by no CPU stage).
|
|
|
108
108
|
| `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
|
|
109
109
|
| `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. See §5 for how to place one |
|
|
110
110
|
| `assemble.cuts` | assemble; `meshes`/`regions` keys may name the new parts too, and must (each is a part like any other) | **authored**, optional (issue #170): `[{from, into, polygon, draw, overlap, under, under_fill}]`, `under` and `under_fill` optional — a plan part's layer cut by a polygon the author draws: the pixels of plan part `from` whose **centre** lies inside `polygon` (even-odd; a centre exactly on an edge is outside) leave `from` and make the new part `into`, colour and alpha, so every pixel of the layer ends in exactly one of the two. `polygon` is `[[x, y], …]`, at least three points, non-negative integer **rig** pixels — the space of `parts.json` and of the patches' boxes. The file that shows the layer in those coordinates is `assemble`'s own `rig/parts/<from>.png`, placed at its `parts.json` `x`, `y` (the PNG's pixel `(i, j)` is rig pixel `(x + i, y + j)`), or `render/recomposite_rig.png`, the whole rig 1:1; the contact sheets (`sheet`) are scaled and carry no coordinates. `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`, as for a patch (to keep the setup pose, draw the piece immediately in front of `from` — `{"before": "<the plan part after from>"}`, or `"front"` when `from` is the last plan part; `{"before": "<from>"}` draws it behind `from`, which `under` refuses as `ASSEMBLE_UNDER_DRAW` because the fill would hide it). `from` must be a plan part (`CONFIG_NAME_RESOLVES`; a patch or another cut's piece is refused), `into` a new name (`CONFIG_PART_UNIQUE`), the polygon three or more integer points (`CONFIG_FIELD_TYPE`). The piece's `parts.json` record carries `from`'s `<run>:<tag>` and its own counts, each split from the layer's by where the pixel lies; it is a mesh or a region under its own name, and the coverage rule holds it like a plan part. What the config cannot know the stage refuses: a polygon point past the rig (`ASSEMBLE_CUT_INSIDE`), two cuts of one part sharing a pixel (`ASSEMBLE_CUT_OVERLAP`), a cut that takes none of the layer's opaque pixels or leaves `from` none (`ASSEMBLE_CUT_PIXELS`). `overlap` is required, a non-negative integer of rig pixels with no default (`CONFIG_FIELD_TYPE`): the **band** — the polygon's pixels within `overlap` px of its edge by Chebyshev distance (the polygon's mask less its erosion by a `2 * overlap + 1` square) — stays in `from` as well as going to the piece, so the piece's resampled edge has art under it; the counts stay a strict partition and the band is the base's extra copy, printed on the `cut:` line. At `0` the two abut and `check`'s seam bar judges the edge between them (`CHECK_SEAM_WITHIN_BAR`, §5). A band only covers the edge at rest: a piece that swings well clear of where it was painted shows the hole it leaves, and a thin piece's band shows a ghost of the piece. `under` (issue #193), optional, is the author's image of what lies under the piece: a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE` unless a non-empty string), in rig pixels over the polygon's **bounding box** — the least and greatest polygon x and y, so the image is `max x − min x` by `max y − min y` and its pixel `(i, j)` is rig pixel `(min x + i, min y + j)`. Where the cut removed pixels from `from` — the piece's pixels (alpha above 0) less the band — `from` takes the image's pixel, colour and alpha wherever the image's alpha is at least the alpha the cut removed there (**filled**); elsewhere `from` stays empty there (**left empty**): a transparent pixel is the author's statement that nothing lies under it (the background, or a part drawn behind), and a less opaque one is not half-filled. The image is read nowhere else, so its pixels over the band, over the box outside the polygon or where the layer had no art never change `from`. The `cut:` line then ends `; under <n> px filled, <m> px left empty` (`n + m` is every removed pixel), so an unintended hole is a number in the log, not a guess; the filled pixels count in `from`'s `opaque_px` and `occluded_px` (the piece is drawn over them), never as visible or projected. The stage refuses: a file that is not there (`ASSEMBLE_UNDER_PRESENT`) or not a PNG (`ASSEMBLE_UNDER_DECODES`), an image not exactly the box's size (`ASSEMBLE_UNDER_SIZE`, both sizes given), an image that fills none of the removed pixels — all transparent there, or another file (`ASSEMBLE_UNDER_EMPTY`; to leave the whole hole, drop `under`), and a piece drawn behind `from` (`ASSEMBLE_UNDER_DRAW`: the fill would hide it; draw it `"front"` or `{"before": "<the plan part after from>"}`). Producing the image is the author's, by whatever means; absent, `from` keeps only its band. `under_fill` (issue #360), optional, is the one value `"colour"` (`CONFIG_FIELD_TYPE` on any other): the same pixels are filled and left empty, and a filled pixel takes the image's **colour** at the alpha the cut removed there instead of the image's alpha, so `from`'s alpha is the uncut layer's wherever the image fills and its silhouette does not grow — an opaque image over a soft edge leaves the edge soft. It applies to the part's hidden-art layer as well as to a file; a cut with neither is refused (`ASSEMBLE_UNDER_FILL_SOURCE`). The `cut:` line's filled count then says how many took the colour only: `; under 190 px filled (190 colour only, at the alpha the cut removed), 0 px left empty`. Absent, a filled pixel takes the image's colour and alpha, as above |
|
|
111
|
-
| `assemble.alternates` | assemble, rig, check (`--set`, `--all-sets`) | **authored**, optional (issues #454, #465): `[{name, source?, full?, head?, head_box?, parts?, hides?, cuts?, skeleton?, drawn?}]` — alternate painting sets assembled into `<out>/rig/parts/<set>_<part>.png` (`parts.json` `alternates`), attached on each covered slot in `rig.json` as `<set>_<part>` beside the base attachment, and indexed in `<out>/rig/sets.json` (`rig-parts-sets/1`: `sets.<set>.slots`, `.hides`, and `.
|
|
111
|
+
| `assemble.alternates` | assemble, rig, check (`--set`, `--all-sets`) | **authored**, optional (issues #454, #465, #472): `[{name, source?, full?, head?, head_box?, parts?, hides?, cuts?, skeleton?, drawn?}]` — alternate painting sets assembled into `<out>/rig/parts/<set>_<part>.png` (`parts.json` `alternates`), attached on each covered slot in `rig.json` as `<set>_<part>` beside the base attachment, and indexed in `<out>/rig/sets.json` (`rig-parts-sets/1`: `sets.<set>.slots`, `.hides`, `.drawn` when present, and `.threshold: {yaw: 15, pitch: 12}` when the set replaces `face` on a rig with `turn` or `turn`/`tilt` slider animations). `source`, `full` and `head` may instead be given on the CLI with `--alt <name>=<source>:<full>[:<head>]`. `parts` lists the subset of parts the set replaces (default: every part not in `hides`); `hides` lists slots the set hides (`null` in `sets.json`). `skeleton` (a pose file path relative to the config's directory, `{"width": W, "height": H, "points": {…}}` with all 18 body-18 keypoints on the canvas, validated by the same rules as `generation.control.skeleton`: `CONFIG_FILE_PRESENT`, `CONFIG_IS_JSON`, `CONFIG_FIELD_TYPE`, `CONFIG_KEY_KNOWN`, `CONFIG_KEY_UNIQUE`, `CONFIG_POSE_CANVAS`, `CONFIG_POSE_KEYPOINTS`, `CONFIG_POSE_IN_CANVAS`) or `drawn` (`{<bone>: <worldDegrees>}`, each bone resolved against `bones`, `CONFIG_NAME_RESOLVES`) declares the posed angles the alternate was painted at (issue #465); when `skeleton` is given, `assemble` derives `drawn` from its 18 keypoints (with `drawn` overriding any bone named in both), records both `skeleton` (scaled by `assemble.rig_scale` to rig px) and `drawn` in `parts.json` `alternates`, and the `rig` stage writes `<set>_*` region and mesh attachments in the bone-local frame of `poseRigBonesForDrawn(bones, drawn)` (weighting limb segments directly from `skeleton`'s joint endpoints when `skeleton` is present) and emits `drawn` in `sets.json` so `rig-pose` and `check --set <name>` / `--all-sets` pose the bones at `drawn` without rotating the already-posed art a second time |
|
|
112
112
|
| `limbs` | assemble (the cut and the caps), rig (through `regions`), check (`LIMB_ONE_BONE`) | **proposed** by `propose --keypoints` (issue #349), then checked: `{<limb>: {segments: [{part, from, bone}, {part, from, bone, joint, range?, cap?, edge?, cap_shape?, cut_band?}, …]}}`, the upper segment first. The rule is *Limbs: cut at their joints* below the table. `part` is a new part (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` against the plan, patches, cuts and every other limb part); `from` a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`) — the layer the segment's pixels come from, which is cut into the limb parts and is not itself made: a `meshes` or `regions` entry for it is `CONFIG_NAME_RESOLVES`, and the segments of one limb that take one part are consecutive (`CONFIG_LIMB_FROM_RUN`); `bone` a declared bone, each segment's the child of the one before (`CONFIG_LIMB_BONES_CHAIN`); the upper segment names no `joint`, `range`, `cap`, `edge`, `cap_shape` or `cut_band` (`CONFIG_KEY_KNOWN`), every other names its `joint` (`CONFIG_FIELD_PRESENT`) — the joint is its bone's origin; `range` is `[min, max]` degrees from rest with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`), which `assemble` writes into the lower part's `parts.json` record and `check` poses the joint at — its two ends and its middle — reading the seam there (`SEAM_OVER_RANGE`, issue #360; rig-c needs no rotation limit); `cap` a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE`); `edge` `"feather"` and `cap_shape` `"disc"` (issue #360), each the one value its field takes (`CONFIG_FIELD_TYPE` on any other) — the cut's geometry at that joint, *Limbs: cut at their joints* below; absent, the cut is hard and the cap the layer's half-disc of half the limb's width; `cut_band` a whole number of px, 1 or more (`CONFIG_FIELD_TYPE`), on a segment that takes the segment above's part — between two layers nothing is cut, so a band there is `CONFIG_KEY_KNOWN` — places the cut within that many px of the joint line (issue #360, *Limbs: cut at their joints*); absent, the cut is the joint line. Each limb part is attached like any part — `regions.<part>`, which must be the segment's `bone` (`CONFIG_LIMB_BONE`), or a mesh, which `check` holds to that bone alone (`LIMB_ONE_BONE`). With `limbs`, `assemble` reads `bones` too (`CONFIG_FIELD_PRESENT` without it). Absent, nothing is cut at a joint and no byte of any output moves |
|
|
113
113
|
| `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip, link_inherit?}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone with a `tip` is turned the same way, towards its tip, with that distance as its length (issue #239); a tip on the bone's own origin is refused (`RIG_TIP_DISTINCT`). The five face bones `eye_r`, `eye_l`, `brow_r`, `brow_l` and `mouth` (by name, when one has no tip of its own) are turned to the eye line — the line through the origins of `eye_r` and `eye_l` as `bones` writes them, read image-left to image-right, drawn when parts.json holds both an `eyewhite-r` and an `eyewhite-l` part — so the blink's `scaley` squashes across the eye line on a tilted head; move an eye bone and the line moves with it. With one eyewhite or none, or an eye bone missing, the rig stage's `face axis:` line says so and they keep the page's axes. A bone under a face bone keeps the face's axis. With the line, `rig.json` also declares `invariants.axes` — one entry, `"face"`, through `eye_r` and `eye_l`, holding the turned face bones (and their controls) — so rig-c's gate holds the build to the line; the line is the one rigc draws through the same two bones, so the tolerance is only the float floor — the largest gap the emitted (float32) bones have to it, rounded up to 6 places plus one 6-place unit — and the `face axis:` line prints it. With the line the rig stage also adds `head_up` (issue #250): a slotless bone under `head`, at head's origin, turned to the eye line + 90 degrees and as long as the eye midpoint lies from head's origin along that axis — the head's frame, which a rule that needs it reads; `head` keeps its own frame, so no key on it or under it changes meaning, and a config bone of that name is refused (`RIG_HEAD_UP_NAME_FREE`). A face bone with its own `tip` is held too (issue #258), in a second entry, `"face_tips"`, when its tip lies along the line within whole-pixel rounding — a tip written in whole pixels sits at most sqrt(2)/2 px off the point of the line it stands for, so its direction may sit up to asin((sqrt(2)/2) / length) degrees off — with its tolerance measured the same way; a tip further off is the bone's own axis, and the `face axis:` line names it with its gap and that bound. A failure is rig-c's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` line, which names the bone and the fix (rig-c's `docs/AUTHORING.md` §3.7 and §5.2); with no line no entry is written and A51 reports SKIP. Every region on a turned bone is turned back, so the setup pose does not move. Any other single bone keeps world rotation 0; one whose parent is a link is turned back upright. A single bone may declare `inherit` (issue #348): what it takes from its parent's transform, one of rig-c's own mode names exactly as rig-c's table spells them — `normal`, `onlyTranslation`, `noRotationOrReflection`, `noScale`, `noScaleOrReflection` (rig-c's `RigBone.inherit`) — written to that bone in `rig.json` as declared; any other spelling, `"NoScale"` included (the runtime would fold its first letter; nothing here folds or maps), is refused (`CONFIG_BONE_INHERIT`), and a chain takes no `inherit` (`CONFIG_KEY_KNOWN`). A chain may instead declare `link_inherit` (issue #369), an object mapping its own expanded link names to those same modes, for example `"link_inherit": {"leg_r1": "noScale"}`. Only the named link changes; omitted links inherit normally. A key outside this chain is `CONFIG_NAME_RESOLVES`, with this chain's valid link names in the detail; a non-object or non-string mode is `CONFIG_FIELD_TYPE`, and another mode spelling is `CONFIG_BONE_INHERIT`. The mode is forwarded to the expanded link, or to its control under `--idle-keys ctl`, by the same rule as a single bone. This is an authored opt-in: `propose` does not add it. Its setup rotation is the turn from where rig-c's own poser puts the bone with no rotation of its own under that mode, so it still stands upright (or along its tip) at the setup pose. Where the idle gives the bone a `<bone>_ctl`, the mode goes on the control: the control is the bone the parent's rotation reaches, and the bone under it inherits normally, so the idle's keys moved onto the control still turn it. A foot that does not take its shin's rotation (`noRotationOrReflection` or `onlyTranslation`) keeps its shoe level while the leg is posed (`FEET_STAY_LEVEL`, §7); which mode is the author's. `noScale` keeps a shin or shoe's shape under a scaled parent but still takes the parent's rotation: a shoe that must also stay level needs a separate rotation constraint, for example a transform constraint from an unrotated bone. This does not change the proposed feet's existing inheritance or `FEET_STAY_LEVEL` default. An automatic mesh weighted through a bone with a mode other than `normal` gets no derived amplitude (its row names the bone) and no residual envelope (rig-c's `SKINNING_RANGE_UNSUPPORTED`). Absent, `rig.json` carries no `inherit` and no byte moves |
|
|
114
114
|
| `meshes.<part>` | rig | **proposed**, then corrected. Exactly one of `grid` (the lattice: cell size, px — what `propose` writes: by default one spacing for the figure, round(H / 37) rig px with H the height of every part's box together, a part whose tenth of its short side is already coarser keeping that, and under `propose --grid-rule part` the reference's tenth of the part's short side clamped to 5..36, issue #265), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In every mode: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`), and, **authored**, optional, `exponent` (issue #161): a number above 0 that replaces the 2 in that rule, `w = 1/(d + r)^exponent` — a larger one hands each vertex more fully to its nearest bone; absent is 2 and changes no byte, a value that is not a number above 0 is `CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`, and a declared one is echoed as `exponent` in the mesh's `mesh_report.json` row; the exponent is the distance rule's, so declared beside `rule: "heat"` it is refused (`CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's. Optional `rule` (issue #161): `"distance"` — the rule above, and what an absent `rule` means (every byte is the same) — or `"heat"`, bone heat over the part's own silhouette, in every mode: the lattice's art (alpha above 8, less any island a declared `stray` left out) with holes filled, or for `contour` and `auto` the silhouette their outline is traced from (that mode's art — alpha above 8 for `contour`, alpha 1 and above for `auto` — less any island a declared `stray` left out, holes filled, grown by its `margin`); each bound bone's sources are the silhouette pixels whose centre lies within 0.75 px of its segment (a pixel two bones reach goes to the nearer, a tie to the first listed), held at 1 for that bone and 0 for the others; the field is solved by row-major Gauss–Seidel over-relaxation to a sweep change under 1e-7 within 20000 sweeps; a vertex reads the nearest silhouette pixel and takes the same cap and floor (and `regions`) as the distance rule; `r` is not read. The mesh's `mesh_report.json` row gains `heat` (the constants, each bone's sources and sweeps, the residual). Any other value is `CONFIG_FIELD_TYPE`; a bound bone with no source pixel is `RIG_HEAT_BONE_SOURCE`, an island no bone has a source in `RIG_HEAT_ISLAND_SOURCE`, a solve past its sweep bound `RIG_HEAT_CONVERGED` — none is weighted 0. The rule is chosen per mesh, so `RIG_HEAT_BONE_SOURCE` names three remedies: move the segment onto the art, take the bone out of `segments`, or keep this part on the distance rule — the one to take when the bone anchors the part from outside its art (a torso bone under a neck), since the first moves a bone another part placed and the second drops the anchor (issue #303). On a lattice, `RIG_HEAT_ISLAND_SOURCE` on an island that is dust — at most 4 px and at most 0.1 % of the part's art px, `mesh-draft`'s line — names `meshes.<part>.stray` and the figure that leaves it out, with the value declared; an island above that line keeps the segment remedy, a real piece being a real authoring question |
|
|
@@ -124,11 +124,28 @@ read by no CPU stage).
|
|
|
124
124
|
| `motion.animations_from` | rig; check (named; read for folds) | 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` names the others on one line (`beside the idle: …`) and reads each for folds, with the idle — `ANIMATIONS_DO_NOT_FOLD` (§7, issues #320, #333); every other bar reads the idle. Absent, `motion.json` holds the idle alone, byte for byte as before, and `check` reads that idle for folds |
|
|
125
125
|
| `constraints` | rig (handed to rig-c as `rig.json`'s `constraints`); `compare` and the coverage lines (roles) | **authored**, optional; `propose` writes none, but with `--cloth` (issue #364), where it writes the recipe's physics and swing transforms (*Recipe: cloth and hair that answer the world*, below). 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 |
|
|
126
126
|
| `stops` | rig (handed to rig-c as `rig.json`'s `stops`, and moves slots, mesh weights and child bones on stopped links onto `<link>_d`); `propose --from-config` (`LINT stop on <chain> …`) | **authored**, optional (issues #404, #447): `[{chain, links?, against, at, inward, outward, lower?: {inward, outward}}]` — rig-c's leg stops (`RigStop`, rig-c 2.56.0, `rig-c/src/stop.ts`). `chain` is a chain `bones` declares (`CONFIG_NAME_RESOLVES`, `RIG_NAME_RESOLVES`); `links`, optional, is a non-empty list of links of that chain (`CONFIG_NAME_RESOLVES`, `CONFIG_FIELD_TYPE`), or absent for every link of `chain` in chain order; `against` is a bone `bones` declares, a chain link as `<chain><i>` (`CONFIG_NAME_RESOLVES`, `RIG_NAME_RESOLVES`); `at` is `[x, y]` in `against`'s local space, as rig-c's stop takes it (`CONFIG_FIELD_TYPE`): since each chain link (`leg_r0`, `leg_r1`) is turned to point along its segment (`+x` from origin to next link or tip, `+y` 90° counter-clockwise in Spine world), `at: [d, n]` sits `d` px from `against`'s origin toward its end and `n` px perpendicular to it; `inward`, `outward` and `lower.inward`, `lower.outward` are finite numbers (`CONFIG_FIELD_TYPE`). Every other stop rule — non-negative bounds, each stopped link written by a constraint, `against` not under the stopped chain, the aim not degenerate, `\|rest\| + outward <= 180°` (`RIG_STOP_BOUNDS`) — is rigc's to accept or refuse at the rig stage's gate, in rigc's words (`RIG_RIGC_GREEN`). **Rising-limb / prone geometry (issue #447):** rig-c's `expandStops` aims `<chain>_rest` from `links[0]`'s setup origin at `<chain>_touch` (`at` in `against`'s local frame) and clamps `<link>_d` inside `[rest − inward, rest + outward]`; Spine wraps rotation at `±180°`, so `\|rest\| + outward <= 180°` is required (`RIG_STOP_BOUNDS`). Whenever `<chain>_touch` lies **above** `links[0]`'s root in rig pixels (`ty < y0`, i.e. higher Spine `y` — e.g. stopping a hanging cloth chain against a rising shin `leg_<s1>` in a prone or kneeling pose where the knee sits below `links[0]` and the ankle above it, so positive `at[0]` walks upward along the shin past `links[0]`'s root), `rest` lands in the upper half-plane (`\|rest\| > 90°`) and `\|rest\| + outward` exceeds `180°`. Stop against the descending thigh (`leg_<s0>`), set `links` to start at a link whose root sits above `<chain>_touch`, or omit the stop when the rising shin sits outside the hanging chain's sweep; `propose --from-config` prints `LINT stop on <chain> …` with the exact coordinates and angles when `<chain>_touch` lies above `links[0]`'s root or `\|rest\| + outward > 180°`. For every stopped link `<link>`, the rig stage moves any slot on `<link>`, any mesh weight on `<link>`, and any bone whose parent is `<link>` (a branched cloth tree's branches hanging from `<trunk_last>`, before `--idle-keys ctl` adds `<bone>_ctl` and before `turn` adds `turn_<part>_<bone>`) onto `<link>_d`, the drawn link bone rig-c's `expandStops` creates at `<link>`'s setup frame to carry the clamped rotation. Absent, `rig.json` carries no `stops` and no byte of any output moves |
|
|
127
|
-
| `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), read from the run the part was assembled from; a `painting:` patch has none and is not carried. A rider (issue #271) — a part from an `eyewhite`, `irides`, `eyelash`, `eyebrow`, `nose` or `mouth` layer — is drafted at the `face` part's depth, 0.001 nearer when parts.json draws it after the face and 0.001 further when before: See-through makes no depth of its own for the eye group (it cuts them from one depth map of the combined eyes) and clamps nose and mouth to the face's median − 0.001, and the two runs' own medians for these layers disagreed by up to 0.47 and swapped their order against the face. Every other layer — ears, headwear and earwear included — keeps its own median. With no `face` part, or more than one, the riders keep their own and a note says so. One note lists each part's depth and where it came from; a carrier the other run places more than 10 % of `pivot − nearest` away gets a note naming both values and the run read — the two runs are not reconciled. `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
|
|
127
|
+
| `turn` | rig (adds the turn control) | **proposed** only with `propose --full`/`--head` (issue #251), then corrected: `{pivot, depths, deform?, band?}`. `depths` holds, per part, the See-through layer's `depth_median` the part came from (larger = further back), read from the run the part was assembled from; a `painting:` patch has none and is not carried. A rider (issue #271) — a part from an `eyewhite`, `irides`, `eyelash`, `eyebrow`, `nose` or `mouth` layer — is drafted at the `face` part's depth, 0.001 nearer when parts.json draws it after the face and 0.001 further when before: See-through makes no depth of its own for the eye group (it cuts them from one depth map of the combined eyes) and clamps nose and mouth to the face's median − 0.001, and the two runs' own medians for these layers disagreed by up to 0.47 and swapped their order against the face. Every other layer — ears, headwear and earwear included — keeps its own median. With no `face` part, or more than one, the riders keep their own and a note says so. One note lists each part's depth and where it came from; a carrier the other run places more than 10 % of `pivot − nearest` away gets a note naming both values and the run read — the two runs are not reconciled. `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. Optional `deform` (`Partial<FaceDeformFractions>`, issue #436) overrides the Set B ×2.5 face surface deformation table (`FACE_DEFORM_FRACTIONS`, checked against `FACE_DEFORM_RANGES` by `CONFIG_TURN_DEFORM_RANGE`); when `applyFaceSurfaceDeform` runs on a rig whose `motion` carries `tilt` or `turn` slider `derive` keys, it promotes `face` on `head` (or `turn_face_head` when `turn` is declared) to the uneven 5×5 grid mesh, keys `face`, `hair_front` and `hair_back` deform timelines and ear/topknot tracks from `evalPitchFaceSurface` and `evalYawFaceSurface`, and samples the same deform field across each `features` member's rest span (`featRx = (24 / 106) · W`, `featRy = (12 / 128) · H`) with its depth fraction `z / zRef` as the field's angle input and chord foreshortening (`scalex` narrowing on the leading side and widening slightly on the trailing side with its outer edge anchored to the field, `scaley` on pitch) so every feature's bounding box stays inside the deformed face plate across the rated band (issue #468). Optional `band` (`number` or `{yaw?, pitch?}`, positive degrees, issue #472) declares the rig's rated face turn band (default `FACE_RATED_BAND`: `yaw <= 15°`, `pitch <= 12°`); declaring a `turn.band` or a `motion.animations_from` `turn`/`tilt` `derive.degrees` above `±15°` yaw or `±12°` pitch without an `assemble.alternates` entry that replaces `face` is refused (`CONFIG_FACE_BAND_EXCEEDED`), and on a single-plate rig `applyFaceSurfaceDeform` adds self-targeting transform constraints `yaw_clamp` (`[-15, 15]`) and `pitch_clamp` (`[-12, 12]`) ahead of the slider constraints so driving `yaw` or `pitch` beyond the rated band clamps at the boundary. 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 `pivot` nor `depths` 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 |
|
|
128
128
|
| `scene` | assemble (the recomposite, its holes and the figure's mask); check, through `parts.json`'s `scene` block (`RECOMPOSITE_HOLES`, the seam) | **authored**, optional; `propose` writes none (issue #242). `{}` declares the painting a scene: the figure and its surroundings in one image, See-through's full run holding only the figure. With it, `assemble` reads the recomposite figures, the hole list and the `uncovered:` lines inside the figure — the rig pixels where any full-run layer's alpha is above 8 (`OPAQUE_ALPHA_ABOVE`, the cut every layer's painted pixels are read by) — each line starting `inside the figure (config.scene: …, <n> of <m> rig px)`; writes that figure as `figure_rig.png` (white on black) beside the recomposite, and a `scene` block in `parts.json`; flags error pixels on the error map inside it only (outside, the painting grey); and `check` leaves out of the seam every frame pixel whose composite sample reaches the painting's outermost row or column, saying how many (`seam_border` in `check.json`, and on the seam line). It takes no field: the figure and its threshold are derived. A key in it other than a record or an annotation is `CONFIG_KEY_KNOWN`. Absent, no byte of any output moves |
|
|
129
129
|
| `cloth` | `propose --cloth` only; no other stage reads it, so it moves no byte of any build | **authored**, optional (issue #364): one recipe per tag, `{"bottomwear": {…}, "back hair": {…}, "front hair": {…}}`, each `{chains, links, wind, gravity, split?, length_ratio?, strength?, damping?, alternate_damping?, translate?, swing?, part?}`. `chains` — trees across the piece (for `back hair`, per side of the eye axis), a whole number, 1 or more; `links` — links from root to tip on every path, 1 or more; `split` — `{at, into}`: the first `at` links are the trunk, which forks into `into` (2 or more) branches; `at` below `links` (`CONFIG_CLOTH_SPLIT_BEFORE_TIP`); `length_ratio` — a path's last link over its first, above 0, default 0.5; `strength` — `{trunk, branch, edge}`, each above 0 and below the spring's stability bound (`CONFIG_CLOTH_SPRING_STABLE`), default 100 / 100 / 50; `damping` — in [0, 1], absent: rig-c's default; `alternate_damping` — the odd trees' damping, in [0, 1], beside `damping` and different from it (`CONFIG_CLOTH_ALTERNATE_DIFFERS`); `wind`, `gravity` — `[root, tip]`, each 0 or more, tip at or above root (`CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP`); `translate` — the x and y mix of a dangling end's flutter, above 0 and at most 1; `swing` — `bottomwear` only, `{scales}`, one positive scale per tree (`CONFIG_CLOTH_SWING_SCALE_PER_TREE`); `part` — `bottomwear` only (`CONFIG_CLOTH_PART_ON_BOTTOMWEAR`), a non-empty part or cut-piece name: the `topwear` part that holds a skirt See-through returned inside it (a dress), or its skirt piece cut by `assemble.cuts`, which takes the skirt's trees (issue #402, *A skirt inside topwear* below; the part is checked by `propose`, `PROPOSE_CLOTH_PART`). The two defaults are the recipe's own, measured on its synthetic fixture (the recipe below). The recipe below says what each field drafts |
|
|
130
130
|
| `proposed_rig_scale` | assemble, rig, build, `propose --from-config`/`--compare` | **proposed** by `propose` (issue #354): `parts.json`'s `scale_rig_per_source`, the `assemble.rig_scale` the rig sections (`bones`, `meshes`, `limbs`, `motion`) were drafted at. Held equal to `assemble.rig_scale` when present (`CONFIG_PROPOSED_RIG_SCALE`; 0, negative or not a number: `CONFIG_FIELD_TYPE`). Absent on a hand-authored config, where no byte of any output moves |
|
|
131
131
|
|
|
132
|
+
**One drawing, one band; beyond it, another drawing (issue #472).** A single front-facing
|
|
133
|
+
`face` plate has one painted outline, one nose ridge, and two eyes in front-view topology.
|
|
134
|
+
Within the rated single-plate band (`FACE_RATED_BAND`: `yaw ±15°`, `pitch ±12°`),
|
|
135
|
+
`applyFaceSurfaceDeform` bends the 5×5 `face` mesh and carries the features along the same
|
|
136
|
+
surface field with chord foreshortening (`#468`), keeping every feature inside the face
|
|
137
|
+
plate without inventing unpainted geometry. Past `±15°` yaw or `±12°` pitch, a 3/4 or
|
|
138
|
+
profile turn is a topological change — the far cheek disappears behind the bridge of the
|
|
139
|
+
nose, the jawline shifts to a three-quarter contour, and the far eye changes silhouette —
|
|
140
|
+
which warping one front plate can only fake as a stretched rubber mask. Rather than
|
|
141
|
+
silently extrapolating a single plate into angles it does not depict, `rig-parts` refuses
|
|
142
|
+
any `turn.band` or `turn`/`tilt` `derive.degrees` above `±15°` / `±12°` unless an
|
|
143
|
+
`assemble.alternates` entry supplies a second `face` drawing (`CONFIG_FACE_BAND_EXCEEDED`),
|
|
144
|
+
clamps the single-plate `yaw` and `pitch` controls at `±15` and `±12` via `yaw_clamp` and
|
|
145
|
+
`pitch_clamp`, and, when an alternate face set is declared, omits the clamps and records
|
|
146
|
+
`"threshold": {"yaw": 15, "pitch": 12}` on that set in `sets.json` so the consumer swaps
|
|
147
|
+
plates at the rated boundary.
|
|
148
|
+
|
|
132
149
|
**A scene target is an ordinary bone.** A target the scene places — a point a hand
|
|
133
150
|
reaches for, a face something turns toward — is a single bone parented to `root`,
|
|
134
151
|
`{"name": "tgt", "parent": "root", "at": [x, y]}`, that a constraint names as its
|
|
@@ -2658,6 +2675,8 @@ See-through with another seed.
|
|
|
2658
2675
|
| <a id="CONFIG_CLOTH_SPRING_STABLE"></a>`CONFIG_CLOTH_SPRING_STABLE` | a `cloth` recipe's strength — declared, or the default 100 / 100 / 50 — whose spring is unstable at a damping the recipe runs at (`damping`, `alternate_damping`, or rig-c's 0.85 when `damping` is left out): k = strength·massInverse·step² at or past 2(1 + damping^(60·step)), step 1/60 and mass 1 (rig-c's defaults; the recipe sets neither). The detail gives k and the bound | a lower strength, or a higher damping |
|
|
2659
2676
|
| <a id="CONFIG_TURN_BONE_FREE"></a>`CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
|
|
2660
2677
|
| <a id="CONFIG_TURN_DEFORM_RANGE"></a>`CONFIG_TURN_DEFORM_RANGE` | an entry in `turn.deform` lies outside its allowed `[min, max]` interval in `FACE_DEFORM_RANGES` (`src/turn.ts`, issue #436); the detail gives the value found, the interval required and the default fraction | put that `turn.deform.<key>` inside the stated range, or remove it to take the Set B ×2.5 default |
|
|
2678
|
+
| <a id="CONFIG_TURN_EAR_DEPTH_RANGE"></a>`CONFIG_TURN_EAR_DEPTH_RANGE` | an ear track (`group: "ears"` or `bone: "ear_r" \| "ear_l"`) in `turn` or `tilt` declares a `derive.depth` outside `EAR_DEPTH_RANGE` (`[-50, 0]`, `src/turn.ts`, issue #477); the detail gives the value found and the interval required | put the ear `derive.depth` in `[-50, 0]` (at or behind the head pivot, e.g. `-14`) |
|
|
2679
|
+
| <a id="CONFIG_FACE_BAND_EXCEEDED"></a>`CONFIG_FACE_BAND_EXCEEDED` | `turn.band` or a `motion.animations_from` `turn`/`tilt` `derive.degrees` exceeds the single-plate rated band (`FACE_RATED_BAND`: `yaw <= 15°`, `pitch <= 12°`, issue #472) while no `assemble.alternates` entry replaces `face`; the detail gives the requested angle, the rated limit and both remedies | clamp the angle to `±15°` yaw / `±12°` pitch, or declare an `assemble.alternates` entry covering `face` for the 3/4 or profile plate |
|
|
2661
2680
|
| <a id="CONFIG_PROPOSED_RIG_SCALE"></a>`CONFIG_PROPOSED_RIG_SCALE` | `proposed_rig_scale` (the `scale_rig_per_source` `propose` drafted the rig sections at, issue #354) differs from `assemble.rig_scale`: the coordinates in `bones`, `meshes`, `limbs` and `motion` are in that rig canvas's pixels and do not scale when `assemble.rig_scale` changes | re-run `assemble` and `propose` at the new `assemble.rig_scale`, or restore `assemble.rig_scale` to `proposed_rig_scale` |
|
|
2662
2681
|
|
|
2663
2682
|
### inputs
|
package/docs/STABILITY.md
CHANGED
|
@@ -162,9 +162,10 @@ part name, `[i]` an entry of a list):
|
|
|
162
162
|
- **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
|
|
163
163
|
- **motion.blink.still.<part>** — required: `row`, `bone`
|
|
164
164
|
- **motion.animations_from**, the file it names — required: `spec`, `animations`
|
|
165
|
-
- **turn** — required: `pivot`, `depths`; optional: `deform`
|
|
165
|
+
- **turn** — required: `pivot`, `depths`; optional: `deform`, `band`
|
|
166
166
|
- **turn.depths.<part>** — a See-through depth
|
|
167
167
|
- **turn.deform.<key>** — a per-degree fraction or coupling share (FACE_DEFORM_FRACTIONS, issue #436)
|
|
168
|
+
- **turn.band**, when an object — optional: `yaw`, `pitch` (issue #472)
|
|
168
169
|
- **scene** — no field (`{}`; a record or an annotation only)
|
|
169
170
|
- **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
|
|
170
171
|
<!-- /stable:config -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.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": {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"ag-psd": "^31.0.2",
|
|
64
|
-
"rig-c": "^2.
|
|
64
|
+
"rig-c": "^2.59.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@esotericsoftware/spine-core": "4.3.13",
|
package/src/build.ts
CHANGED
|
@@ -1086,12 +1086,15 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
1086
1086
|
}
|
|
1087
1087
|
}
|
|
1088
1088
|
const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
|
|
1089
|
-
const textsOf = (r: RigOutput): Array<[string, string]> =>
|
|
1090
|
-
|
|
1091
|
-
[
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1089
|
+
const textsOf = (r: RigOutput): Array<[string, string]> => {
|
|
1090
|
+
const mergedMotion = withAnimations(r.motion, from === null ? null : from.table);
|
|
1091
|
+
return [
|
|
1092
|
+
['rig.json', rigJsonText(r.rig)],
|
|
1093
|
+
['motion.json', rigJsonText(mergedMotion)],
|
|
1094
|
+
['mesh_report.json', rigJsonText(r.meshReport)],
|
|
1095
|
+
...(r.sets === undefined ? [] : [[SETS_FILE, rigJsonText(r.sets)] as [string, string]]),
|
|
1096
|
+
];
|
|
1097
|
+
};
|
|
1095
1098
|
const firstTexts = textsOf(first);
|
|
1096
1099
|
// The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
|
|
1097
1100
|
// gate compares the model document of this very build, the one written (issue #126 item 3). mesh_report.json is staged
|
package/src/config.ts
CHANGED
|
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
|
|
|
93
93
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
94
94
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
95
95
|
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
96
|
-
import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
96
|
+
import { EAR_DEPTH_RANGE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
97
97
|
import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
|
|
98
98
|
|
|
99
99
|
export type Point = [number, number];
|
|
@@ -1305,11 +1305,13 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1305
1305
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1306
1306
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1307
1307
|
if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
|
|
1308
|
-
|
|
1308
|
+
const hasAltFace = hasAlternateFaceSet(t.assemble, [...parts, ...cuts]);
|
|
1309
|
+
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones, hasAltFace);
|
|
1309
1310
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1310
1311
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1311
1312
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1312
1313
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1314
|
+
if (animations !== null) checkFaceAnimationBand(c, animations, hasAltFace);
|
|
1313
1315
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
1314
1316
|
refuseIfAny(c.problems);
|
|
1315
1317
|
return { config: raw as CharacterConfig, animations };
|
|
@@ -3185,9 +3187,96 @@ function checkScene(c: Check, v: Json): void {
|
|
|
3185
3187
|
}
|
|
3186
3188
|
}
|
|
3187
3189
|
|
|
3188
|
-
function
|
|
3190
|
+
function hasAlternateFaceSet(assemble: Json, planParts: readonly string[]): boolean {
|
|
3191
|
+
if (!isPlainObject(assemble) || !Array.isArray(assemble.alternates)) return false;
|
|
3192
|
+
const allParts = new Set(planParts);
|
|
3193
|
+
for (const alt of assemble.alternates) {
|
|
3194
|
+
if (!isPlainObject(alt)) continue;
|
|
3195
|
+
const hides = Array.isArray(alt.hides) ? alt.hides : [];
|
|
3196
|
+
if (Array.isArray(alt.parts)) {
|
|
3197
|
+
if (alt.parts.includes('face') && !hides.includes('face')) return true;
|
|
3198
|
+
} else if (allParts.has('face') && !hides.includes('face')) {
|
|
3199
|
+
return true;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
return false;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
function maxDeriveDegrees(anim: unknown): number {
|
|
3206
|
+
if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) return 0;
|
|
3207
|
+
let maxDeg = 0;
|
|
3208
|
+
for (const tr of anim.tracks) {
|
|
3209
|
+
if (!isPlainObject(tr) || !Array.isArray(tr.keys)) continue;
|
|
3210
|
+
for (const k of tr.keys) {
|
|
3211
|
+
if (!isPlainObject(k) || !isPlainObject(k.derive)) continue;
|
|
3212
|
+
const deg = k.derive.degrees;
|
|
3213
|
+
if (typeof deg === 'number' && Number.isFinite(deg)) {
|
|
3214
|
+
const a = Math.abs(deg);
|
|
3215
|
+
if (a > maxDeg) maxDeg = a;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
return maxDeg;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace: boolean): void {
|
|
3223
|
+
const at = `config.motion.animations_from (${animations.file}).animations`;
|
|
3224
|
+
const maxYaw = maxDeriveDegrees(animations.table.turn);
|
|
3225
|
+
const maxPitch = maxDeriveDegrees(animations.table.tilt);
|
|
3226
|
+
if (maxYaw > FACE_RATED_YAW_BAND && !hasAltFace) {
|
|
3227
|
+
c.fail(
|
|
3228
|
+
'CONFIG_FACE_BAND_EXCEEDED',
|
|
3229
|
+
`${at}.turn`,
|
|
3230
|
+
`rates yaw ±${maxYaw}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
if (maxPitch > FACE_RATED_PITCH_BAND && !hasAltFace) {
|
|
3234
|
+
c.fail(
|
|
3235
|
+
'CONFIG_FACE_BAND_EXCEEDED',
|
|
3236
|
+
`${at}.tilt`,
|
|
3237
|
+
`rates pitch ±${maxPitch}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
const [earLo, earHi] = EAR_DEPTH_RANGE;
|
|
3241
|
+
for (const [animName, anim] of [
|
|
3242
|
+
['tilt', animations.table.tilt],
|
|
3243
|
+
['turn', animations.table.turn],
|
|
3244
|
+
] as const) {
|
|
3245
|
+
if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) continue;
|
|
3246
|
+
anim.tracks.forEach((tr, ti) => {
|
|
3247
|
+
if (!isPlainObject(tr) || !Array.isArray(tr.keys)) return;
|
|
3248
|
+
const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
|
|
3249
|
+
if (!isEarTrack) return;
|
|
3250
|
+
tr.keys.forEach((k, ki) => {
|
|
3251
|
+
if (!isPlainObject(k) || !isPlainObject(k.derive)) return;
|
|
3252
|
+
const d = k.derive.depth;
|
|
3253
|
+
if (typeof d === 'number' && Number.isFinite(d)) {
|
|
3254
|
+
if (d < earLo || d > earHi) {
|
|
3255
|
+
c.fail(
|
|
3256
|
+
'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
3257
|
+
`${at}.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
|
|
3258
|
+
`is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3261
|
+
} else if (isPlainObject(d)) {
|
|
3262
|
+
for (const [member, val] of Object.entries(d)) {
|
|
3263
|
+
if (typeof val === 'number' && Number.isFinite(val) && (val < earLo || val > earHi)) {
|
|
3264
|
+
c.fail(
|
|
3265
|
+
'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
3266
|
+
`${at}.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
|
|
3267
|
+
`is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
3268
|
+
);
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
});
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>, hasAltFace: boolean): void {
|
|
3189
3278
|
const at = 'config.turn';
|
|
3190
|
-
const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
|
|
3279
|
+
const o = c.object(at, v, ['pivot', 'depths'], ['deform', 'band']);
|
|
3191
3280
|
if (o === null) return;
|
|
3192
3281
|
const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
|
|
3193
3282
|
const depths: Record<string, number> = {};
|
|
@@ -3208,6 +3297,39 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
|
|
|
3208
3297
|
}
|
|
3209
3298
|
}
|
|
3210
3299
|
}
|
|
3300
|
+
if ('band' in o) {
|
|
3301
|
+
const b = o.band;
|
|
3302
|
+
if (typeof b === 'number' && Number.isFinite(b) && b > 0) {
|
|
3303
|
+
if (b > FACE_RATED_YAW_BAND && !hasAltFace) {
|
|
3304
|
+
c.fail(
|
|
3305
|
+
'CONFIG_FACE_BAND_EXCEEDED',
|
|
3306
|
+
`${at}.band`,
|
|
3307
|
+
`rates yaw ±${b}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
} else if (isPlainObject(b)) {
|
|
3311
|
+
const bo = c.object(`${at}.band`, b, [], ['yaw', 'pitch']);
|
|
3312
|
+
if (bo !== null) {
|
|
3313
|
+
const yawOk = !('yaw' in bo) || c.number(`${at}.band.yaw`, bo.yaw, 'positive');
|
|
3314
|
+
const pitchOk = !('pitch' in bo) || c.number(`${at}.band.pitch`, bo.pitch, 'positive');
|
|
3315
|
+
const yawVal = yawOk && typeof bo.yaw === 'number' ? bo.yaw : null;
|
|
3316
|
+
const pitchVal = pitchOk && typeof bo.pitch === 'number' ? bo.pitch : null;
|
|
3317
|
+
if (!hasAltFace && ((yawVal !== null && yawVal > FACE_RATED_YAW_BAND) || (pitchVal !== null && pitchVal > FACE_RATED_PITCH_BAND))) {
|
|
3318
|
+
const foundParts = [
|
|
3319
|
+
...(yawVal !== null ? [`yaw ±${yawVal}°`] : []),
|
|
3320
|
+
...(pitchVal !== null ? [`pitch ±${pitchVal}°`] : []),
|
|
3321
|
+
];
|
|
3322
|
+
c.fail(
|
|
3323
|
+
'CONFIG_FACE_BAND_EXCEEDED',
|
|
3324
|
+
`${at}.band`,
|
|
3325
|
+
`rates ${foundParts.join(' / ')} on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
|
|
3326
|
+
);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
} else {
|
|
3330
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.band`, `is ${show(b)}; a positive yaw number or {yaw, pitch} is required`);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3211
3333
|
if ('deform' in o) {
|
|
3212
3334
|
const df = o.deform;
|
|
3213
3335
|
if (!isPlainObject(df)) {
|
package/src/parts.ts
CHANGED
package/src/propose.ts
CHANGED
|
@@ -1962,7 +1962,7 @@ export function proposeWithBasis(
|
|
|
1962
1962
|
const [g, r] = gridR(p);
|
|
1963
1963
|
const top = p.y;
|
|
1964
1964
|
const bot = p.y + p.h;
|
|
1965
|
-
if (bot < neckY + 0.5 * fh) {
|
|
1965
|
+
if (bot < neckY + 0.5 * fh && isOn(dilate(P.alpha(p), 31), pyRound(axis), pyRound(bot - 0.1 * p.h))) {
|
|
1966
1966
|
B(
|
|
1967
1967
|
'bun',
|
|
1968
1968
|
'head',
|
package/src/rig.ts
CHANGED
|
@@ -172,7 +172,7 @@ import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
|
172
172
|
import { pyRound } from './round.ts';
|
|
173
173
|
import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
174
174
|
import type { PosePoints } from './skeleton.ts';
|
|
175
|
-
import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
175
|
+
import { applyFaceSurfaceDeform, FACE_RATED_BAND, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
176
176
|
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
177
177
|
|
|
178
178
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -908,6 +908,7 @@ export const SETS_FILE = 'sets.json';
|
|
|
908
908
|
*/
|
|
909
909
|
export interface AttachmentSetsFile {
|
|
910
910
|
spec: typeof SETS_SPEC;
|
|
911
|
+
threshold?: { yaw: number; pitch: number };
|
|
911
912
|
sets: Record<string, AlternateSetMap>;
|
|
912
913
|
}
|
|
913
914
|
|
|
@@ -2247,14 +2248,22 @@ export function buildRig(
|
|
|
2247
2248
|
for (const slotName of Object.keys(alt.slots)) {
|
|
2248
2249
|
if (stills[slotName] !== undefined && !hideList.includes(`${slotName}${STILL_SUFFIX}`)) hideList.push(`${slotName}${STILL_SUFFIX}`);
|
|
2249
2250
|
}
|
|
2251
|
+
const coversFace = Object.hasOwn(slotMap, 'face');
|
|
2250
2252
|
setEntries[alt.name] = {
|
|
2251
2253
|
slots: slotMap,
|
|
2252
2254
|
...(hideList.length > 0 ? { hides: hideList } : {}),
|
|
2253
2255
|
...(drawn === undefined ? {} : { drawn }),
|
|
2256
|
+
...(coversFace && cfg.turn !== undefined ? { threshold: { ...FACE_RATED_BAND } } : {}),
|
|
2254
2257
|
};
|
|
2255
2258
|
}
|
|
2256
|
-
|
|
2259
|
+
const anyCoversFace = Object.values(setEntries).some((e) => Object.hasOwn(e.slots, 'face'));
|
|
2260
|
+
sets = {
|
|
2261
|
+
spec: SETS_SPEC,
|
|
2262
|
+
...(anyCoversFace ? { threshold: { ...FACE_RATED_BAND } } : {}),
|
|
2263
|
+
sets: setEntries,
|
|
2264
|
+
};
|
|
2257
2265
|
}
|
|
2266
|
+
const hasAlternateFaceSet = sets !== undefined && Object.values(sets.sets).some((e) => Object.hasOwn(e.slots, 'face'));
|
|
2258
2267
|
// ---- leg stops (issue #404) --------------------------------------------
|
|
2259
2268
|
// Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
|
|
2260
2269
|
// from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
|
|
@@ -2294,7 +2303,15 @@ export function buildRig(
|
|
|
2294
2303
|
opaqueWidth: facePart?.w,
|
|
2295
2304
|
opaqueHeight: facePart?.h,
|
|
2296
2305
|
overrides: cfg.turn?.deform,
|
|
2306
|
+
hasAlternateFaceSet,
|
|
2297
2307
|
});
|
|
2308
|
+
if (sets !== undefined && (merged.animations.turn !== undefined || merged.animations.tilt !== undefined)) {
|
|
2309
|
+
for (const entry of Object.values(sets.sets)) {
|
|
2310
|
+
if (Object.hasOwn(entry.slots, 'face')) {
|
|
2311
|
+
entry.threshold = { ...FACE_RATED_BAND };
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2298
2315
|
});
|
|
2299
2316
|
return {
|
|
2300
2317
|
rig,
|
package/src/turn.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* one piece. Pure: no file, no clock.
|
|
30
30
|
*/
|
|
31
31
|
import { computeWorldTransforms, toBoneLocalVector, toWorld, type BoneTransform } from './coords.ts';
|
|
32
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
32
33
|
import { pyRound } from './round.ts';
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -165,10 +166,29 @@ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDefo
|
|
|
165
166
|
return out;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Issue #472: the rated band of a single face plate — yaw `±15°`, pitch `±12°`.
|
|
171
|
+
* Beyond this band on one plate, the foreshortened far eye runs out of cheek to
|
|
172
|
+
* sit on; going further takes an alternate face set (`assemble.alternates`),
|
|
173
|
+
* whose swap threshold is this band edge.
|
|
174
|
+
*/
|
|
175
|
+
export const FACE_RATED_YAW_BAND = 15;
|
|
176
|
+
export const FACE_RATED_PITCH_BAND = 12;
|
|
177
|
+
export const FACE_RATED_BAND = { yaw: FACE_RATED_YAW_BAND, pitch: FACE_RATED_PITCH_BAND } as const;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Issue #477: allowed `[min, max]` range (in rig px) for an authored ear
|
|
181
|
+
* `derive.depth` in `turn` or `tilt`. Ears sit at or behind the head pivot
|
|
182
|
+
* (`depth <= 0`, where `-14` moves the ears slightly against the face turn); a
|
|
183
|
+
* depth outside `[-50, 0]` is refused by `CONFIG_TURN_EAR_DEPTH_RANGE`.
|
|
184
|
+
*/
|
|
185
|
+
export const EAR_DEPTH_RANGE: readonly [number, number] = [-50, 0];
|
|
186
|
+
|
|
187
|
+
/** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, optional rated band, and optional face surface deform overrides. */
|
|
169
188
|
export interface TurnSpec {
|
|
170
189
|
pivot: number;
|
|
171
190
|
depths: Record<string, number>;
|
|
191
|
+
band?: number | { yaw?: number; pitch?: number };
|
|
172
192
|
deform?: Partial<FaceDeformFractions>;
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -291,6 +311,13 @@ export interface FaceSurfaceDeformOptions {
|
|
|
291
311
|
opaqueHeight?: number;
|
|
292
312
|
/** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
|
|
293
313
|
overrides?: Readonly<Partial<FaceDeformFractions>> | null;
|
|
314
|
+
/**
|
|
315
|
+
* Issue #472: true when `assemble.alternates` declares an alternate face set
|
|
316
|
+
* for the overflow beyond `±15°` yaw / `±12°` pitch. When false or absent,
|
|
317
|
+
* any `yaw` / `pitch` slider constraints on the rig are preceded by clamping
|
|
318
|
+
* transform constraints holding the dial bones to `[-15, 15]` and `[-12, 12]`.
|
|
319
|
+
*/
|
|
320
|
+
hasAlternateFaceSet?: boolean;
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
interface DeformWeight {
|
|
@@ -328,7 +355,7 @@ interface DeriveSpec {
|
|
|
328
355
|
|
|
329
356
|
interface SliderKey {
|
|
330
357
|
t: number;
|
|
331
|
-
v?: number[]
|
|
358
|
+
v?: number[] | Record<string, number[]>;
|
|
332
359
|
derive?: DeriveSpec;
|
|
333
360
|
}
|
|
334
361
|
|
|
@@ -390,14 +417,16 @@ function buildFaceGrid(width: number, height: number): { points: Array<[number,
|
|
|
390
417
|
/**
|
|
391
418
|
* Promote the `face` region on `head` to a 5×5 grid mesh and populate the
|
|
392
419
|
* `tilt` and `turn` slider animations with the Set B ×2.5 face and hair mesh
|
|
393
|
-
* deform timelines
|
|
394
|
-
* neither `tilt` nor `turn` with slider
|
|
395
|
-
* `motion` is touched.
|
|
420
|
+
* deform timelines, feature field-sampled tracks (#468) and ear/topknot tracks
|
|
421
|
+
* (issue #436). When `motion` holds neither `tilt` nor `turn` with slider
|
|
422
|
+
* `derive` keys, neither `rig` nor `motion` is touched.
|
|
396
423
|
*/
|
|
397
424
|
export function applyFaceSurfaceDeform(
|
|
398
425
|
rig: {
|
|
399
426
|
bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
|
|
427
|
+
slots?: Array<{ name: string; bone: string }>;
|
|
400
428
|
skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
|
|
429
|
+
constraints?: Array<Record<string, unknown>>;
|
|
401
430
|
invariants?: Record<string, unknown>;
|
|
402
431
|
},
|
|
403
432
|
motion: {
|
|
@@ -413,6 +442,44 @@ export function applyFaceSurfaceDeform(
|
|
|
413
442
|
(rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
|
|
414
443
|
(rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
|
|
415
444
|
if (!hasDerive) return;
|
|
445
|
+
|
|
446
|
+
// Issue #477: refuse an out-of-range ear derive.depth by name before touching rig or motion.
|
|
447
|
+
const earProblems: Problem[] = [];
|
|
448
|
+
const [earLo, earHi] = EAR_DEPTH_RANGE;
|
|
449
|
+
for (const [animName, anim] of [
|
|
450
|
+
['tilt', rawTilt],
|
|
451
|
+
['turn', rawTurn],
|
|
452
|
+
] as const) {
|
|
453
|
+
if (anim === undefined) continue;
|
|
454
|
+
anim.tracks.forEach((tr, ti) => {
|
|
455
|
+
const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
|
|
456
|
+
if (!isEarTrack) return;
|
|
457
|
+
tr.keys.forEach((k, ki) => {
|
|
458
|
+
const d = k.derive?.depth;
|
|
459
|
+
if (typeof d === 'number') {
|
|
460
|
+
if (d < earLo || d > earHi) {
|
|
461
|
+
earProblems.push({
|
|
462
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
463
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
|
|
464
|
+
detail: `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
} else if (d !== undefined && typeof d === 'object') {
|
|
468
|
+
for (const [member, val] of Object.entries(d)) {
|
|
469
|
+
if (typeof val === 'number' && (val < earLo || val > earHi)) {
|
|
470
|
+
earProblems.push({
|
|
471
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
472
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
|
|
473
|
+
detail: `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
refuseIfAny(earProblems);
|
|
482
|
+
|
|
416
483
|
const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
|
|
417
484
|
const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
|
|
418
485
|
if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
|
|
@@ -421,10 +488,63 @@ export function applyFaceSurfaceDeform(
|
|
|
421
488
|
const isOpts =
|
|
422
489
|
options !== undefined &&
|
|
423
490
|
options !== null &&
|
|
424
|
-
('imageWidth' in options ||
|
|
491
|
+
('imageWidth' in options ||
|
|
492
|
+
'imageHeight' in options ||
|
|
493
|
+
'opaqueWidth' in options ||
|
|
494
|
+
'opaqueHeight' in options ||
|
|
495
|
+
'overrides' in options ||
|
|
496
|
+
'hasAlternateFaceSet' in options);
|
|
425
497
|
const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
|
|
426
498
|
const f = resolveFaceDeformFractions(opts.overrides);
|
|
427
499
|
|
|
500
|
+
// Issue #472: when no alternate face set is declared, clamp dial bones to the single-plate rated band (±15° yaw, ±12° pitch).
|
|
501
|
+
if (!opts.hasAlternateFaceSet && Array.isArray(rig.constraints)) {
|
|
502
|
+
const nextConstraints: Array<Record<string, unknown>> = [];
|
|
503
|
+
const existingNames = new Set(rig.constraints.map((c) => c.name));
|
|
504
|
+
const makeClampConstraint = (name: string, bone: string, prop: unknown, band: number): Record<string, unknown> => {
|
|
505
|
+
const useX = prop === 'x' || prop === 'translatex';
|
|
506
|
+
const key = useX ? 'x' : 'rotate';
|
|
507
|
+
const mixKey = useX ? 'mixX' : 'mixRotate';
|
|
508
|
+
return {
|
|
509
|
+
name,
|
|
510
|
+
type: 'transform',
|
|
511
|
+
source: bone,
|
|
512
|
+
bones: [bone],
|
|
513
|
+
localSource: true,
|
|
514
|
+
localTarget: true,
|
|
515
|
+
clamp: true,
|
|
516
|
+
properties: {
|
|
517
|
+
[key]: {
|
|
518
|
+
offset: -band,
|
|
519
|
+
to: {
|
|
520
|
+
[key]: {
|
|
521
|
+
offset: -band,
|
|
522
|
+
max: band,
|
|
523
|
+
scale: 1,
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
[mixKey]: 1,
|
|
529
|
+
};
|
|
530
|
+
};
|
|
531
|
+
for (const c of rig.constraints) {
|
|
532
|
+
if (c.type === 'slider' && typeof c.bone === 'string') {
|
|
533
|
+
const isYaw = c.name === 'yaw' || c.animation === 'turn';
|
|
534
|
+
const isPitch = c.name === 'pitch' || c.animation === 'tilt';
|
|
535
|
+
if (isYaw && !existingNames.has('yaw_clamp')) {
|
|
536
|
+
existingNames.add('yaw_clamp');
|
|
537
|
+
nextConstraints.push(makeClampConstraint('yaw_clamp', c.bone, c.property, FACE_RATED_YAW_BAND));
|
|
538
|
+
} else if (isPitch && !existingNames.has('pitch_clamp')) {
|
|
539
|
+
existingNames.add('pitch_clamp');
|
|
540
|
+
nextConstraints.push(makeClampConstraint('pitch_clamp', c.bone, c.property, FACE_RATED_PITCH_BAND));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
nextConstraints.push(c);
|
|
544
|
+
}
|
|
545
|
+
rig.constraints = nextConstraints;
|
|
546
|
+
}
|
|
547
|
+
|
|
428
548
|
const faceSlot = rig.skins.default.face;
|
|
429
549
|
if (faceSlot === undefined || faceSlot.face === undefined) return;
|
|
430
550
|
const faceAtt = faceSlot.face;
|
|
@@ -434,13 +554,15 @@ export function applyFaceSurfaceDeform(
|
|
|
434
554
|
let faceRegX = -1;
|
|
435
555
|
let faceRegY = 49;
|
|
436
556
|
|
|
557
|
+
const faceBone = rig.slots?.find((s) => s.name === 'face')?.bone ?? (rig.bones.some((b) => b.name === 'turn_face_head') ? 'turn_face_head' : 'head');
|
|
558
|
+
|
|
437
559
|
if (!('type' in faceAtt)) {
|
|
438
560
|
faceRegX = faceAtt.x;
|
|
439
561
|
faceRegY = faceAtt.y;
|
|
440
562
|
const grid = buildFaceGrid(faceW, faceH);
|
|
441
563
|
const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
|
|
442
564
|
{
|
|
443
|
-
bone:
|
|
565
|
+
bone: faceBone,
|
|
444
566
|
x: pyRound(faceRegX - faceW / 2 + px, 6),
|
|
445
567
|
y: pyRound(faceRegY + faceH / 2 - py, 6),
|
|
446
568
|
weight: 1,
|
|
@@ -531,8 +653,31 @@ export function applyFaceSurfaceDeform(
|
|
|
531
653
|
};
|
|
532
654
|
|
|
533
655
|
const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
|
|
656
|
+
const referencedGroups = new Set<string>();
|
|
657
|
+
for (const anim of [tiltAnim, turnAnim]) {
|
|
658
|
+
for (const tr of anim?.tracks ?? []) {
|
|
659
|
+
if (tr.group !== undefined) referencedGroups.add(tr.group);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const defaultGroups: Readonly<Record<string, readonly string[]>> = {
|
|
663
|
+
features: ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'pupil_r', 'pupil_l', 'eyelash_r', 'eyelash_l', 'eyebrow_r', 'eyebrow_l', 'nose', 'mouth'],
|
|
664
|
+
ears: ['ear_r', 'ear_l'],
|
|
665
|
+
bangs: ['bang_r0', 'bang_c0', 'bang_l0'].map((b) => (boneMap.has(`${b}_ctl`) ? `${b}_ctl` : b)),
|
|
666
|
+
topknot: ['bun_ctl', 'hairpin', 'headwear'],
|
|
667
|
+
};
|
|
668
|
+
for (const g of referencedGroups) {
|
|
669
|
+
if (motion.groups?.[g] === undefined && defaultGroups[g] !== undefined) {
|
|
670
|
+
const members = defaultGroups[g].filter((b) => boneMap.has(b));
|
|
671
|
+
if (members.length > 0) {
|
|
672
|
+
motion.groups ??= {};
|
|
673
|
+
motion.groups[g] = members;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
534
678
|
const evalDeriveKey = (boneName: string, k: SliderKey): number => {
|
|
535
679
|
if (Array.isArray(k.v)) return k.v[0] ?? 0;
|
|
680
|
+
if (k.v !== undefined && typeof k.v === 'object') return k.v[boneName]?.[0] ?? 0;
|
|
536
681
|
const d = k.derive;
|
|
537
682
|
if (d === undefined) return 0;
|
|
538
683
|
const b = boneMap.get(boneName);
|
|
@@ -546,17 +691,18 @@ export function applyFaceSurfaceDeform(
|
|
|
546
691
|
};
|
|
547
692
|
|
|
548
693
|
const mapParent: Readonly<Record<string, string>> = {
|
|
549
|
-
bang_r0: 'bang_r0_ctl',
|
|
550
|
-
bang_r1: 'bang_r0_ctl',
|
|
551
|
-
bang_c0: 'bang_c0_ctl',
|
|
552
|
-
bang_c1: 'bang_c0_ctl',
|
|
553
|
-
bang_l0: 'bang_l0_ctl',
|
|
554
|
-
bang_l1: 'bang_l0_ctl',
|
|
555
|
-
bun: 'bun_ctl',
|
|
694
|
+
bang_r0: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
695
|
+
bang_r1: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
696
|
+
bang_c0: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
697
|
+
bang_c1: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
698
|
+
bang_l0: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
699
|
+
bang_l1: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
700
|
+
bun: boneMap.has('bun_ctl') ? 'bun_ctl' : 'bun',
|
|
556
701
|
};
|
|
557
702
|
|
|
558
703
|
const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
|
|
559
|
-
const
|
|
704
|
+
const baseBone = boneName.replace(/^turn_[^_]+_/, '');
|
|
705
|
+
const driver = mapParent[baseBone] ?? baseBone;
|
|
560
706
|
let dx = 0;
|
|
561
707
|
let dy = 0;
|
|
562
708
|
for (const tr of tracks) {
|
|
@@ -598,16 +744,37 @@ export function applyFaceSurfaceDeform(
|
|
|
598
744
|
const hyEar = Y0 - (16 / 128) * H;
|
|
599
745
|
const earRx = boneMap.get('ear_r')?.x ?? -51;
|
|
600
746
|
const earLx = boneMap.get('ear_l')?.x ?? 47.5;
|
|
747
|
+
const featRx = (24 / 106) * W;
|
|
748
|
+
const featRy = (12 / 128) * H;
|
|
749
|
+
const featMembers = motion.groups?.features ?? [];
|
|
750
|
+
|
|
751
|
+
const minDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, fallback: number): number => {
|
|
752
|
+
if (typeof depth === 'number') return depth > 0 ? depth : fallback;
|
|
753
|
+
if (depth === undefined) return fallback;
|
|
754
|
+
const vals = Object.values(depth).filter((v): v is number => typeof v === 'number' && v > 0);
|
|
755
|
+
return vals.length > 0 ? Math.min(...vals) : fallback;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const memberDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, boneName: string, zRef: number): number => {
|
|
759
|
+
if (typeof depth === 'number') return depth;
|
|
760
|
+
return depth?.[boneName] ?? zRef;
|
|
761
|
+
};
|
|
601
762
|
|
|
602
763
|
if (tiltAnim !== undefined) {
|
|
603
764
|
const origTiltTracks = tiltAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
765
|
+
const refTiltTrack = origTiltTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTiltTracks[0];
|
|
766
|
+
const isLegacyTilt =
|
|
767
|
+
tiltAnim.duration === 2.5 &&
|
|
768
|
+
Math.abs(refTiltTrack?.keys[0]?.derive?.degrees ?? 0) === 25;
|
|
769
|
+
const tiltKeys: Array<{ t: number; deg: number }> = isLegacyTilt
|
|
770
|
+
? [
|
|
771
|
+
{ t: 0, deg: -25 },
|
|
772
|
+
{ t: 0.75, deg: -10 },
|
|
773
|
+
{ t: 1.25, deg: 0 },
|
|
774
|
+
{ t: 2.0, deg: 15 },
|
|
775
|
+
{ t: 2.5, deg: 25 },
|
|
776
|
+
]
|
|
777
|
+
: (refTiltTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
611
778
|
const tiltDeform: DeformTimelineEntry[] = [
|
|
612
779
|
{
|
|
613
780
|
slot: 'face',
|
|
@@ -657,6 +824,31 @@ export function applyFaceSurfaceDeform(
|
|
|
657
824
|
});
|
|
658
825
|
}
|
|
659
826
|
for (const tr of tiltAnim.tracks) {
|
|
827
|
+
if (tr.group === 'features' && (tr.property === 'translatey' || tr.property === 'scaley') && featMembers.length > 0) {
|
|
828
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 34);
|
|
829
|
+
tr.keys = tr.keys.map((k) => {
|
|
830
|
+
const deg = k.derive?.degrees ?? 0;
|
|
831
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scaley' ? [1] : [0] };
|
|
832
|
+
const vMap: Record<string, number[]> = {};
|
|
833
|
+
for (const bname of featMembers) {
|
|
834
|
+
const b = boneMap.get(bname);
|
|
835
|
+
const bx = b?.x ?? 0;
|
|
836
|
+
const by = b?.y ?? Y0;
|
|
837
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
838
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
839
|
+
const uy = Math.max(-1, Math.min(1, (by - Y0) / (H / 2)));
|
|
840
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - uy * uy));
|
|
841
|
+
const [, dyD] = evalPitchFaceSurface(bx, by - featRy, effDeg, W, H, X0, Y0, f);
|
|
842
|
+
const [, dyU] = evalPitchFaceSurface(bx, by + featRy, effDeg, W, H, X0, Y0, f);
|
|
843
|
+
if (tr.property === 'translatey') {
|
|
844
|
+
vMap[bname] = [pyRound(0.5 * (dyD + dyU), 4)];
|
|
845
|
+
} else {
|
|
846
|
+
vMap[bname] = [pyRound(Math.max(0.2, 1 + (dyU - dyD) / (2 * featRy)), 4)];
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { t: k.t, v: vMap };
|
|
850
|
+
});
|
|
851
|
+
}
|
|
660
852
|
if (tr.group === 'ears' && tr.property === 'translatey') {
|
|
661
853
|
tr.keys = tr.keys.map((k) => {
|
|
662
854
|
const deg = k.derive?.degrees ?? 0;
|
|
@@ -674,7 +866,7 @@ export function applyFaceSurfaceDeform(
|
|
|
674
866
|
});
|
|
675
867
|
}
|
|
676
868
|
}
|
|
677
|
-
const refTiltKeys =
|
|
869
|
+
const refTiltKeys = refTiltTrack?.keys ?? [];
|
|
678
870
|
for (const [earBone, earHx] of [
|
|
679
871
|
['ear_r', earRx],
|
|
680
872
|
['ear_l', earLx],
|
|
@@ -696,13 +888,19 @@ export function applyFaceSurfaceDeform(
|
|
|
696
888
|
|
|
697
889
|
if (turnAnim !== undefined) {
|
|
698
890
|
const origTurnTracks = turnAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
891
|
+
const refTurnTrack = origTurnTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTurnTracks[0];
|
|
892
|
+
const isLegacyTurn =
|
|
893
|
+
turnAnim.duration === 3.0 &&
|
|
894
|
+
Math.abs(refTurnTrack?.keys[0]?.derive?.degrees ?? 0) === 30;
|
|
895
|
+
const turnKeys: Array<{ t: number; deg: number }> = isLegacyTurn
|
|
896
|
+
? [
|
|
897
|
+
{ t: 0, deg: -30 },
|
|
898
|
+
{ t: 0.75, deg: -15 },
|
|
899
|
+
{ t: 1.5, deg: 0 },
|
|
900
|
+
{ t: 2.25, deg: 15 },
|
|
901
|
+
{ t: 3.0, deg: 30 },
|
|
902
|
+
]
|
|
903
|
+
: (refTurnTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
706
904
|
const turnDeform: DeformTimelineEntry[] = [
|
|
707
905
|
{
|
|
708
906
|
slot: 'face',
|
|
@@ -753,25 +951,84 @@ export function applyFaceSurfaceDeform(
|
|
|
753
951
|
),
|
|
754
952
|
});
|
|
755
953
|
}
|
|
756
|
-
const refTurnKeys =
|
|
954
|
+
const refTurnKeys = refTurnTrack?.keys ?? [];
|
|
955
|
+
const earMembers = motion.groups?.ears ?? ['ear_r', 'ear_l'].filter((b) => boneMap.has(b));
|
|
956
|
+
const hasAuthoredEarTurnTx = turnAnim.tracks.some(
|
|
957
|
+
(tr) => tr.property === 'translatex' && (tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l'),
|
|
958
|
+
);
|
|
757
959
|
turnAnim.tracks = turnAnim.tracks.filter(
|
|
758
|
-
(tr) => !(tr.group === '
|
|
960
|
+
(tr) => !(tr.group === 'topknot' && tr.property === 'translatex'),
|
|
759
961
|
);
|
|
760
|
-
for (const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (!boneMap.has(earBone)) continue;
|
|
765
|
-
turnAnim.tracks.push({
|
|
766
|
-
bone: earBone,
|
|
767
|
-
property: 'translatex',
|
|
768
|
-
keys: refTurnKeys.map((k) => {
|
|
962
|
+
for (const tr of turnAnim.tracks) {
|
|
963
|
+
if (tr.group === 'features' && (tr.property === 'translatex' || tr.property === 'scalex') && featMembers.length > 0) {
|
|
964
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 36);
|
|
965
|
+
tr.keys = tr.keys.map((k) => {
|
|
769
966
|
const deg = k.derive?.degrees ?? 0;
|
|
967
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scalex' ? [1] : [0] };
|
|
968
|
+
const vMap: Record<string, number[]> = {};
|
|
969
|
+
const s = Math.sign(deg);
|
|
970
|
+
for (const bname of featMembers) {
|
|
971
|
+
const b = boneMap.get(bname);
|
|
972
|
+
const bx = b?.x ?? X0;
|
|
973
|
+
const by = b?.y ?? Y0;
|
|
974
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
975
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
976
|
+
const ux = Math.max(-1, Math.min(1, (bx - X0) / (W / 2)));
|
|
977
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - ux * ux));
|
|
978
|
+
const [dxL] = evalYawFaceSurface(bx - featRx, by, effDeg, W, H, X0, Y0, f);
|
|
979
|
+
const [dxR] = evalYawFaceSurface(bx + featRx, by, effDeg, W, H, X0, Y0, f);
|
|
980
|
+
const rawDelta = (dxR - dxL) / (2 * featRx);
|
|
981
|
+
const isTrailing = s * (bx - X0) > featRx * 0.5;
|
|
982
|
+
const sx = isTrailing ? 1 + Math.abs(rawDelta) : Math.max(0.2, 1 + rawDelta);
|
|
983
|
+
const dx = isTrailing
|
|
984
|
+
? (s > 0 ? dxR - featRx * (sx - 1) : dxL + featRx * (sx - 1))
|
|
985
|
+
: 0.5 * (dxL + dxR);
|
|
986
|
+
if (tr.property === 'translatex') {
|
|
987
|
+
vMap[bname] = [pyRound(dx, 4)];
|
|
988
|
+
} else {
|
|
989
|
+
vMap[bname] = [pyRound(sx, 4)];
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return { t: k.t, v: vMap };
|
|
993
|
+
});
|
|
994
|
+
} else if (tr.group === 'ears' && tr.property === 'translatex' && earMembers.length > 0) {
|
|
995
|
+
tr.keys = tr.keys.map((k) => {
|
|
996
|
+
if (k.derive === undefined) return k;
|
|
997
|
+
const deg = k.derive.degrees;
|
|
770
998
|
if (deg === 0) return { t: k.t, v: [0] };
|
|
771
|
-
const [
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
999
|
+
const vMap: Record<string, number[]> = {};
|
|
1000
|
+
for (const bname of earMembers) {
|
|
1001
|
+
vMap[bname] = [evalDeriveKey(bname, k)];
|
|
1002
|
+
}
|
|
1003
|
+
return { t: k.t, v: vMap };
|
|
1004
|
+
});
|
|
1005
|
+
} else if ((tr.bone === 'ear_r' || tr.bone === 'ear_l') && tr.property === 'translatex') {
|
|
1006
|
+
const bname = tr.bone;
|
|
1007
|
+
tr.keys = tr.keys.map((k) => {
|
|
1008
|
+
if (k.derive === undefined) return k;
|
|
1009
|
+
const deg = k.derive.degrees;
|
|
1010
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1011
|
+
return { t: k.t, v: [evalDeriveKey(bname, k)] };
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (!hasAuthoredEarTurnTx) {
|
|
1016
|
+
for (const [earBone, earHx] of [
|
|
1017
|
+
['ear_r', earRx],
|
|
1018
|
+
['ear_l', earLx],
|
|
1019
|
+
] as const) {
|
|
1020
|
+
if (!boneMap.has(earBone)) continue;
|
|
1021
|
+
turnAnim.tracks.push({
|
|
1022
|
+
bone: earBone,
|
|
1023
|
+
property: 'translatex',
|
|
1024
|
+
keys: refTurnKeys.map((k) => {
|
|
1025
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1026
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1027
|
+
const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
|
|
1028
|
+
return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
|
|
1029
|
+
}),
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
775
1032
|
}
|
|
776
1033
|
for (const [tkBone, tkFrac] of [
|
|
777
1034
|
['bun_ctl', f.turn_bun_counter_x],
|