@rig-c/parts 1.20.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/build.ts CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -1135,6 +1135,22 @@ export function limbCheckLines(line: LimbLine): string[] {
1135
1135
  ];
1136
1136
  }
1137
1137
 
1138
+ /**
1139
+ * The seam over the range (issue #360) as the console prints it: the status, what was posed and the bar, then each
1140
+ * joint at each angle on a line of its own — its status and the seam figures there — or, not read, why.
1141
+ */
1142
+ export function sweepCheckLines(line: SweepLine): string[] {
1143
+ const s = line.frame_scale;
1144
+ return [
1145
+ `${SWEEP_LINE}: ${line.status} — ${line.joints.length} joint(s) bent to their declared range's ends and middle${line.physics_left_out === 0 ? '' : `, ${line.physics_left_out} physics constraint(s) left out`}; at each, the pose against the same pose with the joint's own art drawn under the limb, on the rest-pose seam's bar (mean |d| <= ${line.bar.mean.toFixed(1)}, <= ${line.bar.rig_px_over_40} rig px over ${line.bar.level})${s === null ? '' : ` at frame scale ${s}`}`,
1146
+ ...line.joints.flatMap((j) => {
1147
+ const head = `joint "${j.joint}" (${j.limb}, "${j.part}" on "${j.bone}", range [${j.range.join(', ')}]${j.shape === 'disc' ? `, cap_shape "disc": no pixel over ${line.bar.level} in the disc` : ''})`;
1148
+ if (j.unread !== undefined) return [`${head}: FAIL — not read: ${j.unread}`];
1149
+ return j.poses.map((p) => `${head} at ${p.degrees}°: ${p.status} — mean |d| ${p.mean}/255, ${p.px_over_40} frame px over ${line.bar.level} (${p.px_over_80} over ${SEAM_PX_LEVEL_HIGH}), ${p.rig_px_over_40.toFixed(2)} rig px`);
1150
+ }),
1151
+ ];
1152
+ }
1153
+
1138
1154
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1139
1155
  export function requirementText(name: string, line: RequirementLine): string {
1140
1156
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1181,6 +1197,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1181
1197
  if (stress !== undefined) log(` ${judgementLine(STRESS_LINE, stress)}`);
1182
1198
  const limbs = fig[LIMB_LINE];
1183
1199
  if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1200
+ const sweep = fig[SWEEP_LINE];
1201
+ if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1184
1202
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1185
1203
  const vsSource = fig[SOURCE_LINE];
1186
1204
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
package/src/check.ts CHANGED
@@ -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 {
@@ -1737,6 +1738,8 @@ export interface CheckFigures {
1737
1738
  [STRESS_LINE]?: JudgementLine;
1738
1739
  /** Written only where parts.json holds a part a `config.limbs` segment made (issue #349): {@link LimbLine}. */
1739
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;
1740
1743
  RECOMPOSITE_HOLES: ReportedLine;
1741
1744
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1742
1745
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -1830,6 +1833,46 @@ export const LEVEL_LINE = 'FEET_STAY_LEVEL';
1830
1833
  /** Issue #349: every limb part rides its one bone ({@link limbLine}). */
1831
1834
  export const LIMB_LINE = 'LIMB_ONE_BONE';
1832
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
+
1833
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. */
1834
1877
  export const LEVEL_BAR_DEGREES = 10;
1835
1878
 
@@ -1878,7 +1921,7 @@ export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
1878
1921
  * rig-c's and never accepted, {@link FOLD_LINE} (issue #320) and
1879
1922
  * {@link STRESS_LINE} (issue #347).
1880
1923
  */
1881
- 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];
1882
1925
 
1883
1926
  /**
1884
1927
  * The bars a `check.json`, as `check` writes it, says were not met, in
@@ -1887,7 +1930,7 @@ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== '
1887
1930
  * seam when its figures fail {@link seamFails}, and each judgement line whose
1888
1931
  * status is `FAIL`, then {@link FOLD_LINE} when it is written and `FAIL`
1889
1932
  * (issue #320), then {@link LEVEL_LINE} the same way (issue #348), then {@link STRESS_LINE} likewise (issue #347), then
1890
- * {@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
1891
1934
  * one of them. Read off the file rather than its problems, because the file is
1892
1935
  * all a finished build leaves (issue #203).
1893
1936
  */
@@ -1898,7 +1941,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1898
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.
1899
1942
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1900
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');
1901
- 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]) {
1902
1945
  const l = fig[n];
1903
1946
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
1904
1947
  }
@@ -2591,6 +2634,275 @@ export function limbLine(parts: PartsFile, rig: Record<string, unknown>, problem
2591
2634
  return { status: rows.some((r) => r.status === 'FAIL') ? 'FAIL' : 'PASS', parts: rows };
2592
2635
  }
2593
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
+
2594
2906
  /**
2595
2907
  * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
2596
2908
  * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
@@ -2872,6 +3184,73 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2872
3184
  }
2873
3185
  }
2874
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
+
2875
3254
  // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
2876
3255
  // the painting — and, beside it, the same pose with the eyes shut
2877
3256
  let seam: SeamFigures | null = null;
@@ -2983,6 +3362,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2983
3362
  ...(level === null ? {} : { [LEVEL_LINE]: level }),
2984
3363
  ...(stress === null ? {} : { [STRESS_LINE]: stress }),
2985
3364
  ...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
3365
+ ...(sweep === null ? {} : { [SWEEP_LINE]: sweep }),
2986
3366
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
2987
3367
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
2988
3368
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -3014,7 +3394,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3014
3394
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
3015
3395
  problems.sort((a, b) => rank(a) - rank(b));
3016
3396
  problems.unshift(...barProblems);
3017
- problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...reqProblems);
3397
+ problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...sweepProblems, ...reqProblems);
3018
3398
  figures.PASS = problems.length === 0;
3019
3399
  write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
3020
3400
  const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
@@ -3036,6 +3416,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3036
3416
  if (stress !== null) tally(STRESS_LINE, stress.status === 'SKIP' ? stress.reason : null);
3037
3417
  // Issue #349: the limb bar where it is written; it reads rig.json, so it always measures.
3038
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);
3039
3421
  return {
3040
3422
  figures,
3041
3423
  gateHtml,