@rig-c/parts 1.20.0 → 1.22.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
@@ -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,
package/src/config.ts CHANGED
@@ -213,6 +213,16 @@ export interface Cut {
213
213
  * pixels; it is read nowhere else. Absent: the base keeps only its band.
214
214
  */
215
215
  under?: string;
216
+ /**
217
+ * Issue #360 (item 11), optional, one value: `"colour"` — where the under
218
+ * fills a removed pixel, the base takes the under's colour and keeps the
219
+ * alpha the cut removed there, so the part's silhouette is the uncut
220
+ * layer's and never grows. The under is `under`'s image or the part's
221
+ * hidden-art layer (issue #359); a cut with neither is refused by the
222
+ * assemble stage (`ASSEMBLE_UNDER_FILL_SOURCE`). Absent: the base takes the
223
+ * under's colour and alpha, issue #193's rule.
224
+ */
225
+ under_fill?: 'colour';
216
226
  }
217
227
 
218
228
  export interface Assemble {
@@ -643,9 +653,18 @@ export interface ConfigConstraint {
643
653
  * pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
644
654
  * rides with weight 1. Every segment after the first sits below a joint at
645
655
  * its bone's origin: `joint` names it, `range` is the bend the joint may take
646
- * from rest, `[min, max]` degrees (metadata: read by the stress readers, not
647
- * by rig-c), and `cap` an image, relative to the config's directory, of the
648
- * cap that overlaps the joint (`limbCaps` in `src/assemble.ts`).
656
+ * from rest, `[min, max]` degrees (carried into `parts.json` and posed by
657
+ * `check`'s `SEAM_OVER_RANGE`, issue #360; not read by rig-c), and `cap` an
658
+ * image, relative to the config's directory, of the cap that overlaps the
659
+ * joint (`limbCaps` in `src/assemble.ts`). Two opt-in fields shape the cut at
660
+ * the joint (issue #360, `src/limbs.ts`): `edge: "feather"` fades the upper
661
+ * piece out across the cap, and `cap_shape: "disc"` sizes the cap by the
662
+ * limb's half-width about the joint and fills it from the art, the upper
663
+ * segment's included. Absent, the cut
664
+ * is hard and the cap the layer's half-disc of half the limb's width. A third,
665
+ * `cut_band: N` (issue #360, item 14), places the cut on the line within N px
666
+ * of the joint line that crosses the fewest stroke pixels; absent, the cut is
667
+ * the joint line.
649
668
  */
650
669
  export interface LimbSegment {
651
670
  part: string;
@@ -654,6 +673,10 @@ export interface LimbSegment {
654
673
  joint?: string;
655
674
  range?: [number, number];
656
675
  cap?: string;
676
+ edge?: 'feather';
677
+ cap_shape?: 'disc';
678
+ /** Issue #360 (item 14): the half-width, whole rig px, of the band about the joint line the cut is placed in (`placeCut` in `src/limbs.ts`). Absent, the cut is the joint line. */
679
+ cut_band?: number;
657
680
  }
658
681
 
659
682
  /** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
@@ -1340,25 +1363,36 @@ export interface SkeletonSections {
1340
1363
 
1341
1364
  /**
1342
1365
  * A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
1343
- * `motion` and `notes` (strings) — under the full loader's own rules for the
1344
- * four rig sections (issue #85). There is no plan to hold the meshes and
1345
- * regions against, so the coverage rule (every plan part exactly one of a
1346
- * mesh or a region) is the one rule not run; `propose` held its proposal to
1347
- * it before writing (`checkProposal`). A key the loader would not take is
1366
+ * `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
1367
+ * them (issue #349) and `constraints` when `propose --cloth` did (issue #364;
1368
+ * both read here since issue #372, whose `wind` example tracks such a
1369
+ * proposal) — under the full loader's own rules for those sections (issue
1370
+ * #85). There is no plan to hold the meshes, regions and limbs against, so
1371
+ * the coverage rule (every plan part exactly one of a mesh or a region) is
1372
+ * not run, and a limb segment's `from` is held to no plan part (the parts
1373
+ * the limbs take are the ones they name); `propose` held its proposal to
1374
+ * both before writing (`checkProposal`). A key the loader would not take is
1348
1375
  * refused by name, as in a config. Refusals name the fields `proposal.…`.
1349
1376
  */
1350
- export function parseProposalSections(raw: Json): SkeletonSections {
1377
+ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] } {
1351
1378
  const c = new Check();
1352
- const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes']);
1379
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints']);
1353
1380
  if (top !== null) {
1354
1381
  const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1382
+ if ('limbs' in top) {
1383
+ const taken = isPlainObject(top.limbs)
1384
+ ? Object.values(top.limbs).flatMap((l) => (isPlainObject(l) && Array.isArray(l.segments) ? l.segments.flatMap((sg) => (isPlainObject(sg) && typeof sg.from === 'string' ? [sg.from] : [])) : []))
1385
+ : [];
1386
+ checkLimbs(c, top.limbs, { parts: [...new Set(taken)], patches: [], cuts: [] }, names);
1387
+ }
1355
1388
  if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains);
1356
1389
  if ('regions' in top) checkRegions(c, top.regions, names.bones);
1357
1390
  if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1391
+ if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
1358
1392
  if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
1359
1393
  }
1360
1394
  refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1361
- return raw as SkeletonSections;
1395
+ return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] };
1362
1396
  }
1363
1397
 
1364
1398
  /**
@@ -1548,7 +1582,9 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
1548
1582
  /**
1549
1583
  * `assemble.cuts` (issue #170): each entry `{from, into, polygon, draw, overlap}`
1550
1584
  * and optionally `under` (issue #193, a non-empty path string; the assemble
1551
- * stage reads the file), refused field by field. `from` must be a plan part — a cut of a patch or of
1585
+ * stage reads the file) and `under_fill` (issue #360, the one value
1586
+ * `"colour"`; whether the cut has an under to fill with is the assemble
1587
+ * stage's to judge, since a hidden-art layer can be one), refused field by field. `from` must be a plan part — a cut of a patch or of
1552
1588
  * another cut's piece is refused, so every piece's provenance is one layer and
1553
1589
  * one polygon. `into` is a new part name: not a plan part, not a patch, not
1554
1590
  * another cut's. Returns the names the cuts make, in order.
@@ -1560,11 +1596,14 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
1560
1596
  const seen = new Map<string, number>();
1561
1597
  (v as Json[]).forEach((entry, i) => {
1562
1598
  const at = `${p}[${i}]`;
1563
- const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under']);
1599
+ const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under', 'under_fill']);
1564
1600
  if (e === null) return;
1565
1601
  if ('under' in e && !(typeof e.under === 'string' && e.under !== '')) {
1566
1602
  c.fail('CONFIG_FIELD_TYPE', `${at}.under`, `is ${show(e.under)}; a non-empty path to a PNG is required, relative to the config's directory — the image of what lies under the piece, in rig pixels over the polygon's bounding box`);
1567
1603
  }
1604
+ if ('under_fill' in e && e.under_fill !== 'colour') {
1605
+ c.fail('CONFIG_FIELD_TYPE', `${at}.under_fill`, `is ${show(e.under_fill)}; "colour" is required — the base takes the under's colour and keeps the alpha the cut removed, so its silhouette does not grow; leave under_fill out for the under's colour and alpha`);
1606
+ }
1568
1607
  if ('overlap' in e && !(typeof e.overlap === 'number' && Number.isInteger(e.overlap) && e.overlap >= 0)) {
1569
1608
  c.fail('CONFIG_FIELD_TYPE', `${at}.overlap`, `is ${show(e.overlap)}; a non-negative integer of rig pixels is required — the band inside the cut's edge the part keeps under the piece, 0 for none`);
1570
1609
  }
@@ -2165,7 +2204,12 @@ interface LimbNames {
2165
2204
  * the joint, its origin); the first segment has no joint, every other names
2166
2205
  * one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
2167
2206
  * degrees `[min, max]` with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`: the range
2168
- * is measured from rest, so rest is in it); `cap` a non-empty path. A limb
2207
+ * is measured from rest, so rest is in it); `cap` a non-empty path; `edge`
2208
+ * `"feather"` and `cap_shape` `"disc"` (issue #360), each the one value its
2209
+ * field takes — absent is the hard cut and the layer's half-disc
2210
+ * (`CONFIG_FIELD_TYPE` on any other); `cut_band` a whole number of px, 1 or
2211
+ * more (`CONFIG_FIELD_TYPE`), on a segment that takes the segment above's
2212
+ * part (`CONFIG_KEY_KNOWN`: between two layers nothing is cut). A limb
2169
2213
  * has two segments or more. With `names` null (no `bones`), bones are not
2170
2214
  * resolved. Returns the limb parts and the parts the limbs take.
2171
2215
  */
@@ -2190,13 +2234,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2190
2234
  const bones: Array<string | null> = [];
2191
2235
  segs.forEach((entry, i) => {
2192
2236
  const sat = `${at}.segments[${i}]`;
2193
- const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
2237
+ const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape', 'cut_band']);
2194
2238
  froms.push(null);
2195
2239
  bones.push(null);
2196
2240
  if (e === null) return;
2197
2241
  // The upper segment sits below no joint; every other sits below one, which it names.
2198
2242
  if (i === 0) {
2199
- for (const k of ['joint', 'range', 'cap'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
2243
+ for (const k of ['joint', 'range', 'cap', 'edge', 'cap_shape', 'cut_band'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
2200
2244
  } else if (!('joint' in e)) c.fail('CONFIG_FIELD_PRESENT', `${sat}.joint`, 'is absent and required; a segment after the first sits below a joint, at its bone\'s origin, and names it');
2201
2245
  if ('part' in e && c.string(`${sat}.part`, e.part)) {
2202
2246
  const name = e.part;
@@ -2234,6 +2278,21 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2234
2278
  if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
2235
2279
  c.fail('CONFIG_FIELD_TYPE', `${sat}.cap`, `is ${show(e.cap)}; a non-empty path to a PNG is required, relative to the config's directory — the cap's image over the box assemble's limb line prints`);
2236
2280
  }
2281
+ // Issue #360: the cut's geometry at the joint, each field one value; absent is the hard cut and the layer's half-disc.
2282
+ if ('edge' in e && e.edge !== 'feather') {
2283
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.edge`, `is ${show(e.edge)}; "feather" is required — the upper piece fades out across the cap instead of stopping at the joint line; leave edge out for the hard cut`);
2284
+ }
2285
+ if ('cap_shape' in e && e.cap_shape !== 'disc') {
2286
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.cap_shape`, `is ${show(e.cap_shape)}; "disc" is required — the cap a disc of the limb's half-width about the joint, filled from the art, the upper segment's included; leave cap_shape out for the layer's half-disc of half the limb's width`);
2287
+ }
2288
+ // Issue #360 (item 14): the band the cut is placed in, whole px, on a joint whose two segments take one part.
2289
+ if ('cut_band' in e && i > 0) {
2290
+ if (!(typeof e.cut_band === 'number' && Number.isInteger(e.cut_band) && e.cut_band >= 1)) {
2291
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.cut_band`, `is ${show(e.cut_band)}; a whole number of rig px, 1 or more, is required — the half-width of the band about the joint line the cut is placed in (the line there that crosses the fewest stroke pixels); leave cut_band out to cut at the joint line`);
2292
+ } else if (typeof e.from === 'string' && typeof froms[i - 1] === 'string' && froms[i - 1] !== e.from) {
2293
+ c.fail('CONFIG_KEY_KNOWN', `${sat}.cut_band`, `is not a field on a segment whose from ("${e.from}") is not the segment above's ("${String(froms[i - 1])}"); no line is cut between two layers — each segment takes its own part whole — so there is no cut to place`);
2294
+ }
2295
+ }
2237
2296
  });
2238
2297
  froms.forEach((f, i) => {
2239
2298
  if (f === null || i === 0 || froms[i - 1] === f) return;