@rig-c/parts 1.19.0 → 1.21.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/src/check.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * | output | what |
32
32
  * | --- | --- |
33
- * | `build/` | `rigc build --profile spine-html --pack --page-edges free --pack-shape polygon` (or `pot`, `rect`, when the caller names them; {@link PAGE_EDGES}, {@link PACK_SHAPES}) — the packed atlas is the final artifact |
33
+ * | `build/` | `rigc build --profile rig-play --pack --page-edges free --pack-shape polygon` (or `pot`, `rect`, when the caller names them; {@link PAGE_EDGES}, {@link PACK_SHAPES}) — the packed atlas is the final artifact |
34
34
  * | `gate_spine-html.txt` | that build's gate lines, verbatim — the one gate: rigc's `build` runs it once over the compile and once over the packed pages on disk, and `spine-html` holds every rule `spine` measures (selftest `CH09`) |
35
35
  * | `idle_frames/` | `rigc render --animation idle --fps 12 --max 640 --geometry`: `frames.json` + `idle/f*.png` + `idle/geometry.json` (each mesh's skinned vertices, which `TEXTURE_STRETCH` reads, every attachment's posed triangles, which `TIP_OVER_ROOT` and both halves of `STILL_REGIONS_DARK` read since issues #118 and #123, and every bone's world transform, which the face half reads its head bone from) |
36
36
  * | `contact.png` | rigc's own contact sheet of that render, copied out |
@@ -80,7 +80,8 @@
80
80
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
81
81
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
82
82
  import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
83
- import { cropToSpineY, normaliseDegrees } from './coords.ts';
83
+ import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal } from './coords.ts';
84
+ import { capBox, inDisc } from './limbs.ts';
84
85
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
85
86
  import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
