rig-parts 2.8.0 → 2.8.1

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 CHANGED
@@ -5451,7 +5451,7 @@ documents do not ship). Each is an input this package built; none is an author's
5451
5451
  | <a id="BUILD_REQUIREMENTS_CONFLICT"></a>`BUILD_REQUIREMENTS_CONFLICT` | `build --requirements <file>` names a file with other bytes than the one `config.requirements` declares (issue #556): the detail names both, each resolved (the flag against the working directory, the declaration against the config's) with its sha256 — or `no such file`. Refused before assemble writes anything. The same file by both, or a copy of its bytes, is not refused: the declaration is read and recorded | drop the flag to build from the config's file, or point `config.requirements` at the flag's |
5452
5452
  | <a id="BUILD_STAGE_REQUEST"></a>`BUILD_STAGE_REQUEST` | an internal build worker was given a missing or malformed request, an unknown stage, or incomplete runner, input, output or scratch paths; the worker refuses before running a stage. This is the internal process protocol, not a public command or option | invoke `rig-parts build` with its documented flags; if that command produces this refusal, report the named request defect rather than constructing or modifying a worker request |
5453
5453
  | <a id="REQUIREMENTS_FILE"></a>`REQUIREMENTS_FILE` | `--requirements` names no file, or one that does not parse as a JSON object. Refused before anything is built (by `build`, before assemble) | the path of a `spine-parts-requirements/1` file |
5454
- | <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours |
5454
+ | <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours. For a pair read only by `seam_over_motion.required`, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941). |
5455
5455
  | <a id="REQUIREMENTS_RESOLVES"></a>`REQUIREMENTS_RESOLVES` | a bone, an `ik` or `transform` constraint (by name and `constraint_type`, as rig-c resolves it), a slot, a mesh attachment or an animation the file names is not in `rig.json` or `motion.json`, or a seam's part or a `not_planted`, `not_swinging`, `rises` or `rests` name is not in `parts.json` — the detail lists the ones that are; or an animation whose name cannot be a directory. Refused before anything is built | the name as the rig spells it |
5456
5456
  | <a id="REQUIREMENTS_CONSTRAINT_DRIVES"></a>`REQUIREMENTS_CONSTRAINT_DRIVES` | a `follow` names a bone its constraint does not constrain, or a property it does not drive: an `ik` drives `rotate` only; a `transform` drives `rotate` when a `to` names `rotate`, `translate` when one names `x` or `y` | the constraint's own bone and property |
5457
5457
  | <a id="REQUIREMENTS_TARGET_PARENT"></a>`REQUIREMENTS_TARGET_PARENT` | a scene target's bone is the root or is not parented to it, or the root is not at rest at the origin, or the animation keys the root — a stage point is a bone's position only under a root that stands still at the origin | a bone under the root for the scene to place |
@@ -5824,7 +5824,7 @@ the two examples [observed]:
5824
5824
  | <a id="BLINK_NO_HOLE"></a>`BLINK_NO_HOLE` | the setup pose with every `scaley` track on the eyewhite slots' bones held at its closed value, against the setup pose, at full size: pixels in the eyewhite box that show the page where the open eye had art | 0 px | 0 | 0 | no `eyewhite` part, or no `scaley` track on its bones goes below its first key |
5825
5825
  | ″ (reported) | the same box: each closed-eye pixel's max-channel distance to the nearest colour the open eye's box holds — max, and pixels over 40 | none | 15; 0 | 11; 0 | as above |
