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