spine-rigc 1.2.2 → 1.3.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/mesh.ts CHANGED
@@ -99,7 +99,7 @@ export interface MeshVertexWeight {
99
99
  }
100
100
 
101
101
  /** Which builder in this file made a mesh's geometry. */
102
- export type MeshKind = 'ring' | 'ribbon' | 'contour' | 'grid';
102
+ export type MeshKind = 'ring' | 'ribbon' | 'contour' | 'grid' | 'segments';
103
103
 
104
104
  export interface MeshGeometry {
105
105
  kind: MeshKind;
@@ -1795,3 +1795,407 @@ export function meshEdges(vertexCount: number, triangles: readonly number[], hul
1795
1795
  for (const [a, b] of interior) out.push(2 * a, 2 * b);
1796
1796
  return out;
1797
1797
  }
1798
+
1799
+ // ---------------------------------------------------------------------------
1800
+ // segments — a lattice over the part's alpha, weighted by distance to bones
1801
+ // ---------------------------------------------------------------------------
1802
+ //
1803
+ // The generator for "an arbitrary layer pulled by a chosen set of bones". Its
1804
+ // one authored input is WHICH segments may pull the part; the geometry is read
1805
+ // off the art and the weights off the distance between the two, so a pipeline
1806
+ // that used to re-implement distance weighting outside the compiler states the
1807
+ // segment list and nothing else.
1808
+ //
1809
+ // The algorithm is a port of `spine-parts`' lattice mesh and segment weights
1810
+ // (MIT, same owner). What it does, in order:
1811
+ //
1812
+ // 1. **Cells.** A square lattice of `cell`-pixel squares over the part; the
1813
+ // last column and row are clipped to the image. A cell is kept when any
1814
+ // pixel inside it reaches the alpha threshold, so the triangles cover
1815
+ // every art pixel by construction.
1816
+ // 2. **One loop.** Spine's `hull` is one closed outline, and a layer is often
1817
+ // two islands or has a hole. So the kept cells are made one simply
1818
+ // connected region: holes filled, every other island bridged to the
1819
+ // largest by a straight run of cells, every diagonal pinch filled, and the
1820
+ // three repeated until a pass changes nothing. Every pass that does not
1821
+ // settle adds at least one cell, so the loop is bounded by the lattice and
1822
+ // needs no pass limit of its own. The added cells hold no art.
1823
+ // 3. **Triangles.** Vertices numbered in the order the kept cells first touch
1824
+ // them (cells row-major; corners top-left, top-right, bottom-right,
1825
+ // bottom-left), each cell two triangles with the diagonal alternating by
1826
+ // `(i + j) % 2`, so the lattice has no preferred shear. Emitted
1827
+ // counter-clockwise in Spine world, the winding every generator here
1828
+ // writes (GR02 holds the grid to it).
1829
+ // 4. **Outline first.** The boundary is walked from the edges used by exactly
1830
+ // one triangle, in first-seen order, and every interior vertex follows in
1831
+ // index order — the arrangement `checkHullOrder` requires.
1832
+ // 5. **Weights.** Per vertex and per candidate segment, `w = 1 / (d + r)^p`
1833
+ // where `d` is the distance to the segment's nearest point; a bone two
1834
+ // segments name keeps the larger; the strongest `maxBones` are kept and
1835
+ // normalised, a share under `minWeight` is dropped, and the rest are
1836
+ // normalised again. Ties keep the order the bones first appear in.
1837
+
1838
+ /** What the lattice step did, for the report and for the refusals. */
1839
+ export interface SegmentsLatticeReport {
1840
+ /** Cells across and down. */
1841
+ cols: number;
1842
+ rows: number;
1843
+ /** Cells holding an art pixel, and cells kept after the one-loop passes. */
1844
+ artCells: number;
1845
+ keptCells: number;
1846
+ /** Passes of hole-fill, island-join and pinch-fill until one changed nothing. */
1847
+ passes: number;
1848
+ /** 4-connected islands of art cells before the joins. */
1849
+ islands: number;
1850
+ }
1851
+
1852
+ export interface SegmentsLattice {
1853
+ /** Vertex positions in part-local pixels, y down, outline first. */
1854
+ points: Array<[number, number]>;
1855
+ uvs: number[];
1856
+ /** Counter-clockwise in Spine world. */
1857
+ triangles: number[];
1858
+ hullVertices: number;
1859
+ report: SegmentsLatticeReport;
1860
+ }
1861
+
1862
+ /** `np.rint`: to the nearest integer, exact halves to even — the bridge's rounding. */
1863
+ function rintEven(x: number): number {
1864
+ const f = Math.floor(x);
1865
+ const frac = x - f;
1866
+ if (frac < 0.5) return f;
1867
+ if (frac > 0.5) return f + 1;
1868
+ return f % 2 === 0 ? f : f + 1;
1869
+ }
1870
+
1871
+ /** Fill every background cell the lattice border cannot reach through 4-connected background. */
1872
+ function fillCellHoles(cells: Uint8Array, nx: number, ny: number): Uint8Array {
1873
+ const outside = new Uint8Array(nx * ny);
1874
+ const stack: number[] = [];
1875
+ const seed = (i: number): void => {
1876
+ if (cells[i] === 0 && outside[i] === 0) {
1877
+ outside[i] = 1;
1878
+ stack.push(i);
1879
+ }
1880
+ };
1881
+ for (let x = 0; x < nx; x++) {
1882
+ seed(x);
1883
+ seed((ny - 1) * nx + x);
1884
+ }
1885
+ for (let y = 0; y < ny; y++) {
1886
+ seed(y * nx);
1887
+ seed(y * nx + nx - 1);
1888
+ }
1889
+ while (stack.length > 0) {
1890
+ const i = stack.pop()!;
1891
+ const x = i % nx;
1892
+ const y = (i - x) / nx;
1893
+ if (x > 0) seed(i - 1);
1894
+ if (x < nx - 1) seed(i + 1);
1895
+ if (y > 0) seed(i - nx);
1896
+ if (y < ny - 1) seed(i + nx);
1897
+ }
1898
+ const out = new Uint8Array(nx * ny);
1899
+ for (let i = 0; i < out.length; i++) out[i] = outside[i] === 1 ? 0 : 1;
1900
+ return out;
1901
+ }
1902
+
1903
+ /**
1904
+ * 4-connected components of the kept cells, numbered in raster order of each
1905
+ * component's first cell, each with its cells in raster order.
1906
+ */
1907
+ function cellIslands(cells: Uint8Array, nx: number, ny: number): Array<Array<[number, number]>> {
1908
+ const label = new Int32Array(nx * ny).fill(-1);
1909
+ const islands: Array<Array<[number, number]>> = [];
1910
+ for (let start = 0; start < cells.length; start++) {
1911
+ if (cells[start] === 0 || label[start] >= 0) continue;
1912
+ const id = islands.length;
1913
+ const stack = [start];
1914
+ label[start] = id;
1915
+ while (stack.length > 0) {
1916
+ const i = stack.pop()!;
1917
+ const x = i % nx;
1918
+ const y = (i - x) / nx;
1919
+ const visit = (j: number): void => {
1920
+ if (cells[j] !== 0 && label[j] < 0) {
1921
+ label[j] = id;
1922
+ stack.push(j);
1923
+ }
1924
+ };
1925
+ if (x > 0) visit(i - 1);
1926
+ if (x < nx - 1) visit(i + 1);
1927
+ if (y > 0) visit(i - nx);
1928
+ if (y < ny - 1) visit(i + nx);
1929
+ }
1930
+ islands.push([]);
1931
+ }
1932
+ for (let i = 0; i < cells.length; i++) if (label[i] >= 0) islands[label[i]].push([(i - (i % nx)) / nx, i % nx]);
1933
+ return islands;
1934
+ }
1935
+
1936
+ /**
1937
+ * The lattice over one part's alpha: cells, one loop, triangles, outline first.
1938
+ *
1939
+ * Refuses a part that keeps no cell and a cell that is not a whole number of
1940
+ * pixels of at least 1; everything else about the geometry follows from the art.
1941
+ */
1942
+ export function buildSegmentsLattice(input: { mask: AlphaMask; threshold: number; cell: number }): SegmentsLattice {
1943
+ const { mask, threshold, cell } = input;
1944
+ const { width: w, height: h } = mask;
1945
+ if (!Number.isInteger(cell) || cell < 1) {
1946
+ throw new MeshError(`"cell" is ${JSON.stringify(cell)}; it is the lattice's square, a whole number of pixels of at least 1`);
1947
+ }
1948
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > 255) {
1949
+ throw new MeshError(`the alpha threshold must be a whole number in 1..255, got ${threshold}`);
1950
+ }
1951
+ const nx = Math.ceil(w / cell);
1952
+ const ny = Math.ceil(h / cell);
1953
+ const xs = Array.from({ length: nx + 1 }, (_, i) => Math.min(i * cell, w));
1954
+ const ys = Array.from({ length: ny + 1 }, (_, j) => Math.min(j * cell, h));
1955
+ let cells: Uint8Array = new Uint8Array(nx * ny);
1956
+ let artCells = 0;
1957
+ for (let j = 0; j < ny; j++) {
1958
+ for (let i = 0; i < nx; i++) {
1959
+ let any = 0;
1960
+ for (let y = ys[j]; y < ys[j + 1] && any === 0; y++) {
1961
+ for (let x = xs[i]; x < xs[i + 1]; x++) {
1962
+ if (mask.alpha[y * w + x] >= threshold) {
1963
+ any = 1;
1964
+ break;
1965
+ }
1966
+ }
1967
+ }
1968
+ cells[j * nx + i] = any;
1969
+ artCells += any;
1970
+ }
1971
+ }
1972
+ if (artCells === 0) {
1973
+ throw new MeshError(
1974
+ `no pixel of the ${w}x${h} part reaches alpha ${threshold}, so a lattice at cell ${cell} keeps no cell — ` +
1975
+ 'there is no art to cover. Lower "alpha", or point at the image this mesh is meant to draw',
1976
+ );
1977
+ }
1978
+ const islands = cellIslands(cells, nx, ny).length;
1979
+
1980
+ // 2. one loop, until a pass changes nothing.
1981
+ let passes = 0;
1982
+ for (;;) {
1983
+ passes++;
1984
+ cells = fillCellHoles(cells, nx, ny);
1985
+ const found = cellIslands(cells, nx, ny);
1986
+ if (found.length > 1) {
1987
+ let main = 0;
1988
+ for (let k = 1; k < found.length; k++) if (found[k].length > found[main].length) main = k;
1989
+ const mainCells = found[main];
1990
+ for (let k = 0; k < found.length; k++) {
1991
+ if (k === main) continue;
1992
+ const other = found[k];
1993
+ let best = Infinity;
1994
+ let a = 0;
1995
+ let b = 0;
1996
+ for (let p = 0; p < other.length; p++) {
1997
+ for (let q = 0; q < mainCells.length; q++) {
1998
+ const dj = other[p][0] - mainCells[q][0];
1999
+ const di = other[p][1] - mainCells[q][1];
2000
+ const d = dj * dj + di * di;
2001
+ if (d < best) {
2002
+ best = d;
2003
+ a = p;
2004
+ b = q;
2005
+ }
2006
+ }
2007
+ }
2008
+ const [j0, i0] = other[a];
2009
+ const [j1, i1] = mainCells[b];
2010
+ const steps = 2 * (Math.abs(j1 - j0) + Math.abs(i1 - i0)) + 2;
2011
+ const stride = 1 / (steps - 1);
2012
+ for (let s = 0; s < steps; s++) {
2013
+ const t = s === steps - 1 ? 1 : s * stride;
2014
+ cells[rintEven(j0 + (j1 - j0) * t) * nx + i0] = 1;
2015
+ cells[j1 * nx + rintEven(i0 + (i1 - i0) * t)] = 1;
2016
+ cells[rintEven(j0 + (j1 - j0) * t) * nx + rintEven(i0 + (i1 - i0) * t)] = 1;
2017
+ }
2018
+ }
2019
+ continue;
2020
+ }
2021
+ const snap = new Uint8Array(cells);
2022
+ const at = (j: number, i: number): number => snap[j * nx + i];
2023
+ let pinch = false;
2024
+ for (let j = 0; j < ny - 1; j++) {
2025
+ for (let i = 0; i < nx - 1; i++) {
2026
+ if (at(j, i) && at(j + 1, i + 1) && !at(j, i + 1) && !at(j + 1, i)) {
2027
+ cells[j * nx + i + 1] = 1;
2028
+ pinch = true;
2029
+ } else if (at(j, i + 1) && at(j + 1, i) && !at(j, i) && !at(j + 1, i + 1)) {
2030
+ cells[j * nx + i] = 1;
2031
+ pinch = true;
2032
+ }
2033
+ }
2034
+ }
2035
+ if (!pinch) break;
2036
+ }
2037
+
2038
+ // 3. triangles, in the lattice's own winding (top-left, top-right, bottom-right).
2039
+ const vid = new Int32Array((nx + 1) * (ny + 1)).fill(-1);
2040
+ const pts: Array<[number, number]> = [];
2041
+ const tri: number[] = [];
2042
+ let keptCells = 0;
2043
+ for (let j = 0; j < ny; j++) {
2044
+ for (let i = 0; i < nx; i++) {
2045
+ if (cells[j * nx + i] === 0) continue;
2046
+ keptCells++;
2047
+ const c: number[] = [];
2048
+ for (const [jj, ii] of [
2049
+ [j, i],
2050
+ [j, i + 1],
2051
+ [j + 1, i + 1],
2052
+ [j + 1, i],
2053
+ ]) {
2054
+ const k = jj * (nx + 1) + ii;
2055
+ if (vid[k] < 0) {
2056
+ vid[k] = pts.length;
2057
+ pts.push([xs[ii], ys[jj]]);
2058
+ }
2059
+ c.push(vid[k]);
2060
+ }
2061
+ if ((i + j) % 2 === 0) tri.push(c[0], c[1], c[2], c[0], c[2], c[3]);
2062
+ else tri.push(c[0], c[1], c[3], c[1], c[2], c[3]);
2063
+ }
2064
+ }
2065
+
2066
+ // 4. the outline, walked from the boundary edges in first-seen order.
2067
+ const edgeUses = new Map<number, { a: number; b: number; n: number }>();
2068
+ const V = pts.length;
2069
+ for (let t = 0; t < tri.length; t += 3) {
2070
+ for (const [p, q] of [
2071
+ [tri[t], tri[t + 1]],
2072
+ [tri[t + 1], tri[t + 2]],
2073
+ [tri[t + 2], tri[t]],
2074
+ ]) {
2075
+ const a = Math.min(p, q);
2076
+ const b = Math.max(p, q);
2077
+ const key = a * V + b;
2078
+ const e = edgeUses.get(key);
2079
+ if (e === undefined) edgeUses.set(key, { a, b, n: 1 });
2080
+ else e.n++;
2081
+ }
2082
+ }
2083
+ const adj = new Map<number, number[]>();
2084
+ const link = (p: number, q: number): void => {
2085
+ const list = adj.get(p);
2086
+ if (list === undefined) adj.set(p, [q]);
2087
+ else list.push(q);
2088
+ };
2089
+ for (const { a, b, n } of edgeUses.values()) {
2090
+ if (n !== 1) continue;
2091
+ link(a, b);
2092
+ link(b, a);
2093
+ }
2094
+ const order: number[] = [];
2095
+ const seen = new Set<number>();
2096
+ for (const s of adj.keys()) {
2097
+ if (seen.has(s)) continue;
2098
+ let cur = s;
2099
+ let prev = -1;
2100
+ while (!seen.has(cur)) {
2101
+ seen.add(cur);
2102
+ order.push(cur);
2103
+ const next = (adj.get(cur) ?? []).filter((v) => v !== prev && !seen.has(v));
2104
+ if (next.length === 0) break;
2105
+ prev = cur;
2106
+ cur = next[0];
2107
+ }
2108
+ }
2109
+ const hullVertices = order.length;
2110
+ for (let v = 0; v < V; v++) if (!seen.has(v)) order.push(v);
2111
+ const remap = new Int32Array(V);
2112
+ order.forEach((v, i) => (remap[v] = i));
2113
+ const points = order.map((v) => pts[v]);
2114
+ // Emitted counter-clockwise in Spine world: the lattice's corners were taken
2115
+ // clockwise on the y-down page, so each triangle's last two are swapped.
2116
+ const triangles: number[] = [];
2117
+ for (let t = 0; t < tri.length; t += 3) triangles.push(remap[tri[t]], remap[tri[t + 2]], remap[tri[t + 1]]);
2118
+ const uvs: number[] = [];
2119
+ for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
2120
+ return {
2121
+ points,
2122
+ uvs,
2123
+ triangles,
2124
+ hullVertices,
2125
+ report: { cols: nx, rows: ny, artCells, keptCells, passes, islands },
2126
+ };
2127
+ }
2128
+
2129
+ /** One candidate segment, in Spine world, and which of the mesh's bones it belongs to. */
2130
+ export interface WorldSegment {
2131
+ /** Index into the mesh's distinct bone list. */
2132
+ bone: number;
2133
+ a: readonly [number, number];
2134
+ b: readonly [number, number];
2135
+ }
2136
+
2137
+ /** The four numbers the falloff reads, every one of them from the spec. */
2138
+ export interface SegmentsFalloff {
2139
+ power: number;
2140
+ radius: number;
2141
+ maxBones: number;
2142
+ minWeight: number;
2143
+ }
2144
+
2145
+ /**
2146
+ * Distance from `p` to the segment `a -> b`, a zero-length segment being its point.
2147
+ *
2148
+ * `sqrt(dx*dx + dy*dy)` rather than `Math.hypot`, whose last bit can differ:
2149
+ * a weight is written on a 6-decimal grid, and that is where one ulp becomes a
2150
+ * different number in the file.
2151
+ */
2152
+ export function segmentDistance(p: readonly [number, number], a: readonly [number, number], b: readonly [number, number]): number {
2153
+ const abx = b[0] - a[0];
2154
+ const aby = b[1] - a[1];
2155
+ const len2 = abx * abx + aby * aby;
2156
+ let t = 0;
2157
+ if (len2 > 0) {
2158
+ t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / len2;
2159
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
2160
+ }
2161
+ const dx = p[0] - (a[0] + t * abx);
2162
+ const dy = p[1] - (a[1] + t * aby);
2163
+ return Math.sqrt(dx * dx + dy * dy);
2164
+ }
2165
+
2166
+ /**
2167
+ * The shares on one vertex, strongest first and closing at exactly 1 on the
2168
+ * generator's 6-decimal grid — or, when every share the kept bones carry is
2169
+ * under `minWeight`, the unrounded shares that were dropped, for the refusal to
2170
+ * print. A vertex is never given a bone the falloff did not choose.
2171
+ */
2172
+ export function segmentShares(
2173
+ p: readonly [number, number],
2174
+ segments: readonly WorldSegment[],
2175
+ falloff: SegmentsFalloff,
2176
+ ): { weights: MeshVertexWeight[] } | { dropped: Array<{ bone: number; share: number }> } {
2177
+ const byBone = new Map<number, number>();
2178
+ for (const s of segments) {
2179
+ const reach = segmentDistance(p, s.a, s.b) + falloff.radius;
2180
+ const w = 1 / Math.pow(reach, falloff.power);
2181
+ const was = byBone.get(s.bone);
2182
+ byBone.set(s.bone, was === undefined ? w : Math.max(was, w));
2183
+ }
2184
+ // `sort` is stable, so equal pulls keep the order the bones first appeared in.
2185
+ const top = [...byBone.entries()].sort((x, y) => y[1] - x[1]).slice(0, falloff.maxBones);
2186
+ let sum = 0;
2187
+ for (const [, v] of top) sum += v;
2188
+ const shares = top.map(([bone, v]) => ({ bone, share: v / sum }));
2189
+ const kept = shares.filter((e) => e.share >= falloff.minWeight);
2190
+ if (kept.length === 0) return { dropped: shares };
2191
+ let sum2 = 0;
2192
+ for (const e of kept) sum2 += e.share;
2193
+ const weights: MeshVertexWeight[] = [];
2194
+ let others = 0;
2195
+ kept.forEach((e, k) => {
2196
+ const weight = k === kept.length - 1 ? r6(1 - others) : r6(e.share / sum2);
2197
+ others += weight;
2198
+ weights.push({ bone: 'control', control: e.bone, weight });
2199
+ });
2200
+ return { weights };
2201
+ }
package/src/pose.ts CHANGED
@@ -196,6 +196,87 @@ const MAX_ALTERNATES = 3;
196
196
 