86
87
  import {
@@ -304,9 +305,17 @@ export interface PackMode {
304
305
  /** Both defaults, {@link DEFAULT_PAGE_EDGES} and {@link DEFAULT_PACK_SHAPE}. */
305
306
  export const DEFAULT_PACK_MODE: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE };
306
307
 
308
+ /**
309
+ * The profile every rigc build this package runs is gated under. rig-c 2.50.0
310
+ * renamed `spine-html` to `rig-play` and runs the same rules under it; the old
311
+ * name is accepted only until a later minor release and prints a deprecation
312
+ * line on stderr (selftest `CK20` holds every stage's build to the new name).
313
+ */
314
+ export const GATE_PROFILE = 'rig-play';
315
+
307
316
  /** The packed build's rigc arguments after `--out <dir>`, as every stage that packs passes them. */
308
317
  export function packedBuildArgs(mode: PackMode): string[] {
309
- return ['--profile', 'spine-html', '--pack', '--page-edges', mode.pageEdges, '--pack-shape', mode.packShape];
318
+ return ['--profile', GATE_PROFILE, '--pack', '--page-edges', mode.pageEdges, '--pack-shape', mode.packShape];
310
319
  }
311
320
 
312
321
  /** The packed build as a label: the rigc command line that was run, after `rigc`. */
@@ -1729,6 +1738,8 @@ export interface CheckFigures {
1729
1738
  [STRESS_LINE]?: JudgementLine;
1730
1739
  /** Written only where parts.json holds a part a `config.limbs` segment made (issue #349): {@link LimbLine}. */
1731
1740
  [LIMB_LINE]?: LimbLine;
1741
+ /** Written only where parts.json holds a limb record with a `range` (issue #360): {@link SweepLine}. */
1742
+ [SWEEP_LINE]?: SweepLine;
1732
1743
  RECOMPOSITE_HOLES: ReportedLine;
1733
1744
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1734
1745
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -1822,6 +1833,46 @@ export const LEVEL_LINE = 'FEET_STAY_LEVEL';
1822
1833
  /** Issue #349: every limb part rides its one bone ({@link limbLine}). */
1823
1834
  export const LIMB_LINE = 'LIMB_ONE_BONE';
1824
1835
 
1836
+ /**
1837
+ * `SEAM_OVER_RANGE` (issue #360, item 13): the seam bar read with each limb
1838
+ * joint bent, not only at rest. Every lower segment `parts.json` records with
1839
+ * a `range` — its config segment's, `[min, max]` degrees from rest — is posed
1840
+ * at `min`, at the middle `(min + max) / 2` and at `max`, each angle once in
1841
+ * that order ({@link sweepAngles}), by a `rotate` key on its bone (rigc's
1842
+ * sense: counter-clockwise on screen), one joint per animation, on a throwaway
1843
+ * copy of the rig with its physics constraints left out.
1844
+ *
1845
+ * - **What is compared.** At each pose, two renders of one build on one grid:
1846
+ * the copy as it is, and the copy with the joint's own art drawn under the
1847
+ * limb ({@link sweepUnderlay}) — the limb's parts composited at rest,
1848
+ * inside the cap's disc (`limb.cap.at` and `radius`), one pixel in from
1849
+ * every edge of what is opaque there — as a region on the bone the joint's
1850
+ * bone hangs from, so it does not turn, in a slot just before the limb's
1851
+ * first. Where the bent limb still covers the disc the two are the same
1852
+ * picture; where the bend uncovers it, the second shows the joint's art and
1853
+ * the first what lies behind the limb. At rest the limb's parts cover every
1854
+ * one of those pixels opaquely, and the one-pixel margin is the reach of a
1855
+ * bilinear sample, so the underlay never half-shows at an edge the limb
1856
+ * draws.
1857
+ * - **The bar is the rest-pose seam's, unchanged.** {@link seamFigures} on the
1858
+ * pair — the second render as the reference, on the first's own grid, so the
1859
+ * map is the identity — and {@link seamFails}: mean |d| at most
1860
+ * {@link SEAM_MEAN_BAR}, at most {@link SEAM_PX_BAR} rig px over
1861
+ * {@link SEAM_PX_LEVEL}, the count divided by the frame scale squared. No
1862
+ * threshold is new.
1863
+ * - **A disc cap is held to its promise** (issue #360, unit B). A joint whose
1864
+ * cap record says `shape: "disc"` (`cap_shape: "disc"` in the config) is
1865
+ * also a FAIL at a pose where any frame pixel of the comparison is over
1866
+ * {@link SEAM_PX_LEVEL}: the disc is the cap's whole claim, so one uncovered
1867
+ * pixel of it is one too many. The level is the seam's; the count's bar is
1868
+ * the card's ("the joint disc holds no background pixel"), not a figure
1869
+ * measured here. Its row says `shape: "disc"`; no other row changes.
1870
+ * - **Opt-in by construction.** Written only when `parts.json` holds a limb
1871
+ * record with a `range`; a rig without one renders nothing more and writes
1872
+ * what it wrote.
1873
+ */
1874
+ export const SWEEP_LINE = 'SEAM_OVER_RANGE';
1875
+
1825
1876
  /** HQ's line (rig-c/parts#347 ruling 4, issue #348): the sole within ±10 degrees of level while the shin is posed. Not measured here. */
1826
1877
  export const LEVEL_BAR_DEGREES = 10;
1827
1878
 
@@ -1870,7 +1921,7 @@ export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
1870
1921
  * rig-c's and never accepted, {@link FOLD_LINE} (issue #320) and
1871
1922
  * {@link STRESS_LINE} (issue #347).
1872
1923
  */
1873
- export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE];
1924
+ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE];
1874
1925
 
1875
1926
  /**
1876
1927
  * The bars a `check.json`, as `check` writes it, says were not met, in
@@ -1879,7 +1930,7 @@ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== '
1879
1930
  * seam when its figures fail {@link seamFails}, and each judgement line whose
1880
1931
  * status is `FAIL`, then {@link FOLD_LINE} when it is written and `FAIL`
1881
1932
  * (issue #320), then {@link LEVEL_LINE} the same way (issue #348), then {@link STRESS_LINE} likewise (issue #347), then
1882
- * {@link LIMB_LINE} (issue #349). A bar that measured nothing (null figures, `SKIP`) is not
1933
+ * {@link LIMB_LINE} (issue #349), then {@link SWEEP_LINE} (issue #360). A bar that measured nothing (null figures, `SKIP`) is not
1883
1934
  * one of them. Read off the file rather than its problems, because the file is
1884
1935
  * all a finished build leaves (issue #203).
1885
1936
  */
@@ -1890,7 +1941,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1890
1941
  // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1891
1942
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1892
1943
  if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
1893
- for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE]) {
1944
+ for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE]) {
1894
1945
  const l = fig[n];
1895
1946
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
1896
1947
  }
