@rig-c/parts 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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, jointLine, limbPolyline, limbWidth, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
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
- ([from, cs]) =>
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
- base.data.set(under.data.subarray(u, u + 4), p * 4);
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 from = 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`;
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}": a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line), box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}`,
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
- const li = on.length === 1 ? 0 : nearestLimb(lns, x, y);
1981
- const l = on[li];
1982
- own[p] = names.indexOf(l);
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(lines.get(l) as Array<JointLine | null>, first, last, x, y);
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 width = limbWidth(line, art, [0, 0, W - 1, H - 1]);
2032
- if (width === null) {
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(width, 3);
2042
- const radius = w3 / 2;
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(line, x, y)) continue;
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
- const cap: CapRecord = { at: [line.at[0], line.at[1]], width: w3, radius, box, layer_px: layerPx, ...(img === null ? {} : { image_px: imagePx }), ...(hidden === null ? {} : { image_layer: hidden.layer }), missing_px: missing };
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) return;
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
- const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
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)}`);