197
197
  const DEG = Math.PI / 180;
198
198
 
199
+ /**
200
+ * The detail below which `pose` has been MEASURED to almost never place a part
201
+ * — the floor a refusal or an ambiguity is read against (issue #857).
202
+ *
203
+ * 📏 Measured, not chosen: `tools/pose_floor.ts` builds the grid and derives
204
+ * `detail` (`deriveFloor`: the largest rung of a fixed ladder under which at
205
+ * most a tenth of the grid's trials were found), and `docs/AUTHORING.md` §11.5
206
+ * carries the grid, the method and what it rejected. A part is BELOW the floor
207
+ * when its `detail` is under `detail`, or its longest side, in its own pixels,
208
+ * under `span` — the grid cut its parts at scale 1, so its sizes are both.
209
+ *
210
+ * ⚠️ Two things this is not. It is not a size found to matter: `span` is the
211
+ * smallest size the grid measured, and over 24–192 px no texture level placed
212
+ * measurably better when larger — a part under it is below the floor because
213
+ * nothing was measured there, not because it was measured to fail. And it is
214
+ * not a line above which `pose` is reliable: over it, on IDEAL cuts (parts cut
215
+ * from the frame's own render, residual at the truth near zero), `rateAbove`
216
+ * of the trials were found. A generated cut also disagrees with its picture,
217
+ * which can only make that lower. So "above the floor" means "plainness does
218
+ * not explain this refusal", which is the question the issue asked, and never
219
+ * "this part will place".
220
+ */
221
+ export const POSE_FLOOR = {
222
+ material: 'examples/spineboy, cut from its own render (tools/pose_floor.ts)',
223
+ withinPx: 2,
224
+ span: 24,
225
+ detail: 0.5,
226
+ /** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
227
+ rateBelow: 0.066,
228
+ rateAbove: 0.594,
229
+ };
230
+
231
+ /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
232
+ export function floorSide(side: number, detail: number): 'below' | 'above' {
233
+ return side >= POSE_FLOOR.span && detail >= POSE_FLOOR.detail ? 'above' : 'below';
234
+ }
235
+
236
+ /** The floor as one clause, for the sentences that cite it. */
237
+ export function floorClause(): string {
238
+ return `detail ${POSE_FLOOR.detail}, measured from ${POSE_FLOOR.span} px up`;
239
+ }
240
+
241
+ /**
242
+ * The sentence that tells "the part is wrong" from "the part is too small or too
243
+ * plain for `pose`", with every figure it rests on (issue #857).
244
+ *
245
+ * ⭐ Exported for the reason `windowEdgeNote` is: the guide quotes it, and a
246
+ * message and the document teaching it must be built from one place.
247
+ */
248
+ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: 'refused' | 'ambiguous'): string {
249
+ const figures =
250
+ `part is ${l.width}x${l.height} px (span ${l.span} frame px, opaque ${l.opaqueShare}) with texture ${l.texture}, ` +
251
+ `detail ${l.detail}; ` +
252
+ `${l.candidates} candidate(s), best ${l.best.toFixed(4)}` +
253
+ (l.next === null || l.spread === null ? '' : `, next ${l.next.toFixed(4)}, spread ${l.spread.toFixed(4)}`);
254
+ if (l.floor === 'below') {
255
+ const why =
256
+ Math.max(l.width, l.height) < POSE_FLOOR.span
257
+ ? `smaller than the smallest size the floor was measured at, so nothing measured says pose can ` +
258
+ `${verdict === 'refused' ? 'place' : 'separate'} it`
259
+ : `pose placed ${Math.round(POSE_FLOOR.rateBelow * 100)}% of the measured parts this plain, so this part is too ` +
260
+ `plain for pose to ${verdict === 'refused' ? 'place' : 'tell its placements apart'}`;
261
+ return (
262
+ `${figures} — below the measured floor (AUTHORING §11.5: ${floorClause()}): ${why}; that says nothing about ` +
263
+ 'whether the cut is right'
264
+ );
265
+ }
266
+ // ⚠️ "Above the floor" is not "the search found the right hill". Measured on
267
+ // the floor's own material, a part above it came back ambiguous between two
268
+ // equally WRONG placements (residual 0.148 each, 173 px off) while the truth
269
+ // scored 0.035 — a basin the coarse pass never sent down. So the sentence
270
+ // names that reading too, with the one remedy that separates it: a window.
271
+ return (
272
+ `${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
273
+ (verdict === 'refused'
274
+ ? 'the cut (or the search window) is the suspect'
275
+ : 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
276
+ 'a narrower --scale or --rotation window around what you know of the part tells the two apart')
277
+ );
278
+ }
279
+
199
280
  // ---------------------------------------------------------------------------
200
281
  // the report
201
282
  // ---------------------------------------------------------------------------
@@ -307,10 +388,69 @@ export interface PosePart {
307
388
  * the part is small relative to the frame and the first pass had little to go on.
308
389
  */
309
390
  coarse: { reduction: number; cols: number; rows: number; stride: number } | null;
391
+ /**
392
+ * What the part itself gave the search to work with, and how the search's
393
+ * answers stood against each other — `null` only when nothing was searched
394
+ * (`empty-part`, `larger-than-canvas`). See `PoseLegibility`.
395
+ */
396
+ legibility: PoseLegibility | null;
310
397
  /** Plain-language versions of everything above, in the order they were found. */
311
398
  notes: string[];
312
399
  }
313
400
 
401
+ /**
402
+ * The facts that tell "this part is wrong" from "this part is too small or too
403
+ * plain for `pose`" (issue #857).
404
+ *
405
+ * ⭐ Both readings end in the same refusal or the same ambiguity, and nothing in
406
+ * a residual separates them: a correct cut of a plain sleeve and a foreign part
407
+ * can print the same number. What separates them is the part's own evidence —
408
+ * how big it is in the frame and how much pattern it carries — held against a
409
+ * floor measured on parts whose placement was known (`POSE_FLOOR`). So every
410
+ * searched part carries these, whatever its verdict, and a refusal or an
411
+ * ambiguity quotes them in a sentence that says which of the two it is.
412
+ */
413
+ export interface PoseLegibility {
414
+ /** The part's material box, part pixels — the PNG less its transparent margin. */
415
+ width: number;
416
+ height: number;
417
+ /**
418
+ * Longest side of the material box at the best placement's scale, frame pixels.
419
+ * ⚠️ Not what `floor` is read from — the part's own `width`/`height` are, since
420
+ * this one moves with a placement that may be wrong.
421
+ */
422
+ span: number;
423
+ /** The part's alpha weight over its material box's area, 0..1. A thin diagonal limb reads low; a filled block reads 1. */
424
+ opaqueShare: number;
425
+ /** Mean colour step between neighbouring material pixels, 0..1 — see `partTexture`. */
426
+ texture: number;
427
+ /**
428
+ * `texture` times the material box's longest side in PART pixels: the colour
429
+ * change a walk along the part's length accumulates. The figure the floor is
430
+ * stated in — see `POSE_FLOOR` for why it and not `texture` alone.
431
+ */
432
+ detail: number;
433
+ /** Distinct placements the search measured at full resolution, the best included. */
434
+ candidates: number;
435
+ /** The best placement's residual, and the second distinct one's — `null` when there was no second. */
436
+ best: number;
437
+ next: number | null;
438
+ /** `next − best`: how far the search's second answer was from its first. `null` with no second. */
439
+ spread: number | null;
440
+ /**
441
+ * Where the part stands against `POSE_FLOOR` — `below` when its span or its
442
+ * detail is under the floor's. A reading of the part, not of the answer: it is set on accepted
443
+ * parts too, and it is what a refusal's sentence is chosen by.
444
+ */
445
+ floor: 'below' | 'above';
446
+ /**
447
+ * The sentence a refused or ambiguous part carries, saying which of the two
448
+ * readings its figures support — `null` on a part that was placed. Built by
449
+ * `legibilityReading`, and the console prints the same text.
450
+ */
451
+ reading: string | null;
452
+ }
453
+
314
454
  export interface PoseSearch {
315
455
  scale: { min: number; max: number; steps: number };
316
456
  /**
@@ -550,6 +690,45 @@ function materialBox(part: Plate): { minX: number; minY: number; maxX: number; m
550
690
  return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, weight };
551
691
  }
552
692
 
693
+ /**
694
+ * The part's texture figure: the mean colour step between neighbouring material
695
+ * pixels, 0..1, in the residual's own units.
696
+ *
697
+ * Every pair of a pixel and its right or lower neighbour, both with material,
698
+ * contributes its mean absolute channel difference over 255, weighted by the
699
+ * smaller of the two alphas. A part filled with one colour reads exactly 0.
700
+ *
701
+ * ⭐ Why this figure rather than a colour variance: it is, to first order, what a
702
+ * one-pixel shift costs. Moving a part by a pixel inside material that continues
703
+ * under it compares each of its pixels against its neighbour's colour, so the
704
+ * residual rises by about this much per pixel of shift — and a part whose
705
+ * residual cannot rise when it moves cannot be told apart from itself moved.
706
+ * A variance does not see that: a part half one colour and half another has a
707
+ * large variance and one edge. The silhouette is deliberately not counted — it
708
+ * is what the material term already reads, and only where the frame is ground.
709
+ */
710
+ export function partTexture(part: Plate): number {
711
+ const w = part.width;
712
+ const d = part.data;
713
+ let weight = 0;
714
+ let acc = 0;
715
+ const pair = (i: number, j: number): void => {
716
+ const k = Math.min(d[i + 3], d[j + 3]) / 255;
717
+ if (k <= 0) return;
718
+ weight += k;
719
+ acc += (k * (Math.abs(d[i] - d[j]) + Math.abs(d[i + 1] - d[j + 1]) + Math.abs(d[i + 2] - d[j + 2]))) / 765;
720
+ };
721
+ for (let y = 0; y < part.height; y++) {
722
+ for (let x = 0; x < w; x++) {
723
+ const i = (y * w + x) * 4;
724
+ if (d[i + 3] === 0) continue;
725
+ if (x + 1 < w) pair(i, i + 4);
726
+ if (y + 1 < part.height) pair(i, i + w * 4);
727
+ }
728
+ }
729
+ return weight === 0 ? 0 : acc / weight;
730
+ }
731
+
553
732
  // ---------------------------------------------------------------------------
554
733
  // the objective
555
734
  // ---------------------------------------------------------------------------
@@ -1347,6 +1526,7 @@ function placePart(
1347
1526
  rotationFree: false,
1348
1527
  rotationSelfSimilarity: 1,
1349
1528
  coarse: null,
1529
+ legibility: null,
1350
1530
  notes: [`${name} could not be decoded, so it was not placed.`],
1351
1531
  };
1352
1532
  }
@@ -1363,6 +1543,7 @@ function placePart(
1363
1543
  rotationFree: false,
1364
1544
  rotationSelfSimilarity: 1,
1365
1545
  coarse: null,
1546
+ legibility: null,
1366
1547
  notes: [],
1367
1548
  };
1368
1549
 
@@ -1579,6 +1760,26 @@ function placePart(
1579
1760
  }
1580
1761
 
1581
1762
  const best = distinct[0].placement;
1763
+ const second = distinct.length > 1 ? distinct[1].placement.residual : null;
1764
+ base.legibility = {
1765
+ width: tw,
1766
+ height: th,
1767
+ span: roundTo(span * best.scale, 1),
1768
+ opaqueShare: roundTo(box.weight / (tw * th), 4),
1769
+ texture: roundTo(partTexture(part), 4),
1770
+ detail: roundTo(partTexture(part) * span, 3),
1771
+ candidates: distinct.length,
1772
+ best: best.residual,
1773
+ next: second,
1774
+ spread: second === null ? null : roundTo(second - best.residual, 5),
1775
+ floor: 'above',
1776
+ reading: null,
1777
+ };
1778
+ // The part's OWN size, not `span`: `span` is scaled by the best placement,
1779
+ // and on the parts this reading exists for that placement is the one in
1780
+ // doubt — a gun cut at 32 px read as 16 px under the half-scale optimum it
1781
+ // wrongly settled on. The floor was measured at scale 1, where the two agree.
1782
+ base.legibility.floor = floorSide(Math.max(tw, th), base.legibility.detail);
1582
1783
  const margin = Math.max(AMBIGUITY_ABSOLUTE, best.residual * AMBIGUITY_RELATIVE);
1583
1784
  const close = distinct.slice(1).filter((d) => d.placement.residual - best.residual <= margin);
1584
1785
  base.placement = best;
@@ -1653,6 +1854,17 @@ function placePart(
1653
1854
  `${base.walls.map((wall) => wallClause(wall.axis, wall.edge, wall.window)).join('; ')}.`,
1654
1855
  );
1655
1856
  }
1857
+ // ⭐ Which of the two readings a refusal or an ambiguity is (issue #857),
1858
+ // in the refusal's own detail and in a note, from the figures `legibility`
1859
+ // already carries. After the walls on purpose: a wall is the first thing to
1860
+ // move, and this sentence is about what moving it cannot change.
1861
+ if (base.refusal !== null || base.ambiguous) {
1862
+ const verdict = base.refusal !== null ? 'refused' : 'ambiguous';
1863
+ const reading = legibilityReading(base.legibility, verdict);
1864
+ base.legibility.reading = reading;
1865
+ if (base.refusal !== null) base.refusal.detail += `; ${reading}`;
1866
+ base.notes.push(`${name}: ${reading}.`);
1867
+ }
1656
1868
  if (best.unexplained > 0.25 && best.residual <= maxResidual) {
1657
1869
  base.notes.push(
1658
1870
  `${roundTo(best.unexplained * 100, 1)}% of ${name}'s material disagrees with the frame at this placement. ` +
@@ -1728,6 +1940,7 @@ export function poseLines(report: PoseReport): string[] {
1728
1940
  lines.push(` ${' '.repeat(width)} alt ${i + 2}: ${placementLine(alt)}`);
1729
1941
  });
1730
1942
  if (part.refusal !== null) lines.push(` ${' '.repeat(width)} ${part.refusal.reason}: ${part.refusal.detail}`);
1943
+ else if (part.legibility?.reading) lines.push(` ${' '.repeat(width)} ambiguous: ${part.legibility.reading}`);
1731
1944
  }
1732
1945
  lines.push('');
1733
1946
  lines.push(' .. residuals are a trust signal, not a score — nothing here has a pass bar.');