@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/README.md +7 -2
- package/docs/AUTHORING.md +57 -6
- package/docs/MIGRATION.md +19 -0
- package/docs/PAINTING.md +4 -3
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/assemble.ts +181 -26
- package/src/build.ts +19 -1
- package/src/check.ts +387 -5
- package/src/config.ts +75 -16
- package/src/limbs.ts +248 -5
- package/src/parts.ts +96 -6
- package/src/rig.ts +21 -0
- package/src/structure.ts +2 -1
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 (
|
|
647
|
-
* by rig-c), and `cap` an
|
|
648
|
-
*
|
|
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)
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
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)
|
|
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
|
|
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;
|