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/README.md +7 -5
- package/cli.ts +63 -6
- package/docs/AUTHORING.md +309 -18
- package/docs/FACE.md +15 -0
- package/docs/INGEST.md +1 -1
- package/package.json +25 -3
- package/src/atlas.ts +120 -2
- package/src/compile.ts +335 -0
- package/src/mesh.ts +405 -1
- package/src/pose.ts +213 -0
- package/src/rig.ts +140 -7
- package/src/types.ts +30 -0
- package/src/validate.ts +101 -24
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.');
|