@@ -2583,6 +2634,275 @@ export function limbLine(parts: PartsFile, rig: Record<string, unknown>, problem
2583
2634
  return { status: rows.some((r) => r.status === 'FAIL') ? 'FAIL' : 'PASS', parts: rows };
2584
2635
  }
2585
2636
 
2637
+ // ---------------------------------------------------------------------------
2638
+ // the seam over a joint's declared range (issue #360; the rule is {@link SWEEP_LINE}'s)
2639
+ // ---------------------------------------------------------------------------
2640
+
2641
+ /** The sweep's copy, builds and renders, under `--out`; removed before the stage returns. */
2642
+ const SWEEP_SCRATCH = '_sweep';
2643
+
2644
+ /** The transparent margin round an underlay image, as `src/rig.ts` gives every part image (its `PAD`), so its edge is sampled against transparency. */
2645
+ const UNDER_PAD = 4;
2646
+
2647
+ /** One joint the line poses: the lower segment's part and its limb record. */
2648
+ export interface SweepJoint {
2649
+ part: string;
2650
+ limb: string;
2651
+ segment: number;
2652
+ joint: string;
2653
+ /** The lower segment's bone: the one the bend turns. */
2654
+ bone: string;
2655
+ range: [number, number];
2656
+ at: [number, number];
2657
+ radius: number;
2658
+ /** Issue #360: the cap's record says `shape: "disc"` — the disc is held to no pixel uncovered ({@link sweepLine}). */
2659
+ disc?: true;
2660
+ }
2661
+
2662
+ /** Every limb record in `parts.json` that declares a `range`, in `parts.json`'s order. */
2663
+ export function sweepJoints(parts: PartsFile): SweepJoint[] {
2664
+ const out: SweepJoint[] = [];
2665
+ for (const p of parts.parts) {
2666
+ const l = p.limb;
2667
+ if (l === undefined || l.range === undefined || l.cap === undefined || l.joint === undefined) continue;
2668
+ out.push({ part: p.name, limb: l.name, segment: l.segment, joint: l.joint, bone: l.bone, range: [l.range[0], l.range[1]], at: [l.cap.at[0], l.cap.at[1]], radius: l.cap.radius, ...(l.cap.shape === 'disc' ? { disc: true as const } : {}) });
2669
+ }
2670
+ return out;
2671
+ }
2672
+
2673
+ /** The angles a joint is posed at: the range's two ends and its middle, `[min, (min + max) / 2, max]`, each once, in that order. */
2674
+ export function sweepAngles(range: readonly [number, number]): number[] {
2675
+ const out: number[] = [];
2676
+ for (const a of [range[0], (range[0] + range[1]) / 2, range[1]]) if (!out.includes(a)) out.push(a);
2677
+ return out;
2678
+ }
2679
+
2680
+ /** The animation one joint at one angle is posed by on the copy. */
2681
+ export function sweepAnimationName(part: string, degrees: number): string {
2682
+ return `seam over range ${part} ${degrees}`;
2683
+ }
2684
+
2685
+ /** The underlay slot of a joint's lower part: `<part>_under`, with one more `_` for each name the rig already holds. */
2686
+ export function underlaySlotName(part: string, taken: ReadonlySet<string>): string {
2687
+ let n = `${part}_under`;
2688
+ while (taken.has(n)) n += '_';
2689
+ return n;
2690
+ }
2691
+
2692
+ /**
2693
+ * The joint's own art at rest (see {@link SWEEP_LINE}): the limb's parts —
2694
+ * every `parts.json` record whose `limb.name` is the joint's limb — composited
2695
+ * at their boxes in `parts.json`'s order over a transparent canvas, and of that
2696
+ * each pixel that, with its eight neighbours, lies inside the cap's disc
2697
+ * ({@link inDisc}) at alpha 255, cropped to the cap's box ({@link capBox})
2698
+ * and padded by {@link UNDER_PAD}. `box` is the padded image's place in rig
2699
+ * px, `px` the pixels it holds.
2700
+ */
2701
+ export function sweepUnderlay(parts: PartsFile, image: (name: string) => Raster, j: SweepJoint): { box: [number, number, number, number]; image: Raster; px: number } {
2702
+ const [W, H] = parts.rig_size;
2703
+ let canvas = newRaster(W, H);
2704
+ for (const p of parts.parts) if (p.limb?.name === j.limb) canvas = alphaComposite(canvas, image(p.name), p.x, p.y);
2705
+ const solid = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < W && y < H && inDisc(j.at, j.radius, x, y) && canvas.data[(y * W + x) * 4 + 3] === 255;
2706
+ const [bx, by, bw, bh] = capBox(j.at, j.radius);
2707
+ const out = newRaster(bw + 2 * UNDER_PAD, bh + 2 * UNDER_PAD);
2708
+ let px = 0;
2709
+ for (let y = by; y < by + bh; y++) {
2710
+ for (let x = bx; x < bx + bw; x++) {
2711
+ let inner = true;
2712
+ for (let dy = -1; dy <= 1 && inner; dy++) for (let dx = -1; dx <= 1 && inner; dx++) inner = solid(x + dx, y + dy);
2713
+ if (!inner) continue;
2714
+ const s = (y * W + x) * 4;
2715
+ out.data.set(canvas.data.subarray(s, s + 4), ((y - by + UNDER_PAD) * out.width + (x - bx + UNDER_PAD)) * 4);
2716
+ px++;
2717
+ }
2718
+ }
2719
+ return { box: [bx - UNDER_PAD, by - UNDER_PAD, out.width, out.height], image: out, px };
2720
+ }
2721
+
2722
+ /** Why a joint is not read, or null when it is: its bone is not in rig.json with a parent, no slot of rig.json shows one of its limb's parts, or its disc holds no opaque art. */
2723
+ export function sweepUnread(j: SweepJoint, rig: Record<string, unknown>, limbSlots: readonly string[], underPx: number): string | null {
2724
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
2725
+ const own = bones.find((b) => b.name === j.bone);
2726
+ if (own === undefined || typeof own.parent !== 'string' || own.parent === '') return `"${j.bone}" is not a bone of rig.json with a parent, so there is no bone the joint's art can stay on while it turns`;
2727
+ if (limbSlots.length === 0) return `no slot of rig.json shows a part of limb "${j.limb}", so there is no limb to draw the joint's art under`;
2728
+ if (underPx === 0) return `the disc of radius ${j.radius} px about [${j.at.join(', ')}] holds no pixel of limb "${j.limb}"'s parts at rest that is opaque (alpha 255) with its eight neighbours, so a gap the bend opens cannot be seen against the joint's art`;
2729
+ return null;
2730
+ }
2731
+
2732
+ /**
2733
+ * The throwaway copy (see {@link SWEEP_LINE}): `rig` with one underlay slot per
2734
+ * read joint — a region on the parent of the joint's bone, centred on its box
2735
+ * and turned back by that bone's world rotation so it is drawn upright, as
2736
+ * `src/rig.ts` places a region (rig-c's frames, through `src/coords.ts`) —
2737
+ * just before the first slot of the joint's limb; `motion` with its
2738
+ * animations replaced by one per read joint and angle, a `rotate` key on the
2739
+ * joint's bone held from 0 to the still's duration. `read[i]` is joint `i`'s
2740
+ * underlay, or null when it is not read; `files` the names, less `.png`, the
2741
+ * images directory already holds, which a slot's name avoids as it avoids the
2742
+ * slots ({@link underlaySlotName}). Returns the copy, the underlay slots (for
2743
+ * `--hide`) and their images, each named `<slot>.png`.
2744
+ */
2745
+ export function sweepCopy(
2746
+ rig: Record<string, unknown>,
2747
+ motion: Record<string, unknown>,
2748
+ joints: readonly SweepJoint[],
2749
+ read: ReadonlyArray<{ box: [number, number, number, number]; image: Raster } | null>,
2750
+ limbSlots: (limb: string) => string[],
2751
+ stage: StageBox,
2752
+ rigH: number,
2753
+ files: ReadonlySet<string> = new Set(),
2754
+ ): { rig: Record<string, unknown>; motion: Record<string, unknown>; slots: string[]; images: Array<[string, Raster]> } {
2755
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
2756
+ const posable: Parameters<typeof computeExactFrameTransforms>[0][number][] = bones.map((b) => ({
2757
+ name: String(b.name),
2758
+ ...(typeof b.parent === 'string' && b.parent !== '' ? { parent: b.parent } : {}),
2759
+ ...Object.fromEntries((['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY'] as const).filter((k) => typeof b[k] === 'number').map((k) => [k, b[k] as number])),
2760
+ ...(typeof b.inherit === 'string' ? { inherit: b.inherit } : {}),
2761
+ }));
2762
+ const world = computeExactFrameTransforms(posable);
2763
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => ({ ...s }));
2764
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2765
+ const skin: Record<string, unknown> = { ...(isRecord(skins.default) ? skins.default : {}) };
2766
+ const taken = new Set([...slots.map((s) => String(s.name)), ...files]);
2767
+ const added: string[] = [];
2768
+ const images: Array<[string, Raster]> = [];
2769
+ const animations: Record<string, unknown> = {};
2770
+ joints.forEach((j, i) => {
2771
+ const u = read[i];
2772
+ if (u === null) return;
2773
+ const parent = String((bones.find((b) => b.name === j.bone) as Record<string, unknown>).parent);
2774
+ const m = world.get(parent) as BoneTransform;
2775
+ const name = underlaySlotName(j.part, taken);
2776
+ taken.add(name);
2777
+ const [x, y] = toBoneLocal(m, stage.x + u.box[0] + u.box[2] / 2, stage.y + cropToSpineY(u.box[1] + u.box[3] / 2, rigH));
2778
+ const turn = normaliseDegrees(-m.worldRotation);
2779
+ skin[name] = { [name]: turn === 0 ? { image: `${name}.png`, x, y } : { image: `${name}.png`, x, y, rotation: turn } };
2780
+ const mine = new Set(limbSlots(j.limb));
2781
+ slots.splice(slots.findIndex((s) => mine.has(String(s.name))), 0, { name, bone: parent, attachment: name });
2782
+ added.push(name);
2783
+ images.push([`${name}.png`, u.image]);
2784
+ for (const a of sweepAngles(j.range)) {
2785
+ animations[sweepAnimationName(j.part, a)] = { duration: STILL_DURATION, loop: false, tracks: [{ bone: j.bone, property: 'rotate', keys: [{ t: 0, v: [a] }, { t: STILL_DURATION, v: [a] }] }] };
2786
+ }
2787
+ });
2788
+ return { rig: { ...rig, slots, skins: { ...skins, default: skin } }, motion: { ...motion, animations }, slots: added, images };
2789
+ }
2790
+
2791
+ /** One pose of a joint: the angle from rest and the rest-pose seam's figures read there ({@link SWEEP_LINE}). */
2792
+ export interface SweepPose {
2793
+ degrees: number;
2794
+ status: 'PASS' | 'FAIL';
2795
+ mean: number;
2796
+ px_over_40: number;
2797
+ px_over_80: number;
2798
+ rig_px_over_40: number;
2799
+ }
2800
+
2801
+ /** One joint's row: its limb record, its disc, how many of the joint's own art pixels the disc holds, and each pose — or, not read, why. */
2802
+ export interface SweepRow {
2803
+ part: string;
2804
+ limb: string;
2805
+ segment: number;
2806
+ joint: string;
2807
+ bone: string;
2808
+ range: [number, number];
2809
+ at: [number, number];
2810
+ radius: number;
2811
+ /** Issue #360: written only for a joint whose cap is `cap_shape: "disc"`, held to no pixel over the level in its disc. */
2812
+ shape?: 'disc';
2813
+ under_px: number;
2814
+ status: 'PASS' | 'FAIL';
2815
+ poses: SweepPose[];
2816
+ unread?: string;
2817
+ }
2818
+
2819
+ /** `check.json`'s `SEAM_OVER_RANGE`: the frame scale the counts are divided by (null when no joint was read), the physics constraints left out, the bar, and one row per joint. */
2820
+ export interface SweepLine {
2821
+ status: 'PASS' | 'FAIL';
2822
+ frame_scale: number | null;
2823
+ physics_left_out: number;
2824
+ bar: { mean: number; rig_px_over_40: number; level: number };
2825
+ joints: SweepRow[];
2826
+ }
2827
+
2828
+ /**
2829
+ * The line from the renders: `frames(animation)` gives the pose's two frame
2830
+ * sets, with the joints' art under the limb (`under`) and without it (`bare`).
2831
+ * Every set is on one grid over one background, or the line is refused
2832
+ * (`CHECK_SEAM_OVER_RANGE`): the figures compare them pixel for pixel. Each
2833
+ * pose that fails {@link seamFails}, and each joint not read, adds one
2834
+ * `CHECK_SEAM_OVER_RANGE` problem naming the joint and the angle.
2835
+ */
2836
+ export function sweepLine(
2837
+ joints: readonly SweepJoint[],
2838
+ underPx: readonly number[],
2839
+ unread: ReadonlyArray<string | null>,
2840
+ frames: (animation: string) => { under: FrameSet; bare: FrameSet },
2841
+ physicsLeftOut: number,
2842
+ problems: Problem[],
2843
+ ): SweepLine {
2844
+ const seen: { grid: FrameSet | null } = { grid: null };
2845
+ const rows: SweepRow[] = joints.map((j, i) => {
2846
+ const object = (at: string): string => `joint "${j.joint}" (config.limbs.${j.limb} segment ${j.segment}, part "${j.part}", bone "${j.bone}") ${at}`;
2847
+ const head = { part: j.part, limb: j.limb, segment: j.segment, joint: j.joint, bone: j.bone, range: j.range, at: j.at, radius: j.radius, ...(j.disc === true ? { shape: 'disc' as const } : {}), under_px: underPx[i] };
2848
+ const why = unread[i];
2849
+ if (why !== null) {
2850
+ problems.push({ code: `CHECK_${SWEEP_LINE}`, object: object(`over its range [${j.range.join(', ')}]`), detail: `is not read: ${why}; a joint not read is not a pass` });
2851
+ return { ...head, status: 'FAIL', poses: [], unread: why };
2852
+ }
2853
+ const poses: SweepPose[] = sweepAngles(j.range).map((a) => {
2854
+ const { under, bare } = frames(sweepAnimationName(j.part, a));
2855
+ for (const s of [under, bare]) {
2856
+ const first: FrameSet = seen.grid ?? s;
2857
+ if (!sameViewport(first.viewport, s.viewport) || first.background.join(',') !== s.background.join(',')) {
2858
+ refuseIfAny([
2859
+ {
2860
+ code: `CHECK_${SWEEP_LINE}`,
2861
+ object: `the render of "${sweepAnimationName(j.part, a)}"${s === under ? '' : ' with the underlays hidden'}`,
2862
+ detail: `is on ${JSON.stringify(s.viewport)} over ${JSON.stringify(s.background)}; the sweep's first render is on ${JSON.stringify(first.viewport)} over ${JSON.stringify(first.background)}, and the two renders of a pose are compared pixel for pixel`,
2863
+ },
2864
+ ]);
2865
+ }
2866
+ seen.grid = first;
2867
+ }
2868
+ const vp = bare.viewport;
2869
+ const f = seamFigures(under.frames[0].image, bare.frames[0].image, { x: 0, y: 0, width: vp.pixelWidth, height: vp.pixelHeight }, vp, bare.background, { sx: 1, sy: 1, tx: 0, ty: 0 });
2870
+ const mean = round3(f.mean);
2871
+ const scale = round6(vp.scale);
2872
+ // Issue #360: a disc cap promises the disc covered at every pose, so one pixel over the level there is a FAIL of its own.
2873
+ const discFail = j.disc === true && f.over40 > 0 && !seamFails(mean, f.over40, scale);
2874
+ const fail = seamFails(mean, f.over40, scale) || discFail;
2875
+ if (discFail) {
2876
+ problems.push({
2877
+ code: `CHECK_${SWEEP_LINE}`,
2878
+ object: object(`bent ${a}°`),
2879
+ detail:
2880
+ `${f.over40} frame px over ${SEAM_PX_LEVEL} (${f.over80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${scale}, mean |d| ${mean}/255, against the same pose with the joint's own art drawn under the limb (${underPx[i]} px, the disc of radius ${j.radius} px about [${j.at.join(', ')}]); ` +
2881
+ `the cap is cap_shape "disc", which promises the disc covered at every pose — no pixel of it over ${SEAM_PX_LEVEL}, under the rest-pose seam's bar as well — and the bend shows what lies behind the limb there: a cap pixel the art did not give ("limb cap:" counts them as missing), or a pixel cleared from "${j.part}" after assemble wrote it`,
2882
+ });
2883
+ } else if (fail) {
2884
+ problems.push({
2885
+ code: `CHECK_${SWEEP_LINE}`,
2886
+ object: object(`bent ${a}°`),
2887
+ detail:
2888
+ `mean |d| ${mean}/255 and ${f.over40} frame px over ${SEAM_PX_LEVEL} (${f.over80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${scale}, ${seamRigPxText(f.over40, scale)} rig px (the count / s²), against the same pose with the joint's own art drawn under the limb (${underPx[i]} px, the disc of radius ${j.radius} px about [${j.at.join(', ')}]); ` +
2889
+ `mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required, the rest-pose seam's bar — the bend uncovers the joint, and a cap that covers the turn (the limb's art within that radius on the upper side of the joint line, carried by "${j.part}") keeps it covered`,
2890
+ });
2891
+ }
2892
+ return { degrees: a, status: fail ? 'FAIL' : 'PASS', mean, px_over_40: f.over40, px_over_80: f.over80, rig_px_over_40: Number(seamRigPxText(f.over40, scale)) };
2893
+ });
2894
+ return { ...head, status: poses.some((p) => p.status === 'FAIL') ? 'FAIL' : 'PASS', poses };
2895
+ });
2896
+ const g = seen.grid;
2897
+ return {
2898
+ status: rows.some((r) => r.status === 'FAIL') ? 'FAIL' : 'PASS',
2899
+ frame_scale: g === null ? null : round6(g.viewport.scale),
2900
+ physics_left_out: physicsLeftOut,
2901
+ bar: { mean: SEAM_MEAN_BAR, rig_px_over_40: SEAM_PX_BAR, level: SEAM_PX_LEVEL },
2902
+ joints: rows,
2903
+ };
2904
+ }
2905
+
2586
2906
  /**
2587
2907
  * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
2588
2908
  * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
@@ -2864,6 +3184,73 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2864
3184
  }
2865
3185
  }
2866
3186
 
3187
+ // 3d. each limb joint with a declared range, bent to its ends and middle (issue #360): a throwaway copy with the
3188
+ // joints' own art under the limbs, physics left out, rendered twice by rigc on one grid — none without a range
3189
+ let sweep: SweepLine | null = null;
3190
+ const sweepProblems: Problem[] = [];
3191
+ const joints = inp.parts === null ? [] : sweepJoints(inp.parts);
3192
+ if (joints.length > 0) {
3193
+ const parts = inp.parts as PartsFile;
3194
+ const sweepDir = join(out, SWEEP_SCRATCH);
3195
+ rmSync(sweepDir, { recursive: true, force: true });
3196
+ try {
3197
+ const base = stripped === null ? inp.rig : stripped.rig;
3198
+ const slotNames = (Array.isArray(base.slots) ? base.slots : []).filter(isRecord).map((s) => String(s.name));
3199
+ const limbSlots = (limb: string): string[] => {
3200
+ const mine = new Set(parts.parts.filter((p) => p.limb?.name === limb).map((p) => p.name));
3201
+ return slotNames.filter((n) => mine.has(n));
3202
+ };
3203
+ const pngs = new Map<string, Raster>();
3204
+ const png = (name: string): Raster => {
3205
+ const hit = pngs.get(name);
3206
+ if (hit !== undefined) return hit;
3207
+ const r = readPng(join(inp.partsDir, `${name}.png`));
3208
+ pngs.set(name, r);
3209
+ return r;
3210
+ };
3211
+ const under = joints.map((j) => sweepUnderlay(parts, png, j));
3212
+ const unread = joints.map((j, i) => sweepUnread(j, base, limbSlots(j.limb), under[i].px));
3213
+ const imagesFrom = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : null;
3214
+ const imagesDir = join(sweepDir, 'images');
3215
+ mkdirSync(imagesDir, { recursive: true });
3216
+ if (imagesFrom !== null && existsSync(imagesFrom)) cpSync(imagesFrom, imagesDir, { recursive: true });
3217
+ const files = new Set(readdirSync(imagesDir).filter((f) => f.endsWith('.png')).map((f) => f.slice(0, -'.png'.length)));
3218
+ const copy = sweepCopy(base, stripped === null ? inp.motion : stripped.motion, joints, under.map((u, i) => (unread[i] === null ? u : null)), limbSlots, inp.stage, parts.rig_size[1], files);
3219
+ const sets = new Map<string, { under: FrameSet; bare: FrameSet }>();
3220
+ if (copy.slots.length > 0) {
3221
+ for (const [file, image] of copy.images) writePng(join(imagesDir, file), image);
3222
+ writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...copy.rig, images: imagesDir }));
3223
+ writeFileSync(join(sweepDir, 'motion.json'), JSON.stringify(copy.motion));
3224
+ const sb = rigc(['build', '--rig', join(sweepDir, 'rig.json'), '--motion', join(sweepDir, 'motion.json'), '--out', join(sweepDir, 'build'), '--profile', 'spine']);
3225
+ if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${SWEEP_LINE}'s bent copy)`, sb, buildGateLines(sb.out)));
3226
+ // The stage is the parts' canvas wherever parts.json is read (CHECK_RIG_STAGE_IS_THE_CANVAS), so this is its longest side, as the setup-pose still's.
3227
+ const maxSide = Math.ceil(Math.max(inp.stage.width, inp.stage.height));
3228
+ for (const [label, hide] of [['under', []], ['bare', ['--hide', copy.slots.join(',')]]] as const) {
3229
+ const r = rigc(['render', '--candidate', join(sweepDir, 'build'), '--fps', String(STILL_FPS), '--max', String(maxSide), ...hide, '--out', join(sweepDir, label)]);
3230
+ if (r.status !== 0) refuseIfAny(rigcFailed(`render (${SWEEP_LINE}'s bent copy${hide.length === 0 ? '' : ', the underlays hidden'})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3231
+ }
3232
+ const named = (label: string, animation: string): FrameSet => {
3233
+ const sidecar = join(sweepDir, label, 'frames.json');
3234
+ const raw = readJson(sidecar, []);
3235
+ const listed = raw !== null && Array.isArray(raw.sets) ? raw.sets.filter(isRecord) : [];
3236
+ const s = listed.find((x) => x.animation === animation);
3237
+ if (s === undefined || typeof s.dir !== 'string') return sidecarProblem(sidecar, `names no frame set for animation ${JSON.stringify(animation)}; \`rigc render\` over ${SWEEP_LINE}'s bent copy writes one per animation`);
3238
+ return readFrameSet(join(sweepDir, label, s.dir));
3239
+ };
3240
+ joints.forEach((j, i) => {
3241
+ if (unread[i] !== null) return;
3242
+ for (const a of sweepAngles(j.range)) {
3243
+ const name = sweepAnimationName(j.part, a);
3244
+ sets.set(name, { under: named('under', name), bare: named('bare', name) });
3245
+ }
3246
+ });
3247
+ }
3248
+ sweep = sweepLine(joints, under.map((u) => u.px), unread, (a) => sets.get(a) as { under: FrameSet; bare: FrameSet }, stripped === null ? 0 : stripped.physics.length, sweepProblems);
3249
+ } finally {
3250
+ rmSync(sweepDir, { recursive: true, force: true });
3251
+ }
3252
+ }
3253
+
2867
3254
  // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
2868
3255
  // the painting — and, beside it, the same pose with the eyes shut
2869
3256
  let seam: SeamFigures | null = null;
@@ -2975,6 +3362,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2975
3362
  ...(level === null ? {} : { [LEVEL_LINE]: level }),
2976
3363
  ...(stress === null ? {} : { [STRESS_LINE]: stress }),
2977
3364
  ...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
3365
+ ...(sweep === null ? {} : { [SWEEP_LINE]: sweep }),
2978
3366
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
2979
3367
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
2980
3368
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -3006,7 +3394,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3006
3394
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
3007
3395
  problems.sort((a, b) => rank(a) - rank(b));
3008
3396
  problems.unshift(...barProblems);
3009
- problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...reqProblems);
3397
+ problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...sweepProblems, ...reqProblems);
3010
3398
  figures.PASS = problems.length === 0;
3011
3399
  write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
3012
3400
  const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
@@ -3028,6 +3416,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3028
3416
  if (stress !== null) tally(STRESS_LINE, stress.status === 'SKIP' ? stress.reason : null);
3029
3417
  // Issue #349: the limb bar where it is written; it reads rig.json, so it always measures.
3030
3418
  if (limbs !== null) tally(LIMB_LINE, null);
3419
+ // Issue #360: the range bar where it is written; a joint it cannot read fails, so it always measures.
3420
+ if (sweep !== null) tally(SWEEP_LINE, null);
3031
3421
  return {
3032
3422
  figures,
3033
3423
  gateHtml,