@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/assemble.ts
CHANGED
|
@@ -93,7 +93,9 @@
|
|
|
93
93
|
* - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
|
|
94
94
|
* this port's: an authored image the base takes where the cut removed its
|
|
95
95
|
* pixels, so a piece that swings away leaves no hole. The stage reads that
|
|
96
|
-
* image only there and never invents a pixel.
|
|
96
|
+
* image only there and never invents a pixel. With `under_fill: "colour"`
|
|
97
|
+
* (issue #360) the base takes the image's colour only and keeps the alpha
|
|
98
|
+
* the cut removed, so the part's silhouette is the uncut layer's.
|
|
97
99
|
*
|
|
98
100
|
* Pure: no clock, no randomness, no file access. The CLI reads and writes.
|
|
99
101
|
*/
|
|
@@ -102,7 +104,7 @@ import { type Problem, refuseIfAny } from './errors.ts';
|
|
|
102
104
|
import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
|
|
103
105
|
import { padToSquare, squarePad } from './inputs.ts';
|
|
104
106
|
import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
|
|
105
|
-
import { type BonePlace, bonePlaces, capBox, inDisc, type JointLine,
|
|
107
|
+
import { type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
|
|
106
108
|
import {
|
|
107
109
|
alphaComposite,
|
|
108
110
|
connectedComponents,
|
|
@@ -1726,22 +1728,28 @@ export interface CutCount {
|
|
|
1726
1728
|
under?: number;
|
|
1727
1729
|
/** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
|
|
1728
1730
|
underEmpty?: number;
|
|
1731
|
+
/** With `under_fill: "colour"` only (issue #360): the `under` pixels took the image's colour at the alpha the cut removed; absent without it. */
|
|
1732
|
+
underFill?: 'colour';
|
|
1729
1733
|
}
|
|
1730
1734
|
|
|
1731
1735
|
/**
|
|
1732
1736
|
* The cut lines the assemble stage prints, one per plan part with cuts, in
|
|
1733
1737
|
* the `pixels:` line's shape: ` cut: "<from>" opaque <before> = "<from>"
|
|
1734
1738
|
* <kept> + "<into>" <taken> …`. Empty without cuts, so a config with none
|
|
1735
|
-
* prints what it printed before.
|
|
1739
|
+
* prints what it printed before. With a cut of `under_fill: "colour"`
|
|
1740
|
+
* (issue #360) the filled count says how many of its pixels took the colour
|
|
1741
|
+
* only; without one the line is issue #193's.
|
|
1736
1742
|
*/
|
|
1737
1743
|
export function cutLines(cuts: readonly CutCount[]): string[] {
|
|
1738
1744
|
const byFrom = new Map<string, CutCount[]>();
|
|
1739
1745
|
for (const c of cuts) byFrom.set(c.from, [...(byFrom.get(c.from) ?? []), c]);
|
|
1740
|
-
return [...byFrom.entries()].map(
|
|
1741
|
-
(
|
|
1746
|
+
return [...byFrom.entries()].map(([from, cs]) => {
|
|
1747
|
+
const colour = cs.some((c) => c.underFill === 'colour') ? ` (${cs.reduce((a, c) => a + (c.underFill === 'colour' ? (c.under ?? 0) : 0), 0)} colour only, at the alpha the cut removed)` : '';
|
|
1748
|
+
return (
|
|
1742
1749
|
` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both` +
|
|
1743
|
-
(cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : '')
|
|
1744
|
-
|
|
1750
|
+
(cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled${colour}, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : '')
|
|
1751
|
+
);
|
|
1752
|
+
});
|
|
1745
1753
|
}
|
|
1746
1754
|
|
|
1747
1755
|
/**
|
|
@@ -1771,8 +1779,14 @@ export function underBox(polygon: ReadonlyArray<readonly [number, number]>): { x
|
|
|
1771
1779
|
* is the only place the image is read: its pixels over the band, over pixels
|
|
1772
1780
|
* the layer never had, and over the box outside the polygon change nothing.
|
|
1773
1781
|
* `filled + empty` is the removed count.
|
|
1782
|
+
*
|
|
1783
|
+
* With `fill` `"colour"` (issue #360, `cuts[].under_fill`) the same pixels are
|
|
1784
|
+
* filled and left empty, and a filled pixel takes the image's colour at the
|
|
1785
|
+
* alpha the cut removed there instead of the image's alpha: the part's alpha
|
|
1786
|
+
* is the uncut layer's wherever the image fills, so its silhouette does not
|
|
1787
|
+
* grow over a soft edge an opaque image covers.
|
|
1774
1788
|
*/
|
|
1775
|
-
export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }): { filled: number; empty: number } {
|
|
1789
|
+
export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }, fill?: 'colour'): { filled: number; empty: number } {
|
|
1776
1790
|
const W = base.width;
|
|
1777
1791
|
let filled = 0;
|
|
1778
1792
|
let empty = 0;
|
|
@@ -1784,7 +1798,10 @@ export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster
|
|
|
1784
1798
|
empty++;
|
|
1785
1799
|
continue;
|
|
1786
1800
|
}
|
|
1787
|
-
|
|
1801
|
+
if (fill === 'colour') {
|
|
1802
|
+
base.data.set(under.data.subarray(u, u + 3), p * 4);
|
|
1803
|
+
base.data[p * 4 + 3] = a;
|
|
1804
|
+
} else base.data.set(under.data.subarray(u, u + 4), p * 4);
|
|
1788
1805
|
filled++;
|
|
1789
1806
|
}
|
|
1790
1807
|
return { filled, empty };
|
|
@@ -1839,7 +1856,23 @@ export interface LimbCount {
|
|
|
1839
1856
|
before: number;
|
|
1840
1857
|
shares: Array<{ part: string; px: number }>;
|
|
1841
1858
|
/** The joints whose cap this part's segments carry, in `config.limbs` order. */
|
|
1842
|
-
joints: Array<{ limb: string; joint: string; part: string; cap: CapRecord }>;
|
|
1859
|
+
joints: Array<{ limb: string; joint: string; part: string; upper: string; cap: CapRecord }>;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* What `cut_band` did at a joint (issue #360, item 14), for the `limb cap:`
|
|
1864
|
+
* line: the line chosen — its offset from the joint line and a point it
|
|
1865
|
+
* passes through — the stroke pixels it crosses, those the joint line
|
|
1866
|
+
* crosses, and the split that made a pixel a stroke.
|
|
1867
|
+
*/
|
|
1868
|
+
function cutPhrase(k: CapRecord, upper: string, lower: string): string {
|
|
1869
|
+
const off = k.cut_offset ?? 0;
|
|
1870
|
+
const where = off === 0 ? 'on the joint line' : `${Math.abs(off)} px ${off < 0 ? `before the joint line (toward "${upper}")` : `past the joint line (toward "${lower}")`}`;
|
|
1871
|
+
const stroke =
|
|
1872
|
+
k.stroke_luma_max === undefined
|
|
1873
|
+
? `no stroke: the ${k.band_px ?? 0} px of the limb's art the band's lines cross hold one luma, so nothing splits from it`
|
|
1874
|
+
: `stroke: luma ${k.stroke_luma_max} or below, the Otsu split of the ${k.band_px ?? 0} px of the limb's art the band's lines cross`;
|
|
1875
|
+
return `cut_band ${k.cut_band ?? 0}: the cut ${where}, through [${(k.cut_at ?? k.at).join(', ')}], crosses ${k.cut_crossed ?? 0} stroke px; the joint line crosses ${k.joint_crossed ?? 0} (${stroke})`;
|
|
1843
1876
|
}
|
|
1844
1877
|
|
|
1845
1878
|
/**
|
|
@@ -1853,13 +1886,23 @@ export function limbLines(counts: readonly LimbCount[]): string[] {
|
|
|
1853
1886
|
out.push(` limb cut: "${c.of}" opaque ${c.before} = ${c.shares.map((q) => `"${q.part}" ${q.px}`).join(' + ')}`);
|
|
1854
1887
|
for (const j of c.joints) {
|
|
1855
1888
|
const k = j.cap;
|
|
1856
|
-
const
|
|
1889
|
+
const base = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from ${k.image_layer === undefined ? 'the cap image' : `the hidden-art layer ${k.image_layer}`}, ${k.layer_px} px from the layer`;
|
|
1890
|
+
// Issue #360: what cap_shape "disc" and edge "feather" did, said only when declared, so a limb without them prints what it did.
|
|
1891
|
+
const from = k.shape === undefined ? base : `${base}, ${k.upper_px ?? 0} px from "${j.upper}"`;
|
|
1892
|
+
const radius =
|
|
1893
|
+
k.shape === undefined
|
|
1894
|
+
? `a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line)`
|
|
1895
|
+
: `a disc of radius ${k.radius} px (cap_shape "disc": the joint's distance to the nearer side of the limb, ${k.width} px across the joint line)`;
|
|
1896
|
+
const shaped = [
|
|
1897
|
+
...(k.edge === undefined ? [] : [`edge "feather": ${k.feathered_px ?? 0} px of "${j.upper}" faded across the ${k.feather_radius ?? 0} px about the joint that "${j.part}" covers at every turn`]),
|
|
1898
|
+
...(k.cut_band === undefined ? [] : [cutPhrase(k, j.upper, j.part)]),
|
|
1899
|
+
];
|
|
1857
1900
|
const miss =
|
|
1858
1901
|
k.missing_px === 0
|
|
1859
1902
|
? 'none missing'
|
|
1860
1903
|
: `${k.missing_px} px missing — the layer has no pixel there (hidden in the painting, or past the limb's outline), and the tool invents none: paint them into a ${k.box[2]}x${k.box[3]} image whose pixel (i, j) is rig pixel (${k.box[0]} + i, ${k.box[1]} + j), and name it in the segment's "cap"`;
|
|
1861
1904
|
out.push(
|
|
1862
|
-
` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}":
|
|
1905
|
+
` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}": ${radius}, box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}${shaped.map((q) => `; ${q}`).join('')}`,
|
|
1863
1906
|
);
|
|
1864
1907
|
}
|
|
1865
1908
|
}
|
|
@@ -1893,6 +1936,11 @@ export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[]
|
|
|
1893
1936
|
return out;
|
|
1894
1937
|
}
|
|
1895
1938
|
|
|
1939
|
+
/** The point a joint line moved `offset` px along its axis passes through, to 3 places (issue #360, `cut_band`). */
|
|
1940
|
+
function shiftedAt(line: JointLine, offset: number): [number, number] {
|
|
1941
|
+
return [pyRound(line.at[0] + offset * line.axis[0], 3), pyRound(line.at[1] + offset * line.axis[1], 3)];
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1896
1944
|
/**
|
|
1897
1945
|
* Cut every declared limb (module comment of `src/limbs.ts`): each part a limb
|
|
1898
1946
|
* takes is split among the limbs that take it (nearest polyline), then each
|
|
@@ -1926,7 +1974,7 @@ function cutLimbs(
|
|
|
1926
1974
|
const parts: LimbPart[] = [];
|
|
1927
1975
|
for (const l of names) {
|
|
1928
1976
|
limbs[l].segments.forEach((sg, k) =>
|
|
1929
|
-
parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string }) } }),
|
|
1977
|
+
parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
|
|
1930
1978
|
);
|
|
1931
1979
|
}
|
|
1932
1980
|
const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
|
|
@@ -1954,6 +2002,9 @@ function cutLimbs(
|
|
|
1954
2002
|
);
|
|
1955
2003
|
}
|
|
1956
2004
|
refuseIfAny(problems);
|
|
2005
|
+
// The line each segment's pixels are split by: the joint line, or where cut_band placed the cut (issue #360).
|
|
2006
|
+
const cutLines = new Map([...lines].map(([l, ls]) => [l, [...ls]]));
|
|
2007
|
+
const placements = new Map<string, Extract<CutPlacement, { ok: true }>>();
|
|
1957
2008
|
// Each part a limb takes, in the order the limbs first take them.
|
|
1958
2009
|
const sources = [...new Set(parts.map((q) => q.of))];
|
|
1959
2010
|
const pieces: Array<Built | null> = parts.map(() => null);
|
|
@@ -1977,14 +2028,42 @@ function cutLimbs(
|
|
|
1977
2028
|
for (let x = 0; x < W; x++) {
|
|
1978
2029
|
const p = y * W + x;
|
|
1979
2030
|
if (b.r.data[p * 4 + 3] === 0) continue;
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2031
|
+
own[p] = names.indexOf(on[on.length === 1 ? 0 : nearestLimb(lns, x, y)]);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
// Issue #360 (item 14): each joint of this part that declares cut_band has its cut placed, on the limb's own art.
|
|
2035
|
+
for (const l of on) {
|
|
2036
|
+
const li = names.indexOf(l);
|
|
2037
|
+
limbs[l].segments.forEach((sg, k) => {
|
|
2038
|
+
if (k === 0 || sg.cut_band === undefined || sg.from !== src || limbs[l].segments[k - 1].from !== src) return;
|
|
2039
|
+
const line = (lines.get(l) as Array<JointLine | null>)[k] as JointLine;
|
|
2040
|
+
const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < W && y < H && own[y * W + x] === li && b.r.data[(y * W + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE;
|
|
2041
|
+
const luma = (x: number, y: number): number => luma601(b.r.data[(y * W + x) * 4], b.r.data[(y * W + x) * 4 + 1], b.r.data[(y * W + x) * 4 + 2]);
|
|
2042
|
+
// The joint line on no art is ASSEMBLE_LIMB_JOINT_ON_ART at the caps; nothing is placed about it.
|
|
2043
|
+
if (limbRun(line, art, [0, 0, W - 1, H - 1]) === null) return;
|
|
2044
|
+
const placed = placeCut(line, sg.cut_band, art, luma, [0, 0, W - 1, H - 1]);
|
|
2045
|
+
if (!placed.ok) {
|
|
2046
|
+
problems.push({
|
|
2047
|
+
code: 'ASSEMBLE_LIMB_CUT_BAND',
|
|
2048
|
+
object: `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}").cut_band`,
|
|
2049
|
+
detail: `is ${sg.cut_band}, and the line ${Math.abs(placed.offset)} px ${placed.offset < 0 ? 'before' : 'past'} the joint line, through [${shiftedAt(line, placed.offset).join(', ')}], crosses none of the limb's art within 1 px of that point — the band reaches past the limb there, and a cut placed off the art is no cut; a band whose every line crosses the limb is required — ${Math.abs(placed.offset) > 1 ? `cut_band ${Math.abs(placed.offset) - 1} or less` : 'leave cut_band out'}`,
|
|
2050
|
+
});
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
(cutLines.get(l) as Array<JointLine | null>)[k] = placed.line;
|
|
2054
|
+
placements.set(`${l}/${k}`, placed);
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
for (let y = 0; y < H; y++) {
|
|
2058
|
+
for (let x = 0; x < W; x++) {
|
|
2059
|
+
const p = y * W + x;
|
|
2060
|
+
if (b.r.data[p * 4 + 3] === 0) continue;
|
|
2061
|
+
const l = names[own[p]];
|
|
1983
2062
|
const segs = limbs[l].segments;
|
|
1984
2063
|
const first = segs.findIndex((sg) => sg.from === src);
|
|
1985
2064
|
let last = first;
|
|
1986
2065
|
while (last + 1 < segs.length && segs[last + 1].from === src) last++;
|
|
1987
|
-
const k = segmentOf(
|
|
2066
|
+
const k = segmentOf(cutLines.get(l) as Array<JointLine | null>, first, last, x, y);
|
|
1988
2067
|
const at = parts.findIndex((q) => q.limb === l && q.segment === k);
|
|
1989
2068
|
rasters[at].data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
1990
2069
|
if (b.region === null || b.region.data[p] === 1) regions[at].data[p] = 1;
|
|
@@ -2028,8 +2107,8 @@ function cutLimbs(
|
|
|
2028
2107
|
return false;
|
|
2029
2108
|
};
|
|
2030
2109
|
const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
|
|
2031
|
-
const
|
|
2032
|
-
if (
|
|
2110
|
+
const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
|
|
2111
|
+
if (run === null) {
|
|
2033
2112
|
problems.push({
|
|
2034
2113
|
code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
|
|
2035
2114
|
object,
|
|
@@ -2038,10 +2117,23 @@ function cutLimbs(
|
|
|
2038
2117
|
return;
|
|
2039
2118
|
}
|
|
2040
2119
|
// The width to 3 places (src/round.ts): a measured length, and a radius the line and the record print as read.
|
|
2041
|
-
const w3 = pyRound(
|
|
2042
|
-
|
|
2120
|
+
const w3 = pyRound(run[1] - run[0] + 1, 3);
|
|
2121
|
+
// Issue #360: under cap_shape "disc" the radius is the half-width about the joint (src/limbs.ts), to 3 places as well.
|
|
2122
|
+
const disc = sg.cap_shape === 'disc';
|
|
2123
|
+
const half = halfWidthAbout(run);
|
|
2124
|
+
if (disc && half <= 0) {
|
|
2125
|
+
problems.push({
|
|
2126
|
+
code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
|
|
2127
|
+
object,
|
|
2128
|
+
detail: `the joint, "${sg.bone}"'s origin at [${line.at.join(', ')}], lies on the edge of the limb's art along its joint line — the run of art reaches ${pyRound(run[1] + 0.5, 3)} px from it one way and ${pyRound(0.5 - run[0], 3)} px the other — so cap_shape "disc" has no half-width about it; a bone origin inside the limb's art is required — move the bone onto the joint`,
|
|
2129
|
+
});
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
const radius = disc ? pyRound(half, 3) : w3 / 2;
|
|
2043
2133
|
const box = capBox(line.at, radius);
|
|
2044
2134
|
const at = parts.findIndex((q) => q.limb === l && q.segment === k);
|
|
2135
|
+
const upAt = parts.findIndex((q) => q.limb === l && q.segment === k - 1);
|
|
2136
|
+
const upper = rasters[upAt];
|
|
2045
2137
|
// Issue #359: the lower segment's part's hidden-art layer is its cap when the segment names no cap image; with one named, a refusal.
|
|
2046
2138
|
const hidden = hiddenOf(sg.from);
|
|
2047
2139
|
if (hidden !== null && sg.cap !== undefined) {
|
|
@@ -2063,13 +2155,17 @@ function cutLimbs(
|
|
|
2063
2155
|
}
|
|
2064
2156
|
const lower = rasters[at];
|
|
2065
2157
|
const lowerSrc = builtOf(sg.from) as Built;
|
|
2158
|
+
// Issue #360 (cut_band): the cap's pixels are the disc's on the upper side of the cut, wherever it was placed.
|
|
2159
|
+
const cut = (cutLines.get(l) as Array<JointLine | null>)[k] as JointLine;
|
|
2160
|
+
const placed = placements.get(`${l}/${k}`);
|
|
2066
2161
|
const ownLower = owners.get(sg.from) as Int32Array;
|
|
2067
2162
|
let layerPx = 0;
|
|
2068
2163
|
let imagePx = 0;
|
|
2164
|
+
let upperPx = 0;
|
|
2069
2165
|
let missing = 0;
|
|
2070
2166
|
for (let y = Math.max(0, box[1]); y < Math.min(H, box[1] + box[3]); y++) {
|
|
2071
2167
|
for (let x = Math.max(0, box[0]); x < Math.min(W, box[0] + box[2]); x++) {
|
|
2072
|
-
if (!inDisc(line.at, radius, x, y) || pastJoint(
|
|
2168
|
+
if (!inDisc(line.at, radius, x, y) || pastJoint(cut, x, y)) continue;
|
|
2073
2169
|
const p = y * W + x;
|
|
2074
2170
|
const u = img === null ? -1 : ((y - box[1]) * img.width + (x - box[0])) * 4;
|
|
2075
2171
|
if (img !== null && img.data[u + 3] > 0) {
|
|
@@ -2078,6 +2174,10 @@ function cutLimbs(
|
|
|
2078
2174
|
} else if (ownLower[p] === li && lowerSrc.r.data[p * 4 + 3] > 0) {
|
|
2079
2175
|
lower.data.set(lowerSrc.r.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
2080
2176
|
layerPx++;
|
|
2177
|
+
} else if (disc && upper.data[p * 4 + 3] > 0) {
|
|
2178
|
+
// Issue #360: the disc is filled from the art — the upper segment's, where the lower's layer has none.
|
|
2179
|
+
lower.data.set(upper.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
2180
|
+
upperPx++;
|
|
2081
2181
|
} else missing++;
|
|
2082
2182
|
}
|
|
2083
2183
|
}
|
|
@@ -2085,11 +2185,54 @@ function cutLimbs(
|
|
|
2085
2185
|
problems.push({ code: 'ASSEMBLE_LIMB_CAP_EMPTY', object: `${object}.cap`, detail: `${String(sg.cap)} is transparent over every one of the cap's pixels; an image that gives at least one is required — to keep the layer's cap, drop cap` });
|
|
2086
2186
|
return;
|
|
2087
2187
|
}
|
|
2088
|
-
|
|
2188
|
+
// Issue #360: under edge "feather", the upper piece fades out across the cap, over the lower's opaque pixels only.
|
|
2189
|
+
let featheredPx = 0;
|
|
2190
|
+
let band = 0;
|
|
2191
|
+
if (sg.edge === 'feather') {
|
|
2192
|
+
band = pyRound(Math.max(0, coveredRadius(line.at, radius, (x, y) => x >= 0 && y >= 0 && x < W && y < H && lower.data[(y * W + x) * 4 + 3] === 255)), 3);
|
|
2193
|
+
const fb = capBox(line.at, band);
|
|
2194
|
+
for (let y = Math.max(0, fb[1]); y < Math.min(H, fb[1] + fb[3]); y++) {
|
|
2195
|
+
for (let x = Math.max(0, fb[0]); x < Math.min(W, fb[0] + fb[2]); x++) {
|
|
2196
|
+
if (band === 0 || !inDisc(line.at, band, x, y) || pastJoint(cut, x, y)) continue;
|
|
2197
|
+
const p = y * W + x;
|
|
2198
|
+
const ua = upper.data[p * 4 + 3];
|
|
2199
|
+
if (ua === 0) continue;
|
|
2200
|
+
const o = jointOffsets(line, x, y);
|
|
2201
|
+
const na = Math.floor(ua * featherWeight(band, o.along, o.across, placed?.offset ?? 0) + 0.5);
|
|
2202
|
+
if (na === ua) continue;
|
|
2203
|
+
if (na === 0) upper.data.fill(0, p * 4, p * 4 + 4);
|
|
2204
|
+
else upper.data[p * 4 + 3] = na;
|
|
2205
|
+
featheredPx++;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
const cap: CapRecord = {
|
|
2210
|
+
at: [line.at[0], line.at[1]],
|
|
2211
|
+
width: w3,
|
|
2212
|
+
radius,
|
|
2213
|
+
box,
|
|
2214
|
+
layer_px: layerPx,
|
|
2215
|
+
...(img === null ? {} : { image_px: imagePx }),
|
|
2216
|
+
...(hidden === null ? {} : { image_layer: hidden.layer }),
|
|
2217
|
+
missing_px: missing,
|
|
2218
|
+
...(disc ? { shape: 'disc' as const, upper_px: upperPx } : {}),
|
|
2219
|
+
...(sg.edge === 'feather' ? { edge: 'feather' as const, feather_radius: band, feathered_px: featheredPx } : {}),
|
|
2220
|
+
...(placed === undefined
|
|
2221
|
+
? {}
|
|
2222
|
+
: {
|
|
2223
|
+
cut_band: sg.cut_band as number,
|
|
2224
|
+
cut_offset: placed.offset,
|
|
2225
|
+
cut_at: shiftedAt(line, placed.offset),
|
|
2226
|
+
cut_crossed: placed.crossed,
|
|
2227
|
+
joint_crossed: placed.jointCrossed,
|
|
2228
|
+
band_px: placed.bandPx,
|
|
2229
|
+
...(placed.strokeLumaMax === null ? {} : { stroke_luma_max: placed.strokeLumaMax }),
|
|
2230
|
+
}),
|
|
2231
|
+
};
|
|
2089
2232
|
if (hidden !== null) readHidden(hidden.layer, `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`);
|
|
2090
2233
|
parts[at].record.cap = cap;
|
|
2091
2234
|
const c = counts.find((q) => q.of === sg.from);
|
|
2092
|
-
if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, cap });
|
|
2235
|
+
if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, upper: segs[k - 1].part, cap });
|
|
2093
2236
|
});
|
|
2094
2237
|
}
|
|
2095
2238
|
refuseIfAny(problems);
|
|
@@ -2377,7 +2520,17 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
2377
2520
|
cuts,
|
|
2378
2521
|
);
|
|
2379
2522
|
cuts.forEach((q, i) => {
|
|
2380
|
-
if (q.under === undefined)
|
|
2523
|
+
if (q.under === undefined) {
|
|
2524
|
+
// Issue #360: under_fill says how an under fills; a cut with no under (no file, no hidden-art layer) has nothing it could say it of.
|
|
2525
|
+
if (q.under_fill !== undefined) {
|
|
2526
|
+
patchProblems.push({
|
|
2527
|
+
code: 'ASSEMBLE_UNDER_FILL_SOURCE',
|
|
2528
|
+
object: `config.assemble.cuts[${i}].under_fill (part "${q.into}")`,
|
|
2529
|
+
detail: `is "${q.under_fill}", and the cut has no under: it names no under file and "${q.from}" has no hidden-art layer; under_fill says how an under fills the pixels the cut removed, so one is required — name under, or drop under_fill`,
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2381
2534
|
const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
|
|
2382
2535
|
const u = unders[i];
|
|
2383
2536
|
if (u === null) {
|
|
@@ -2563,7 +2716,8 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
2563
2716
|
const u = unders[i];
|
|
2564
2717
|
if (u === null) return undefined;
|
|
2565
2718
|
// Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
|
|
2566
|
-
|
|
2719
|
+
// With under_fill "colour" (issue #360) a filled pixel keeps the alpha the cut removed and takes the image's colour only.
|
|
2720
|
+
const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon), cuts[i].under_fill);
|
|
2567
2721
|
// A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
|
|
2568
2722
|
// under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
|
|
2569
2723
|
if (f.filled === 0 && input.cuts[i].under !== undefined) {
|
|
@@ -2581,6 +2735,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
2581
2735
|
if (f !== undefined) {
|
|
2582
2736
|
c.under = f.filled;
|
|
2583
2737
|
c.underEmpty = f.empty;
|
|
2738
|
+
if (cuts[i].under_fill === 'colour') c.underFill = 'colour';
|
|
2584
2739
|
}
|
|
2585
2740
|
cutCounts.push(c);
|
|
2586
2741
|
});
|
package/src/build.ts
CHANGED
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
splitSchedule,
|
|
71
71
|
untestedIntervals,
|
|
72
72
|
} from './autoreplay.ts';
|
|
73
|
-
import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
|
|
73
|
+
import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
|
|
74
74
|
import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
|
|
75
75
|
import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
76
76
|
import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
|
|
@@ -1135,6 +1135,22 @@ export function limbCheckLines(line: LimbLine): string[] {
|
|
|
1135
1135
|
];
|
|
1136
1136
|
}
|
|
1137
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* The seam over the range (issue #360) as the console prints it: the status, what was posed and the bar, then each
|
|
1140
|
+
* joint at each angle on a line of its own — its status and the seam figures there — or, not read, why.
|
|
1141
|
+
*/
|
|
1142
|
+
export function sweepCheckLines(line: SweepLine): string[] {
|
|
1143
|
+
const s = line.frame_scale;
|
|
1144
|
+
return [
|
|
1145
|
+
`${SWEEP_LINE}: ${line.status} — ${line.joints.length} joint(s) bent to their declared range's ends and middle${line.physics_left_out === 0 ? '' : `, ${line.physics_left_out} physics constraint(s) left out`}; at each, the pose against the same pose with the joint's own art drawn under the limb, on the rest-pose seam's bar (mean |d| <= ${line.bar.mean.toFixed(1)}, <= ${line.bar.rig_px_over_40} rig px over ${line.bar.level})${s === null ? '' : ` at frame scale ${s}`}`,
|
|
1146
|
+
...line.joints.flatMap((j) => {
|
|
1147
|
+
const head = `joint "${j.joint}" (${j.limb}, "${j.part}" on "${j.bone}", range [${j.range.join(', ')}]${j.shape === 'disc' ? `, cap_shape "disc": no pixel over ${line.bar.level} in the disc` : ''})`;
|
|
1148
|
+
if (j.unread !== undefined) return [`${head}: FAIL — not read: ${j.unread}`];
|
|
1149
|
+
return j.poses.map((p) => `${head} at ${p.degrees}°: ${p.status} — mean |d| ${p.mean}/255, ${p.px_over_40} frame px over ${line.bar.level} (${p.px_over_80} over ${SEAM_PX_LEVEL_HIGH}), ${p.rig_px_over_40.toFixed(2)} rig px`);
|
|
1150
|
+
}),
|
|
1151
|
+
];
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1138
1154
|
/** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
|
|
1139
1155
|
export function requirementText(name: string, line: RequirementLine): string {
|
|
1140
1156
|
const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
|
|
@@ -1181,6 +1197,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
|
|
|
1181
1197
|
if (stress !== undefined) log(` ${judgementLine(STRESS_LINE, stress)}`);
|
|
1182
1198
|
const limbs = fig[LIMB_LINE];
|
|
1183
1199
|
if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
|
|
1200
|
+
const sweep = fig[SWEEP_LINE];
|
|
1201
|
+
if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
|
|
1184
1202
|
for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
|
|
1185
1203
|
const vsSource = fig[SOURCE_LINE];
|
|
1186
1204
|
if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
|