5826
5826
  | <a id="CHAIN_LAG"></a>`CHAIN_LAG` | `motion.json`'s rotate tracks read as sines (DFT of the keys: period, amplitude, phase) and arranged by the bone tree — a keyed bone's parent is its nearest keyed ancestor | every lag ≥ 0.001 cycle; amplitude non-decreasing down each unbranched chain — non-increasing down a chain that carries a part declared `rests` (issue #245) | lags 0.040 (neck to head) to 0.120; 12 chains | lags 0.040 to 0.100; 9 chains | no rotate track under another of the same period |
5827
- | <a id="TIP_OVER_ROOT"></a>`TIP_OVER_ROOT` | each `handwear`/`bottomwear` part: how far the centroid of its art travels in the lower half of its box against the upper half — the art's AREA centroid inside each fixed half (cut at h/2), every art pixel (alpha above 8) carried through its attachment's posed triangles from the idle's `geometry.json` and clipped to the half exactly, rig px; `instrument: "geometry"` in the line (issue #118). Since issue #987 every posed vertex is first carried into the setup place of the bone the part hangs from (`hang_from`, below), so the halves travel against it; the row keeps the world reading as `world_root_rig_px`, `world_tip_rig_px`, `world_ratio`, and the line says `instrument: "geometry, carrier frame"`. A half that travels 0 at the line's precision is 0 | ratio ≥ 1.4725; a part declared `rests`, ratio ≤ 1 (issue #245) | `bottomwear` 3.222, `sleeves` 9.891 against `chest` (world 4.762) | `bottomwear` 5.393, `sleeves` 27.482 against `chest` (world 11.830) | no such part |
5827
+ | <a id="TIP_OVER_ROOT"></a>`TIP_OVER_ROOT` | each `handwear`/`bottomwear` part: how far the centroid of its art travels in the lower half of its box against the upper half — the art's AREA centroid inside each fixed half (cut at h/2), every art pixel (alpha above 8) carried through its attachment's posed triangles from the idle's `geometry.json` and clipped to the half exactly, rig px; `instrument: "geometry"` in the line (issue #118). Since issue #987 every posed vertex is first carried into the setup place of the bone the part hangs from (`hang_from`, below), so the halves travel against it; the row keeps the world reading as `world_root_rig_px`, `world_tip_rig_px`, `world_ratio`, and the line says `instrument: "geometry, carrier frame"`. A half that travels 0 at the line's precision is 0 | carrier-frame 0 / 0 FAILs; otherwise a link with max(root, tip) below 0.5 rig px is SKIP (issue #994); measured ratio ≥ 1.4725; a part declared `rests`, ratio ≤ 1 (issue #245) | `bottomwear` 3.222, `sleeves` 9.891 against `chest` (world 4.762) | `bottomwear` 5.393, `sleeves` 27.482 against `chest` (world 11.830) | no such part, or every applicable row is unmeasured, including sub-floor link motion after the zero guard |
5828
5828
  | <a id="STILL_REGIONS_DARK"></a>`STILL_REGIONS_DARK` | from the idle's posed geometry (`geometry.json`), `instrument: "geometry"` in each half (issue #123). **Face:** the still set — the `face` slots, and every slot drawn after the first of them whose art overlaps a face part's at the setup pose, that the head bone alone carries (a region on it, or a mesh whose every weight is it) and that the idle does not key by slot (`deform`, `sequence`, a slot track: `driven_slots`). The head bone is the bone the `face` parts' slot rides in `rig.json`. Each still slot's art (alpha above 8), carried through its posed attachment and back through the head bone's setup-to-frame map on every idle frame, against its setup position: `max_rig_px` (and where, `at`, when it is a motion), `rms_rig_px`, per slot in `still_slots`. **Feet:** the `footwear` slots the same way in screen space — they ride the root, which the idle does not key, so the screen is their own frame. **Reported, no bar:** `crossing` — per slot drawn after the still set's first slot and not in it, how deep its art reaches into the region (`face` on top at the setup pose, clear of where `eyewhite`, `irides`, `eyelash`, `eyebrow` and `mouth` go in the head's frame; for the feet, `footwear` on top), `depth_rig_px`, and the most of it covered on one frame, `area_rig_px2` | each half's `max_rig_px` ≤ its `tolerance_rig_px`: nothing moves, up to the arithmetic's rounding, √2 × 2 × 35 × M² × ulp(C) (below) | face 4.202e-13 ≤ 2.286e-11; `hair_front` crossing 2.816 rig px deep, 89.395 rig px² (frame 47); feet 0 ≤ 2.814e-12 | face 4.418e-13 ≤ 2.286e-11 (`face`, `ear_l`, `ear_r`); `hair_front` 2.194 deep, 50.605 rig px² (frame 23); feet 0 ≤ 2.814e-12 | no `face` and no `footwear` part (one of the two absent leaves that half unmeasured; so do face parts on two slot bones, or idle frames with no `geometry.json`) |
5829
5829
  | <a id="TEXTURE_STRETCH"></a>`TEXTURE_STRETCH` | every mesh triangle's three edges in every idle frame, read from the `geometry.json` the idle render writes (`rigc render --geometry`, rig-c 1.4.0 or later): skinned length over rest length, the rest being the setup pose's bones with no deform. The figure is the rig's worst max(ratio, 1/ratio); each mesh's largest and smallest ratio, with triangle, vertices, edge and frame, is the detail | max(ratio, 1/ratio) ≤ 1.926544 | 1.388: `hair_back` triangle 449, edge 42-43 at 0.720 in frame 26 (largest stretch 1.291, frame 2) | 1.227: `sleeves` triangle 169, edge 126-51 at 0.815 in frame 28 (largest stretch 1.196, frame 5) | the rig draws no mesh, or the render wrote no `geometry.json` |
5830
5830
  | <a id="ANIMATIONS_DO_NOT_FOLD"></a>`ANIMATIONS_DO_NOT_FOLD` | every animation, the idle included, through `rigc render --folds` at 12 fps over the build with its physics stepping and, when the rig declares physics constraints, over the keyed build with them left out beside it (issue #363): the meshes with a triangle turned over against the setup pose on some frame, less those the requirements file's `may_fold` declares (below) | 0 undeclared folding meshes, and no `may_fold` entry that matches no fold | 0, over `idle` | 0, over `idle` and `hop`, 2 physics constraints on and off | never SKIP: written whenever `motion.json` holds an animation |
@@ -5833,6 +5833,30 @@ the two examples [observed]:
5833
5833
  | <a id="LIMB_ONE_BONE"></a>`LIMB_ONE_BONE` | issue #349. Each part `parts.json` records as a limb segment's (`limb`), read off `rig.json` alone, nothing rendered: the bones its attachments are bound to — a mesh's weights, or its slot's bone for a region — against its segment's bone (under `config.turn`, that bone's leaf twin counts as the bone) | every limb part bound to its one bone alone, weight 1 | not written: no limbs | not written: no limbs | never: written only when `parts.json` holds a limb part, so the examples' `check.json` keep their keys |
5834
5834
  | <a id="SEAM_OVER_RANGE"></a>`SEAM_OVER_RANGE` | issue #360. Each limb part `parts.json` records with a `range` (`config.limbs.<limb>.segments[k].range`): a copy of the rig with its physics constraints left out, its joint's bone keyed `rotate` to the range's min, its middle and its max (each angle once, rigc's sense, counter-clockwise on screen), one `rigc build --profile spine` and two `rigc render`s on one grid — one with the joint's own art drawn under the limb (the limb's parts at rest inside the cap's disc, one pixel in from every edge of what is opaque there, on the bone the joint's bone hangs from), one with it hidden (`--hide`); per pose, the seam figures of the second against the first. Issue #557: per pose also the **contour step**, a reading and never a bar — on the frame with the joint's art hidden, lines across the upper segment (perpendicular to its bone's direction at the setup pose, origin to the joint) one frame pixel apart, from one cap radius before the joint line to one past it; on each, from the axis outward on each side, the outline's extent (the first sample within 40 of the background, out to two radii); the step on a side is the largest change between adjacent lines, in rig px, `left` and `right` as the cap's `short_px` names them. A limb whose outline the bend keeps reads one frame pixel, 1 / s rig px (1.161 at the synthetic fixtures' s 0.861413); the unfilled two-layer knee of the selftest reads 11.609 on the opening side at ±15° and the filled one 1.161. The row prints it; `check.json` carries it (`step_px` on each pose) only for a joint whose cap record carries `short_px` | the rest-pose seam's: mean \|d\| ≤ 1.0 and ≤ 50 rig px over 40 at every pose; a `cap_shape: "disc"` or `"ball"` joint also 0 frame px over 40 (issue #598: the ball over its whole disc; the row says `shape`) | not written: no limbs | not written: no limbs | never: written only when `parts.json` holds a limb record with a `range`, so the examples' `check.json` keep their keys |
5835
5835
 
5836
+ **Small link motion (issue #994).** After the carrier-frame zero guard, a part on
5837
+ one of the links identified by the hang-bone walk is unmeasured when the larger of
5838
+ its unrounded root and tip travels is below **0.5 rig px**. Its row retains both travels, the ratio,
5839
+ the world reading and `travel_floor_rig_px: 0.5`, with `unmeasured` giving
5840
+ "below half a rig pixel: the motion does not show on the art's own grid". If every
5841
+ row is unmeasured the line is SKIP and retains those rows. In a mixed line the
5842
+ measured rows decide PASS or FAIL.
5843
+
5844
+ Half a rig pixel is the art-grid threshold fixed before measurement, taking one
5845
+ rig pixel as the grid unit. The larger-travel operand was amended after measuring
5846
+ the existing defect plants (issue #994). A root above the floor beside a still tip
5847
+ is measured by the ratio bar. The continuous geometry instrument still resolves the
5848
+ travel below it. Its three-place reporting quantum and its 12 fps time sampling
5849
+ do not establish this spatial threshold. A SKIP asks the author to increase the
5850
+ link's motion until either half reaches the threshold when swing is intended, or to
5851
+ retain the low-motion choice with an explicitly unmeasured swing reading. A part
5852
+ that rides its carrier whole still FAILs at carrier-frame 0 / 0, even if its world
5853
+ travel is nonzero. A `rests` declaration also keeps this structural failure, whose
5854
+ detail says to declare it in `not_swinging` if it is meant to ride rigidly. Declare
5855
+ a deliberately fitted garment in `not_swinging`.
5856
+ Translate-only deformation without a link keeps its existing ratio verdict,
5857
+ including the nonzero-root `rests` relation. At or above 0.5 for either half the
5858
+ link's existing ratio bar applies.
5859
+
5836
5860
  **`TIP_OVER_ROOT` is measured from geometry, not pixels (issue #118).** Until #118 the
5837
5861
  line rendered each part alone at `--max 640` and took, per half of its frame box, the
5838
5862
  centroid of every pixel that was not the background; its fields were `root_px` and
@@ -6502,7 +6526,7 @@ is `{ "spec": "spine-parts-requirements/1", "fps": 12, "requirements": [], "not_
6502
6526
  | `range` | `bone`, `lo_degrees`, `hi_degrees` | the bone's world rotation less its parent's (the world's, for the root), less the same at the setup pose, as a signed shortest angle: the least and the greatest, and their frames | — |
6503
6527
  | `stretch` | `slot`, `attachment` (a mesh), `within_ratio` | `TEXTURE_STRETCH`'s own per-mesh measure (imported, not rewritten): the mesh's worst max(ratio, 1/ratio) over the frames; `held_by` names what the worst edge's two vertices hang on, as `CHECK_TEXTURE_STRETCH` does (issue #321) | a rest edge of length 0; no frame shows the mesh |
6504
6528
  | `follow` | `constraint`, `constraint_type` (`ik` or `transform`), `bone`, `property` (`rotate` or `translate`), `fraction`, `tolerance`, `least_drive` | below | no frame's drive reaches `least_drive` (the line prints the largest drive there was) |
6505
- | `seam` | `part`, `neighbour` (two part names: each is drawn by the slot of its name), `within_px` | below | no seam pair reads on any frame (each pixel off its attachment's rest geometry, or the slot shows nothing) |
6529
+ | `seam` | `part`, `neighbour` (two part names: each is drawn by the slot of its name), `within_px` (required for parting distance). For `seam_over_motion.required` pair coverage alone, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941) | below | no seam pair reads on any frame (each pixel off its attachment's rest geometry, or the slot shows nothing) |
6506
6530
 
6507
6531
  A `contact` held against its own bone and point, and an `aim` at a point of the aimed bone itself, read 0 by construction (or, an aim at its own origin, no line at all), so they are refused before anything is built (`REQUIREMENTS_SELF_TARGET`, issue #540); a bone's tip against its own origin stays a contact, measured.
6508
6532
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
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/check.ts CHANGED
@@ -236,6 +236,8 @@ export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
236
236
  /** The smallest lag the reading resolves, in cycles: phases are written to three places. */
237
237
  export const CHAIN_LAG_MIN_STEP = 0.001;
238
238
  export const TIP_RATIO_FLOOR = 1.4725;
239
+ /** Issue #994 sets half a rig pixel as the floor on the larger half travel. */
240
+ export const TIP_TRAVEL_FLOOR = 0.5;
239
241
  /**
240
242
  * Issue #245: the bar a part the scene declares `rests` is held to — its tip
241
243
  * half travels no further than its root half, tip / root <= 1, read at the
@@ -7908,6 +7910,11 @@ export const TIP_INSTRUMENT = 'geometry, carrier frame';
7908
7910
  * nothing or the walk leaves the tree, and the part is then read in the world.
7909
7911
  */
7910
7912
  export function hangBone(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): string | null {
7913
+ return tipHang(rig, motion, part).bone;
7914
+ }
7915
+
7916
+ /** The same walk also identifies a link below the carrier for the art-grid policy. */
7917
+ function tipHang(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): { bone: string | null; onLink: boolean } {
7911
7918
  const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
7912
7919
  const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
7913
7920
  const upFrom = (b: string): string[] => {
@@ -7916,10 +7923,10 @@ export function hangBone(rig: Record<string, unknown>, motion: Record<string, un
7916
7923
  return path;
7917
7924
  };
7918
7925
  const bound = [...boundBones(rig, part)];
7919
- if (bound.length === 0) return null;
7926
+ if (bound.length === 0) return { bone: null, onLink: false };
7920
7927
  const paths = bound.map(upFrom);
7921
7928
  const common = paths[0].find((b) => paths.every((p) => p.includes(b)));
7922
- if (common === undefined) return null;
7929
+ if (common === undefined) return { bone: null, onLink: false };
7923
7930
  // A link: a bone of a chain CHAIN_LAG reads, or a bone a physics constraint drives. The rig stage keys a chain's
7924
7931
  // `<link>_ctl` and binds the mesh to its child the physics drives, and a physics-only chain has no keys at all, so
7925
7932
  // both are walked up through until the body bone that carries them.
@@ -7967,7 +7974,8 @@ export function hangBone(rig: Record<string, unknown>, motion: Record<string, un
7967
7974
  seen.add(at);
7968
7975
  at = parent.get(at) ?? null;
7969
7976
  }
7970
- return at;
7977
+ const onLink = paths.some((path) => path.slice(0, at === null ? path.length : path.indexOf(at)).some((b) => links.has(b)));
7978
+ return { bone: at, onLink };
7971
7979
  }
7972
7980
 
7973
7981
  /**
@@ -8032,7 +8040,8 @@ export function carrierFrame(geo: IdleGeometry | null, bone: string | null): ((i
8032
8040
  * body's carry is not scored as a tip that moves less; the row names that bone
8033
8041
  * (`hang_from`) and keeps the world reading (`world_*`). A half that travels 0
8034
8042
  * at the line's precision is 0, so a part that rides its carrier whole reads
8035
- * 0 / 0 and fails, not a ratio of round-off.
8043
+ * 0 / 0 and fails, not a ratio of round-off. Issue #994 applies the fixed
8044
+ * 0.5 rig px art-grid policy to max(root, tip) of a link part after this guard.
8036
8045
  */
8037
8046
  function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
8038
8047
  const swing = partsTagged(inp.parts, SWING_TAGS);
@@ -8067,6 +8076,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
8067
8076
  }
8068
8077
  const rows: Array<Record<string, unknown>> = [];
8069
8078
  let measured = 0;
8079
+ let belowGrid = 0;
8070
8080
  let failed = false;
8071
8081
  for (const p of swing) {
8072
8082
  if (excepted.not_swinging.includes(p.name)) {
@@ -8085,7 +8095,8 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
8085
8095
  const shown = poses.map((f) => f.get(p.name) ?? null);
8086
8096
  const w = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
8087
8097
  // Issue #987: the halves are read in the frame of the bone the part hangs from; the world reading rides beside them.
8088
- const hangFound = hangBone(inp.rig, inp.motion, p.name);
8098
+ const hanging = tipHang(inp.rig, inp.motion, p.name);
8099
+ const hangFound = hanging.bone;
8089
8100
  const frameOf = carrierFrame(idleGeo, hangFound);
8090
8101
  const hang = frameOf === null ? null : hangFound;
8091
8102
  const toCarrier = frameOf ?? ((_i: number, posed: readonly number[]) => posed.slice());
@@ -8098,19 +8109,27 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
8098
8109
  rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${half} half of its box holds no art its attachment draws` });
8099
8110
  continue;
8100
8111
  }
8101
- measured++;
8102
8112
  // Issue #987: a half that does not move against its carrier, read at the line's precision, is 0 and not the
8103
8113
  // arithmetic's residue — a rigid part reads 0 / 0, not a ratio of two round-off figures.
8104
8114
  const t = { root: round3(c.root) === 0 ? 0 : c.root, tip: round3(c.tip) === 0 ? 0 : c.tip };
8105
8115
  const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
8106
8116
  const worldRatio = w.root > 0 ? round3(w.tip / w.root) : null;
8107
8117
  // Issue #245: a resting tip is held to the opposite relation — no further than its root, at the line's precision.
8108
- const ok = rests ? (ratio === null ? t.tip === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
8118
+ const rigid = t.root === 0 && t.tip === 0;
8119
+ const ok = !rigid && (rests ? (ratio === null ? t.tip === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR);
8109
8120
  rows.push({ part: p.name, ...said, hang_from: hang, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio, world_root_rig_px: round3(w.root), world_tip_rig_px: round3(w.tip), world_ratio: worldRatio });
8121
+ // Structural 0 / 0 stays a failure before the link-only magnitude policy (issue #994).
8122
+ if (!rigid && hanging.onLink && Math.max(c.root, c.tip) < TIP_TRAVEL_FLOOR) {
8123
+ belowGrid++;
8124
+ rows[rows.length - 1] = { ...rows[rows.length - 1], travel_floor_rig_px: TIP_TRAVEL_FLOOR,
8125
+ unmeasured: "below half a rig pixel: the motion does not show on the art's own grid" };
8126
+ continue;
8127
+ }
8128
+ measured++;
8110
8129
  const against = hang === null ? 'in the world' : `against \`${hang}\`, the bone it hangs from`;
8111
8130
  const inWorld = hang === null ? '' : `; in the world ${round3(w.tip)} and ${round3(w.root)}, ratio ${worldRatio ?? 'undefined'}`;
8112
8131
  const travels = `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px ${against}`;
8113
- if (!ok && rests) {
8132
+ if (!ok && rests && !rigid) {
8114
8133
  failed = true;
8115
8134
  problems.push({
8116
8135
  code: 'CHECK_TIP_OVER_ROOT',
@@ -8141,7 +8160,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
8141
8160
  problems.push({
8142
8161
  code: 'CHECK_TIP_OVER_ROOT',
8143
8162
  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' : ''}`,
8144
- detail: `${travels} (ratio ${ratio ?? (hang === null ? 'undefined: nothing moves' : `undefined: nothing moves against \`${hang}\``)}${inWorld}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}`,
8163
+ detail: `${travels} (ratio ${ratio ?? (hang === null ? 'undefined: nothing moves' : `undefined: nothing moves against \`${hang}\``)}${inWorld}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}${rigid && rests ? '; declare it in `not_swinging` if it is meant to ride rigidly' : ''}`,
8145
8164
  });
8146
8165
  }
8147
8166
  }
@@ -8150,8 +8169,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
8150
8169
  ...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
8151
8170
  ...(excepted.rests.length === 0 ? {} : { declared_rests: swing.filter((p) => excepted.rests.includes(p.name)).map((p) => p.name) }),
8152
8171
  };
8153
- if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
8154
- return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR, ...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
8172
+ const figures = { ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR,
8173
+ ...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
8174
+ if (measured === 0) {
8175
+ const reasons = rows.map((r) => {
8176
+ const travel = r.travel_floor_rig_px === undefined ? '' : `; root ${String(r.root_rig_px)} rig px, tip ${String(r.tip_rig_px)} rig px, floor ${String(r.travel_floor_rig_px)} rig px`;
8177
+ return `${String(r.part)}: ${String(r.unmeasured)}${travel}`;
8178
+ });
8179
+ return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${reasons.join('; ')}`,
8180
+ ...(belowGrid === 0 ? {} : figures) };
8181
+ }
8182
+ return { status: failed ? 'FAIL' : 'PASS', ...figures };
8155
8183
  }
8156
8184
 
8157
8185
  /**
package/src/config.ts CHANGED
@@ -1434,6 +1434,41 @@ const DOORS = 'a project\'s own record goes under a key beginning "x-" (any JSON
1434
1434
 
1435
1435
  type Json = unknown;
1436
1436
 
1437
+ /** The loader's config-relative file fields, also used when relocating a config. */
1438
+ const CONFIG_FILE_FIELDS = {
1439
+ requirements: { path: ['requirements'], isFile: (_value: string) => true },
1440
+ animations: { path: ['motion', 'animations_from'], isFile: (_value: string) => true },
1441
+ skeleton: { path: ['generation', 'control', 'skeleton'], isFile: (value: string) => !isSkeletonName(value) },
1442
+ } as const;
1443
+
1444
+ function configFilePath(field: keyof typeof CONFIG_FILE_FIELDS, base: string, value: string): string | null {
1445
+ return CONFIG_FILE_FIELDS[field].isFile(value) ? resolve(base, value) : null;
1446
+ }
1447
+
1448
+ /** A file reference in a parsed config and its setter, excluding built-in names. */
1449
+ export interface ConfigFileReference {
1450
+ at: string;
1451
+ value: string;
1452
+ set: (value: string) => void;
1453
+ }
1454
+
1455
+ /** Enumerate the same file fields that the loader resolves, preserving invalid fields for its diagnostics. */
1456
+ export function configFileReferences(raw: unknown): ConfigFileReference[] {
1457
+ const out: ConfigFileReference[] = [];
1458
+ for (const field of Object.values(CONFIG_FILE_FIELDS)) {
1459
+ let parent = raw;
1460
+ for (const key of field.path.slice(0, -1)) parent = isPlainObject(parent) ? parent[key] : undefined;
1461
+ const key = field.path[field.path.length - 1];
1462
+ if (!isPlainObject(parent)) continue;
1463
+ const value = parent[key];
1464
+ if (typeof value !== 'string' || value === '' || !field.isFile(value)) continue;
1465
+ const target = parent;
1466
+ out.push({ at: field.path.join('.'), value, set: (next) => { target[key] = next; } });
1467
+ }
1468
+ return out;
1469
+ }
1470
+
1471
+
1437
1472
  class Check {
1438
1473
  readonly problems: Problem[] = [];
1439
1474
 
@@ -1836,10 +1871,12 @@ export function cloneConfig<T extends object>(cfg: T): T {
1836
1871
  function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
1837
1872
  if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
1838
1873
  const skel = gen.control.skeleton;
1839
- if (typeof skel !== 'string' || skel === '' || isSkeletonName(skel)) return null;
1874
+ if (typeof skel !== 'string' || skel === '') return null;
1875
+ const file = configFilePath('skeleton', base, skel);
1876
+ if (file === null) return null;
1840
1877
  const l = gen.latent;
1841
1878
  const latent = Array.isArray(l) && l.length === 2 && l.every((n) => typeof n === 'number' && Number.isInteger(n) && n > 0) ? ([l[0], l[1]] as [number, number]) : null;
1842
- return readControlPose(c, resolve(base, skel), latent);
1879
+ return readControlPose(c, file, latent);
1843
1880
  }
1844
1881
 
1845
1882
  /**
@@ -1992,7 +2029,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1992
2029
  if ('turn' in t) checkTurn(c, t.turn, attached, names.bones, hasAltFace, parts, declaredSliders, meshParts, t.meshes, names.chains, names.parents);
1993
2030
  if ('scene' in t) checkScene(c, t.scene);
1994
2031
  if ('cloth' in t) checkCloth(c, t.cloth);
1995
- if ('requirements' in t && c.string('config.requirements', t.requirements) && base !== null) checkRequirementsFile(c, resolve(base, t.requirements));
2032
+ if ('requirements' in t && c.string('config.requirements', t.requirements) && base !== null) checkRequirementsFile(c, configFilePath('requirements', base, t.requirements) as string);
1996
2033
  const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1997
2034
  const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, base, from) : null;
1998
2035
  if (animations !== null) {
@@ -2074,7 +2111,7 @@ function checkRequirementsFile(c: Check, file: string): void {
2074
2111
  */
2075
2112
  function readAnimationsFrom(c: Check, base: string, from: string): AnimationsFrom | null {
2076
2113
  const field = 'config.motion.animations_from';
2077
- const file = resolve(base, from);
2114
+ const file = configFilePath('animations', base, from) as string;
2078
2115
  const form = `{"spec": "${ANIMATIONS_FROM_SPEC}", "animations": {"<name>": {"duration": …, "tracks": [...]}}}`;
2079
2116
  const shape = `a file in rig-c's motion-spec shape, ${form}, its path relative to the config's directory`;
2080
2117
  const where = `names ${JSON.stringify(from)}, resolved against the config's directory ${base} (not the working directory) to ${file}`;
package/src/meshdraft.ts CHANGED
@@ -67,7 +67,7 @@ import { MeshReductionError } from '@rig-c/core/mesh';
67
67
  import { explicitFrameId, idleSchedule } from './automotion.ts';
68
68
  import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
69
69
  import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner } from './check.ts';
70
- import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
70
+ import { type CharacterConfig, configFileReferences, type ConfigFileReference, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
71
71
  import { PartsError, type Problem, problemLine } from './errors.ts';
72
72
  import { IDLE_FPS } from './motion.ts';
73
73
  import { type ContourBridges, FAINT_ALPHA } from './contour.ts';
@@ -743,28 +743,12 @@ export function splicedConfig(text: string, drafted: ReadonlyMap<string, unknown
743
743
  return r.text;
744
744
  }
745
745
 
746
- /** A path in a config the loader resolves against the config's directory, and how to set it. */
747
- export interface RelativeConfigPath {
748
- at: string;
749
- value: string;
750
- set: (v: string) => void;
751
- }
746
+ /** A config-relative file reference supplied by the loader. */
747
+ export type RelativeConfigPath = ConfigFileReference;
752
748
 
753
- /**
754
- * Each path in `o` (a parsed config) the loader resolves against the config's directory — `motion.animations_from`,
755
- * `generation.control.skeleton` — where it is relative and names a file there.
756
- */
757
- export function relativeConfigPaths(configDir: string, o: Json): RelativeConfigPath[] {
758
- const out: RelativeConfigPath[] = [];
759
- const motion = o.motion as Json | undefined;
760
- if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
761
- out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
762
- }
763
- const control = (o.generation as Json | undefined)?.control as Json | undefined;
764
- if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
765
- out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
766
- }
767
- return out;
749
+ /** Each relative file path the loader knows, with built-in skeleton names excluded. */
750
+ export function relativeConfigPaths(_configDir: string, o: Json): RelativeConfigPath[] {
751
+ return configFileReferences(o).filter((field) => !isAbsolute(field.value));
768
752
  }
769
753
 
770
754
  /**