@ikijs/editor 0.4.0 → 0.6.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 +34 -2
- package/dist/index.d.mts +141 -8
- package/dist/index.d.ts +141 -8
- package/dist/index.js +1739 -358
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1737 -358
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1603,11 +1603,27 @@ import {
|
|
|
1603
1603
|
StandardParameter,
|
|
1604
1604
|
parseIkiModel as parseIkiModel3
|
|
1605
1605
|
} from "@ikijs/format";
|
|
1606
|
+
var TURN_GROUP_IDS = [
|
|
1607
|
+
"faceWarp",
|
|
1608
|
+
"eyeWarp_L",
|
|
1609
|
+
"eyeWarp_R",
|
|
1610
|
+
"mouthWarp",
|
|
1611
|
+
"noseWarp",
|
|
1612
|
+
"browWarp_L",
|
|
1613
|
+
"browWarp_R",
|
|
1614
|
+
"blushWarp_L",
|
|
1615
|
+
"blushWarp_R"
|
|
1616
|
+
];
|
|
1617
|
+
var TURN_GROUP_ID_SET = new Set(TURN_GROUP_IDS);
|
|
1618
|
+
function isTurnGroup(id) {
|
|
1619
|
+
return TURN_GROUP_ID_SET.has(id);
|
|
1620
|
+
}
|
|
1606
1621
|
var ROLE_TABLE = {
|
|
1607
1622
|
// The silhouette behind the face. It rides the rigid head (not faceWarp,
|
|
1608
|
-
// whose grid it would stretch across the shoulders) as a MESH
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1623
|
+
// whose grid it would stretch across the shoulders) as a MESH, so its ends
|
|
1624
|
+
// can swing on the hair-sway warps. It holds the head's outline STILL
|
|
1625
|
+
// through the turn — the head it hangs off no longer travels and it has no
|
|
1626
|
+
// turn warp of its own — while the face slides inside it.
|
|
1611
1627
|
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1612
1628
|
// The torso. It rides its own rigid deformer, not the head's: the head turns
|
|
1613
1629
|
// about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
|
|
@@ -1615,34 +1631,43 @@ var ROLE_TABLE = {
|
|
|
1615
1631
|
// reading as a floating head. Drawn over the back hair so long hair falls
|
|
1616
1632
|
// behind the shoulders.
|
|
1617
1633
|
body: { deformer: "bodyDeformer", order: 5, mesh: false },
|
|
1634
|
+
// The face plate and everything painted on it ride per-group turn warps
|
|
1635
|
+
// under the head (TurnGroupId): the plate its own, each eye stack its
|
|
1636
|
+
// side's, both mouth drawings one, the nose, each brow and each blush their
|
|
1637
|
+
// own. A group's grid is sized to its members alone and baked from the one
|
|
1638
|
+
// solved surface with the family's turn depth in its keyforms, so a feature
|
|
1639
|
+
// slides across the plate on its own grid instead of a translate binding
|
|
1640
|
+
// (see generateIkiFromLayerSet).
|
|
1618
1641
|
face: { deformer: "faceWarp", order: 10, mesh: true },
|
|
1619
|
-
nose: { deformer: "
|
|
1620
|
-
blush_L: { deformer: "
|
|
1621
|
-
blush_R: { deformer: "
|
|
1622
|
-
mouth: { deformer: "
|
|
1642
|
+
nose: { deformer: "noseWarp", order: 15, mesh: true },
|
|
1643
|
+
blush_L: { deformer: "blushWarp_L", order: 20, mesh: true },
|
|
1644
|
+
blush_R: { deformer: "blushWarp_R", order: 20, mesh: true },
|
|
1645
|
+
mouth: { deformer: "mouthWarp", order: 25, mesh: true },
|
|
1623
1646
|
// An OPTIONAL second mouth drawing, open. When present the two cross-fade on
|
|
1624
1647
|
// MouthOpen instead of the closed one being stretched, which is the
|
|
1625
1648
|
// difference between a portrait rig and one that can lip-sync.
|
|
1626
|
-
mouth_open: { deformer: "
|
|
1627
|
-
eye_L: { deformer: "
|
|
1628
|
-
eye_R: { deformer: "
|
|
1629
|
-
iris_L: { deformer: "
|
|
1630
|
-
iris_R: { deformer: "
|
|
1631
|
-
pupil_L: { deformer: "
|
|
1632
|
-
pupil_R: { deformer: "
|
|
1633
|
-
highlight_L: { deformer: "
|
|
1634
|
-
highlight_R: { deformer: "
|
|
1649
|
+
mouth_open: { deformer: "mouthWarp", order: 26, mesh: true },
|
|
1650
|
+
eye_L: { deformer: "eyeWarp_L", order: 30, mesh: true, eyeSide: "L" },
|
|
1651
|
+
eye_R: { deformer: "eyeWarp_R", order: 30, mesh: true, eyeSide: "R" },
|
|
1652
|
+
iris_L: { deformer: "eyeWarp_L", order: 31, mesh: true, eyeSide: "L" },
|
|
1653
|
+
iris_R: { deformer: "eyeWarp_R", order: 31, mesh: true, eyeSide: "R" },
|
|
1654
|
+
pupil_L: { deformer: "eyeWarp_L", order: 32, mesh: true, eyeSide: "L" },
|
|
1655
|
+
pupil_R: { deformer: "eyeWarp_R", order: 32, mesh: true, eyeSide: "R" },
|
|
1656
|
+
highlight_L: { deformer: "eyeWarp_L", order: 33, mesh: true, eyeSide: "L" },
|
|
1657
|
+
highlight_R: { deformer: "eyeWarp_R", order: 33, mesh: true, eyeSide: "R" },
|
|
1635
1658
|
// Upper lashes: an OPTIONAL separate layer ABOVE the iris that folds down to
|
|
1636
1659
|
// the closed-eye seam (the same crease the white folds to), covering the cut
|
|
1637
1660
|
// eyeball cleanly. When absent, the white's own fold is the only closed line.
|
|
1638
|
-
lash_L: { deformer: "
|
|
1639
|
-
lash_R: { deformer: "
|
|
1640
|
-
brow_L: { deformer: "
|
|
1641
|
-
brow_R: { deformer: "
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
1645
|
-
|
|
1661
|
+
lash_L: { deformer: "eyeWarp_L", order: 34, mesh: true, eyeSide: "L" },
|
|
1662
|
+
lash_R: { deformer: "eyeWarp_R", order: 34, mesh: true, eyeSide: "R" },
|
|
1663
|
+
brow_L: { deformer: "browWarp_L", order: 40, mesh: true },
|
|
1664
|
+
brow_R: { deformer: "browWarp_R", order: 40, mesh: true },
|
|
1665
|
+
// The bangs ride the rigid head, like the back hair, and bind to no grid:
|
|
1666
|
+
// every motion of their own — the silhouette hold, the turn lead, the nod
|
|
1667
|
+
// and the sway — is a per-vertex warp attached in generateIkiFromLayerSet,
|
|
1668
|
+
// so they draw the head's outline where the plate under them bends and
|
|
1669
|
+
// slides (see bakeHairFrontSilhouetteWarp).
|
|
1670
|
+
hair_front: { deformer: "headDeformer", order: 50, mesh: true }
|
|
1646
1671
|
};
|
|
1647
1672
|
var REQUIRED_ROLES = ["face", "eye_L", "eye_R", "mouth"];
|
|
1648
1673
|
var ALIAS_MAP = {
|
|
@@ -1730,6 +1755,27 @@ function validateLayerInputs(layers, canvas) {
|
|
|
1730
1755
|
`auto-rig: validateLayerInputs: role "${role}" canvas size (${canvasW}\xD7${canvasH}) does not match canvas arg (${canvas.width}\xD7${canvas.height})`
|
|
1731
1756
|
);
|
|
1732
1757
|
}
|
|
1758
|
+
if (layer.rowHalfWidths !== void 0) {
|
|
1759
|
+
const rows = layer.rowHalfWidths;
|
|
1760
|
+
if (!Array.isArray(rows)) {
|
|
1761
|
+
throw new Error(
|
|
1762
|
+
`auto-rig: validateLayerInputs: role "${role}" rowHalfWidths must be an array with one entry per crop row`
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
if (rows.length !== cropH) {
|
|
1766
|
+
throw new Error(
|
|
1767
|
+
`auto-rig: validateLayerInputs: role "${role}" rowHalfWidths has ${rows.length} entries, not one per crop row (cropH ${cropH})`
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1771
|
+
const a = rows[i];
|
|
1772
|
+
if (typeof a !== "number" || !Number.isFinite(a) || a < 0 || a > cropW / 2) {
|
|
1773
|
+
throw new Error(
|
|
1774
|
+
`auto-rig: validateLayerInputs: role "${role}" rowHalfWidths[${i}] is ${String(a)}, not a finite number in [0, ${cropW / 2}] (half the crop's width)`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1733
1779
|
}
|
|
1734
1780
|
}
|
|
1735
1781
|
function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
|
|
@@ -1785,44 +1831,123 @@ function meshCellsFor(w, h) {
|
|
|
1785
1831
|
);
|
|
1786
1832
|
return { cols: cells(w), rows: cells(h) };
|
|
1787
1833
|
}
|
|
1788
|
-
var
|
|
1834
|
+
var FACE_PLATE_CELLS = 10;
|
|
1835
|
+
var FEATURE_GRID_CELLS = 4;
|
|
1836
|
+
var TURN_LATTICE_CELL_PX = 16;
|
|
1837
|
+
var GRID_MARGIN = 0.12;
|
|
1789
1838
|
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1790
1839
|
var HEAD_TURN_MAX_DEG = 30;
|
|
1791
1840
|
var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
|
|
1792
|
-
function headTurnParallaxUnit(
|
|
1793
|
-
return
|
|
1794
|
-
}
|
|
1795
|
-
function gridReach(grid, axis, center) {
|
|
1796
|
-
let reach = 0;
|
|
1797
|
-
for (let i = axis; i < grid.points.length; i += 2) {
|
|
1798
|
-
reach = Math.max(reach, Math.abs(grid.points[i] - center));
|
|
1799
|
-
}
|
|
1800
|
-
return reach;
|
|
1841
|
+
function headTurnParallaxUnit(radius) {
|
|
1842
|
+
return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1801
1843
|
}
|
|
1802
1844
|
function pinnedCylinderBend(local, radius, theta) {
|
|
1803
1845
|
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1804
1846
|
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1805
1847
|
}
|
|
1806
|
-
function
|
|
1848
|
+
function boundedCylinderBend(local, radius, theta) {
|
|
1849
|
+
const bound = radius / HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1850
|
+
const onSurface = Math.max(-bound, Math.min(bound, local));
|
|
1851
|
+
return pinnedCylinderBend(onSurface, radius, theta);
|
|
1852
|
+
}
|
|
1853
|
+
function turnSlide(travel, angleX) {
|
|
1854
|
+
return travel * angleX / HEAD_TURN_MAX_DEG;
|
|
1855
|
+
}
|
|
1856
|
+
var FACE_ROW_MIN_FRACTION = 0.15;
|
|
1857
|
+
var FACE_ROW_SMOOTHING = 0.05;
|
|
1858
|
+
var CHIN_SWING = 0.08;
|
|
1859
|
+
function faceRowProfile(face, faceCenterY) {
|
|
1860
|
+
const raw = face.rowHalfWidths;
|
|
1861
|
+
if (raw === void 0) return void 0;
|
|
1862
|
+
const n = raw.length;
|
|
1863
|
+
let aMax = 0;
|
|
1864
|
+
let widest = -1;
|
|
1865
|
+
for (let i = 0; i < n; i++) {
|
|
1866
|
+
if (raw[i] > 0 && raw[i] >= aMax) {
|
|
1867
|
+
aMax = raw[i];
|
|
1868
|
+
widest = i;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
if (widest < 0) return void 0;
|
|
1872
|
+
const filled = [];
|
|
1873
|
+
for (let i = 0; i < n; i++) {
|
|
1874
|
+
filled.push(i < widest ? aMax : raw[i] > 0 ? raw[i] : filled[i - 1]);
|
|
1875
|
+
}
|
|
1876
|
+
const half = Math.max(1, Math.round(FACE_ROW_SMOOTHING * n / 2));
|
|
1877
|
+
const floor = FACE_ROW_MIN_FRACTION * aMax;
|
|
1878
|
+
const rows = [];
|
|
1879
|
+
for (let i = 0; i < n; i++) {
|
|
1880
|
+
if (i < widest) {
|
|
1881
|
+
rows.push(aMax);
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
let sum = 0;
|
|
1885
|
+
for (let k = i - half; k <= i + half; k++) {
|
|
1886
|
+
sum += filled[Math.max(0, Math.min(n - 1, k))];
|
|
1887
|
+
}
|
|
1888
|
+
rows.push(Math.max(floor, sum / (2 * half + 1)));
|
|
1889
|
+
}
|
|
1890
|
+
const top = faceCenterY + face.cropH / 2;
|
|
1891
|
+
const rowAt = (y) => Math.max(0, Math.min(n - 1, top - y - 0.5));
|
|
1892
|
+
return {
|
|
1893
|
+
at: (y) => {
|
|
1894
|
+
const r = rowAt(y);
|
|
1895
|
+
const i = Math.floor(r);
|
|
1896
|
+
if (i + 1 >= n) return rows[n - 1];
|
|
1897
|
+
return rows[i] + (rows[i + 1] - rows[i]) * (r - i);
|
|
1898
|
+
},
|
|
1899
|
+
aMax,
|
|
1900
|
+
widestY: top - widest - 0.5,
|
|
1901
|
+
faceHalfWidth: face.cropW / 2
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
function turnSurface(spec) {
|
|
1905
|
+
const { faceCenterX, faceCenterY, radius, travel, nodRadius, lattice } = spec;
|
|
1906
|
+
const { profile } = spec;
|
|
1907
|
+
let radiusAt = (_y) => radius;
|
|
1908
|
+
let swingAt = (_y) => 0;
|
|
1909
|
+
if (profile !== void 0) {
|
|
1910
|
+
const aEye = profile.at(spec.eyeRowY ?? faceCenterY);
|
|
1911
|
+
radiusAt = (y) => radius * (profile.at(y) / aEye);
|
|
1912
|
+
swingAt = (y) => y < profile.widestY ? -CHIN_SWING * profile.faceHalfWidth * Math.max(0, 1 - profile.at(y) / profile.aMax) : 0;
|
|
1913
|
+
}
|
|
1914
|
+
return {
|
|
1915
|
+
faceCenterX,
|
|
1916
|
+
faceCenterY,
|
|
1917
|
+
radius,
|
|
1918
|
+
travel,
|
|
1919
|
+
nodRadius,
|
|
1920
|
+
lattice,
|
|
1921
|
+
radiusAt,
|
|
1922
|
+
swingAt,
|
|
1923
|
+
mapAt: (deg, y) => turnColumnMap(
|
|
1924
|
+
lattice,
|
|
1925
|
+
faceCenterX,
|
|
1926
|
+
radiusAt(y),
|
|
1927
|
+
deg,
|
|
1928
|
+
travel + swingAt(y)
|
|
1929
|
+
),
|
|
1930
|
+
nodBendAt: (y, angleY) => pinnedCylinderBend(
|
|
1931
|
+
y - faceCenterY,
|
|
1932
|
+
nodRadius,
|
|
1933
|
+
angleY * NOD_BEND * (Math.PI / 180)
|
|
1934
|
+
)
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
|
|
1807
1938
|
const STOPS = [...HEAD_TURN_STOPS];
|
|
1808
|
-
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1809
|
-
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1810
1939
|
const pointCount = grid.points.length / 2;
|
|
1811
|
-
const DEG_TO_RAD = Math.PI / 180;
|
|
1812
1940
|
const keyforms2d = [];
|
|
1813
1941
|
for (const angleY of STOPS) {
|
|
1814
|
-
const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
|
|
1815
1942
|
for (const angleX of STOPS) {
|
|
1816
|
-
const
|
|
1943
|
+
const shift = shiftAt(angleX);
|
|
1817
1944
|
const offsets = [];
|
|
1818
1945
|
for (let i = 0; i < pointCount; i++) {
|
|
1946
|
+
const x = grid.points[i * 2];
|
|
1947
|
+
const y = grid.points[i * 2 + 1];
|
|
1819
1948
|
offsets.push(
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
grid.points[i * 2 + 1] - centerY,
|
|
1823
|
-
RADIUS_Y,
|
|
1824
|
-
thetaY
|
|
1825
|
-
)
|
|
1949
|
+
angleX === 0 ? 0 : surface.mapAt(angleX, y).mapX(x + shift) - x,
|
|
1950
|
+
angleY === 0 ? 0 : surface.nodBendAt(y, angleY)
|
|
1826
1951
|
);
|
|
1827
1952
|
}
|
|
1828
1953
|
keyforms2d.push({ offsets });
|
|
@@ -1836,16 +1961,967 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
|
|
|
1836
1961
|
keyforms2d
|
|
1837
1962
|
};
|
|
1838
1963
|
}
|
|
1964
|
+
function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
|
|
1965
|
+
if (Math.abs(angleX) > HEAD_TURN_MAX_DEG) {
|
|
1966
|
+
throw new Error(
|
|
1967
|
+
`auto-rig: turnColumnMap: angleX ${angleX}\xB0 is outside the turn's \xB1${HEAD_TURN_MAX_DEG}\xB0 range`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
const theta = angleX * (Math.PI / 180);
|
|
1971
|
+
const slide = turnSlide(travel, angleX);
|
|
1972
|
+
const restX = [];
|
|
1973
|
+
const warpedX = [];
|
|
1974
|
+
for (let col = 0; col <= grid.cols; col++) {
|
|
1975
|
+
const x = grid.points[col * 2];
|
|
1976
|
+
restX.push(x);
|
|
1977
|
+
warpedX.push(
|
|
1978
|
+
x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
const cellFor = (v, edges) => {
|
|
1982
|
+
let lo = 0;
|
|
1983
|
+
let hi = grid.cols - 1;
|
|
1984
|
+
while (lo < hi) {
|
|
1985
|
+
const mid = lo + hi >> 1;
|
|
1986
|
+
if (v < edges[mid + 1]) hi = mid;
|
|
1987
|
+
else lo = mid + 1;
|
|
1988
|
+
}
|
|
1989
|
+
return lo;
|
|
1990
|
+
};
|
|
1991
|
+
const sample = (edges, values, v) => {
|
|
1992
|
+
const c = cellFor(v, edges);
|
|
1993
|
+
const s = Math.max(
|
|
1994
|
+
0,
|
|
1995
|
+
Math.min(1, (v - edges[c]) / (edges[c + 1] - edges[c]))
|
|
1996
|
+
);
|
|
1997
|
+
if (s === 0) return values[c];
|
|
1998
|
+
if (s === 1) return values[c + 1];
|
|
1999
|
+
return values[c] + (values[c + 1] - values[c]) * s;
|
|
2000
|
+
};
|
|
2001
|
+
return {
|
|
2002
|
+
restX,
|
|
2003
|
+
warpedX,
|
|
2004
|
+
mapX: (x) => sample(restX, warpedX, x),
|
|
2005
|
+
invertX: (X) => sample(warpedX, restX, X)
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
function renderedLandingX(part, vertexLanding, px, py) {
|
|
2009
|
+
const { cols, rows } = part;
|
|
2010
|
+
const u = (px - part.x + part.cropW / 2) / part.cropW * cols;
|
|
2011
|
+
const v = (part.y + part.cropH / 2 - py) / part.cropH * rows;
|
|
2012
|
+
const col = Math.max(0, Math.min(cols - 1, Math.floor(u)));
|
|
2013
|
+
const row = Math.max(0, Math.min(rows - 1, Math.floor(v)));
|
|
2014
|
+
const fx = Math.max(0, Math.min(1, u - col));
|
|
2015
|
+
const fy = Math.max(0, Math.min(1, v - row));
|
|
2016
|
+
const cos = Math.cos(part.rotation * (Math.PI / 180));
|
|
2017
|
+
const sin = Math.sin(part.rotation * (Math.PI / 180));
|
|
2018
|
+
const landingAt = (c, r) => {
|
|
2019
|
+
const lx = (-part.cropW / 2 + c / cols * part.cropW) * part.scaleX;
|
|
2020
|
+
const ly = (part.cropH / 2 - r / rows * part.cropH) * part.scaleY;
|
|
2021
|
+
return vertexLanding(
|
|
2022
|
+
part.x + cos * lx - sin * ly,
|
|
2023
|
+
part.y + sin * lx + cos * ly
|
|
2024
|
+
);
|
|
2025
|
+
};
|
|
2026
|
+
const tl = landingAt(col, row);
|
|
2027
|
+
const br = landingAt(col + 1, row + 1);
|
|
2028
|
+
if (fx <= fy) {
|
|
2029
|
+
const bl = landingAt(col, row + 1);
|
|
2030
|
+
return tl + fy * (bl - tl) + fx * (br - bl);
|
|
2031
|
+
}
|
|
2032
|
+
const tr = landingAt(col + 1, row);
|
|
2033
|
+
return tl + fx * (tr - tl) + fy * (br - tr);
|
|
2034
|
+
}
|
|
2035
|
+
function gridChildLandingX(grid, nodeLanding, x, y) {
|
|
2036
|
+
const { cols, rows, points } = grid;
|
|
2037
|
+
const stride = cols + 1;
|
|
2038
|
+
let col = cols - 1;
|
|
2039
|
+
for (let c = 0; c < cols; c++) {
|
|
2040
|
+
if (x < points[(c + 1) * 2]) {
|
|
2041
|
+
col = c;
|
|
2042
|
+
break;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
let row = rows - 1;
|
|
2046
|
+
for (let r = 0; r < rows; r++) {
|
|
2047
|
+
if (y > points[(r + 1) * stride * 2 + 1]) {
|
|
2048
|
+
row = r;
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const xLeft = points[col * 2];
|
|
2053
|
+
const xRight = points[(col + 1) * 2];
|
|
2054
|
+
const yTop = points[row * stride * 2 + 1];
|
|
2055
|
+
const yBottom = points[(row + 1) * stride * 2 + 1];
|
|
2056
|
+
const s = Math.max(0, Math.min(1, (x - xLeft) / (xRight - xLeft)));
|
|
2057
|
+
const t = Math.max(0, Math.min(1, (yTop - y) / (yTop - yBottom)));
|
|
2058
|
+
const at = (c, r) => nodeLanding(points[(r * stride + c) * 2], points[(r * stride + c) * 2 + 1]);
|
|
2059
|
+
const top = at(col, row) + (at(col + 1, row) - at(col, row)) * s;
|
|
2060
|
+
const bot = at(col, row + 1) + (at(col + 1, row + 1) - at(col, row + 1)) * s;
|
|
2061
|
+
return top + (bot - top) * t;
|
|
2062
|
+
}
|
|
2063
|
+
function carrierLandingX(carrier, nodeLanding, px, py) {
|
|
2064
|
+
return renderedLandingX(
|
|
2065
|
+
carrier.part,
|
|
2066
|
+
(vx, vy) => gridChildLandingX(carrier.grid, nodeLanding, vx, vy),
|
|
2067
|
+
px,
|
|
2068
|
+
py
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
function rowMapsOf(surface) {
|
|
2072
|
+
const maps = /* @__PURE__ */ new Map();
|
|
2073
|
+
return (deg, nodeY) => {
|
|
2074
|
+
let byRow = maps.get(deg);
|
|
2075
|
+
if (byRow === void 0) {
|
|
2076
|
+
byRow = /* @__PURE__ */ new Map();
|
|
2077
|
+
maps.set(deg, byRow);
|
|
2078
|
+
}
|
|
2079
|
+
let map = byRow.get(nodeY);
|
|
2080
|
+
if (map === void 0) {
|
|
2081
|
+
map = surface.mapAt(deg, nodeY);
|
|
2082
|
+
byRow.set(nodeY, map);
|
|
2083
|
+
}
|
|
2084
|
+
return map;
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
function plateLandingOn(plate, mapAt) {
|
|
2088
|
+
return (deg, x, y) => carrierLandingX(
|
|
2089
|
+
plate,
|
|
2090
|
+
(nodeX, nodeY) => mapAt(deg, nodeY).mapX(nodeX),
|
|
2091
|
+
x,
|
|
2092
|
+
y
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
function plateGuardRowsFor(face, hairFront) {
|
|
2096
|
+
const rowsOf = (part, y) => {
|
|
2097
|
+
const { rows } = meshCellsFor(part.cropW, part.cropH);
|
|
2098
|
+
return Array.from(
|
|
2099
|
+
{ length: rows + 1 },
|
|
2100
|
+
(_, r) => y + (part.cropH / 2 - r / rows * part.cropH)
|
|
2101
|
+
);
|
|
2102
|
+
};
|
|
2103
|
+
const faceRows = rowsOf(face, face.y);
|
|
2104
|
+
if (hairFront === void 0) return faceRows;
|
|
2105
|
+
const top = face.y + face.cropH / 2;
|
|
2106
|
+
const bottom = face.y - face.cropH / 2;
|
|
2107
|
+
return [
|
|
2108
|
+
...faceRows,
|
|
2109
|
+
...rowsOf(hairFront, hairFront.centerY).map(
|
|
2110
|
+
(y) => Math.max(bottom, Math.min(top, y))
|
|
2111
|
+
)
|
|
2112
|
+
];
|
|
2113
|
+
}
|
|
2114
|
+
function plateGuardsOf(carriers, faceHalfWidth, profile, hairFront) {
|
|
2115
|
+
const plate = carriers.get("face");
|
|
2116
|
+
if (plate === void 0) {
|
|
2117
|
+
throw new Error("auto-rig: plateGuardsOf: the carriers have no face");
|
|
2118
|
+
}
|
|
2119
|
+
return {
|
|
2120
|
+
plate,
|
|
2121
|
+
edgeAt: profile === void 0 ? () => faceHalfWidth : profile.at,
|
|
2122
|
+
plateGuardRows: plateGuardRowsFor(plate.part, hairFront)
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
var TurnTargetError = class extends Error {
|
|
2126
|
+
constructor(message) {
|
|
2127
|
+
super(message);
|
|
2128
|
+
this.name = "TurnTargetError";
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
var DEFAULT_TURN_TARGETS = {
|
|
2132
|
+
eyeShift: 0.22,
|
|
2133
|
+
farEyeRatio: 0.67,
|
|
2134
|
+
silhouetteRatio: 1
|
|
2135
|
+
};
|
|
2136
|
+
var NOSE_SHIFT_SHARE = 1.36;
|
|
2137
|
+
var MOUTH_SHIFT_SHARE = 1.18;
|
|
2138
|
+
var TURN_RATIO_MAX = 1.5;
|
|
2139
|
+
var SILHOUETTE_RATIO_MIN = 0.5;
|
|
2140
|
+
function resolveTurnTargets(targets = {}) {
|
|
2141
|
+
const eyeShift = targets.eyeShift ?? DEFAULT_TURN_TARGETS.eyeShift;
|
|
2142
|
+
const check = (field, value, ok, expected) => {
|
|
2143
|
+
if (value === void 0) return;
|
|
2144
|
+
if (!Number.isFinite(value) || !ok(value)) {
|
|
2145
|
+
const derived = (field === "noseShift" || field === "mouthShift") && targets[field] === void 0;
|
|
2146
|
+
const from = derived ? `, derived from turnTargets.eyeShift (${eyeShift}),` : "";
|
|
2147
|
+
throw new TurnTargetError(
|
|
2148
|
+
`auto-rig: turnTargets.${field} (${value})${from} must be ${expected}`
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
const farEyeRatio = targets.farEyeRatio ?? DEFAULT_TURN_TARGETS.farEyeRatio;
|
|
2153
|
+
const silhouetteRatio = targets.silhouetteRatio ?? DEFAULT_TURN_TARGETS.silhouetteRatio;
|
|
2154
|
+
const noseShift = targets.noseShift ?? NOSE_SHIFT_SHARE * eyeShift;
|
|
2155
|
+
const mouthShift = targets.mouthShift ?? MOUTH_SHIFT_SHARE * eyeShift;
|
|
2156
|
+
const isShift = (v) => Math.abs(v) <= 1;
|
|
2157
|
+
const shiftRange = "a fraction of the head half-width, |value| <= 1";
|
|
2158
|
+
check("eyeShift", eyeShift, isShift, shiftRange);
|
|
2159
|
+
check(
|
|
2160
|
+
"farEyeRatio",
|
|
2161
|
+
farEyeRatio,
|
|
2162
|
+
(v) => v > 0 && v <= TURN_RATIO_MAX,
|
|
2163
|
+
`in (0, ${TURN_RATIO_MAX}]`
|
|
2164
|
+
);
|
|
2165
|
+
check(
|
|
2166
|
+
"silhouetteRatio",
|
|
2167
|
+
silhouetteRatio,
|
|
2168
|
+
(v) => v >= SILHOUETTE_RATIO_MIN && v <= TURN_RATIO_MAX,
|
|
2169
|
+
`in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
|
|
2170
|
+
);
|
|
2171
|
+
check("noseShift", noseShift, isShift, shiftRange);
|
|
2172
|
+
check("mouthShift", mouthShift, isShift, shiftRange);
|
|
2173
|
+
check("headHalfWidth", targets.headHalfWidth, (v) => v > 0, "positive");
|
|
2174
|
+
const defaulted = new Set(
|
|
2175
|
+
[
|
|
2176
|
+
"eyeShift",
|
|
2177
|
+
"farEyeRatio",
|
|
2178
|
+
"silhouetteRatio",
|
|
2179
|
+
"noseShift",
|
|
2180
|
+
"mouthShift"
|
|
2181
|
+
].filter((field) => targets[field] === void 0)
|
|
2182
|
+
);
|
|
2183
|
+
return {
|
|
2184
|
+
eyeShift,
|
|
2185
|
+
farEyeRatio,
|
|
2186
|
+
silhouetteRatio,
|
|
2187
|
+
noseShift,
|
|
2188
|
+
mouthShift,
|
|
2189
|
+
headHalfWidth: targets.headHalfWidth,
|
|
2190
|
+
defaulted
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function turnLandmarks(layers, carriers) {
|
|
2194
|
+
const markOf = (role) => {
|
|
2195
|
+
const layer = layers.find((l) => l.role === role);
|
|
2196
|
+
if (!layer) return void 0;
|
|
2197
|
+
const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
|
|
2198
|
+
const mark = { x: t.x, y: t.y, w: layer.cropW };
|
|
2199
|
+
const carrier = carriers?.get(role);
|
|
2200
|
+
if (carrier !== void 0) mark.carrier = carrier;
|
|
2201
|
+
return mark;
|
|
2202
|
+
};
|
|
2203
|
+
const pairOf = (left, right) => {
|
|
2204
|
+
const l = markOf(left);
|
|
2205
|
+
const r = markOf(right);
|
|
2206
|
+
return l && r ? [l, r] : void 0;
|
|
2207
|
+
};
|
|
2208
|
+
const one = (role) => {
|
|
2209
|
+
const m = markOf(role);
|
|
2210
|
+
return m ? [m] : [];
|
|
2211
|
+
};
|
|
2212
|
+
return {
|
|
2213
|
+
eye: pairOf("eye_L", "eye_R") ?? [],
|
|
2214
|
+
nose: one("nose"),
|
|
2215
|
+
mouth: one("mouth")
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
function eyeRowOf(landmarks, fallback) {
|
|
2219
|
+
const [l, r] = landmarks.eye;
|
|
2220
|
+
return landmarks.eye.length === 2 && l.y !== void 0 && r.y !== void 0 ? (l.y + r.y) / 2 : fallback;
|
|
2221
|
+
}
|
|
2222
|
+
var TURN_BISECT_STEPS = 40;
|
|
2223
|
+
var TURN_DEPTH_EPS = 1e-6;
|
|
2224
|
+
function landmarkLandingX(landmark, mapAt, shift, px, py) {
|
|
2225
|
+
if (landmark.carrier === void 0) return mapAt(py).mapX(px + shift);
|
|
2226
|
+
return carrierLandingX(
|
|
2227
|
+
landmark.carrier,
|
|
2228
|
+
(nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
|
|
2229
|
+
px,
|
|
2230
|
+
py
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
function familyReachPx(landmarks, plateEdgeX) {
|
|
2234
|
+
const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
|
|
2235
|
+
return Math.max(0, farEdge - plateEdgeX);
|
|
2236
|
+
}
|
|
2237
|
+
function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
|
|
2238
|
+
if (landmarks.length === 0) {
|
|
2239
|
+
throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
|
|
2240
|
+
}
|
|
2241
|
+
const cap = familyReachPx(landmarks, plateEdgeX) / unit;
|
|
2242
|
+
const achieved = (d) => landmarks.reduce(
|
|
2243
|
+
(sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
|
|
2244
|
+
0
|
|
2245
|
+
) / landmarks.length;
|
|
2246
|
+
const atCap = achieved(cap);
|
|
2247
|
+
const atRest = achieved(0);
|
|
2248
|
+
const attainable = [atCap, atRest];
|
|
2249
|
+
if (target > atRest + TURN_DEPTH_EPS)
|
|
2250
|
+
return { reached: false, depth: 0, achieved: atRest, attainable };
|
|
2251
|
+
if (target < atCap - TURN_DEPTH_EPS)
|
|
2252
|
+
return { reached: false, depth: cap, achieved: atCap, attainable };
|
|
2253
|
+
const clampedTarget = Math.min(Math.max(target, atCap), atRest);
|
|
2254
|
+
let lo = 0;
|
|
2255
|
+
let hi = cap;
|
|
2256
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2257
|
+
const mid = (lo + hi) / 2;
|
|
2258
|
+
if (achieved(mid) > clampedTarget) lo = mid;
|
|
2259
|
+
else hi = mid;
|
|
2260
|
+
}
|
|
2261
|
+
const depth = (lo + hi) / 2;
|
|
2262
|
+
return { reached: true, depth, achieved: achieved(depth), attainable };
|
|
2263
|
+
}
|
|
2264
|
+
var TURN_SWEEP_MAX_FACTOR = 8;
|
|
2265
|
+
var TURN_SWEEP_SAMPLES = 32;
|
|
2266
|
+
var HOLD_CLEARANCE = 1;
|
|
2267
|
+
var TURN_HOLD_SAMPLES = 16;
|
|
2268
|
+
var TURN_SETTLE_PASSES = 24;
|
|
2269
|
+
function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
|
|
2270
|
+
const local = x - faceCenterX;
|
|
2271
|
+
const dist = Math.abs(local);
|
|
2272
|
+
const side = Math.sign(local);
|
|
2273
|
+
const painted = edgeAt(y);
|
|
2274
|
+
if (dist <= painted) return plateLandingAt(deg, x, y);
|
|
2275
|
+
if (dist <= holdBase) {
|
|
2276
|
+
const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
|
|
2277
|
+
const u = (dist - painted) / (holdBase - painted);
|
|
2278
|
+
return inner + (faceCenterX + side * holdEdge - inner) * u;
|
|
2279
|
+
}
|
|
2280
|
+
return x + side * (holdEdge - holdBase);
|
|
2281
|
+
}
|
|
2282
|
+
function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
|
|
2283
|
+
const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
|
|
2284
|
+
const top = hairFront.centerY + hairFront.cropH / 2;
|
|
2285
|
+
return renderedLandingX(
|
|
2286
|
+
{
|
|
2287
|
+
x: hairFront.x,
|
|
2288
|
+
y: hairFront.centerY,
|
|
2289
|
+
cropW: hairFront.cropW,
|
|
2290
|
+
cropH: hairFront.cropH,
|
|
2291
|
+
cols,
|
|
2292
|
+
rows,
|
|
2293
|
+
scaleX: 1,
|
|
2294
|
+
scaleY: 1,
|
|
2295
|
+
rotation: 0
|
|
2296
|
+
},
|
|
2297
|
+
(vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
|
|
2298
|
+
x,
|
|
2299
|
+
y
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
function shellTravelCap(faceCenterX, edgeAt, plateGuardRows, askedTravel, bendOnlyLandingAt, holdEdgeAt) {
|
|
2303
|
+
let cap = Infinity;
|
|
2304
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2305
|
+
if (deg === 0) continue;
|
|
2306
|
+
const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
|
|
2307
|
+
const towardReach = plateSideReachAt(
|
|
2308
|
+
bendOnlyLandingAt,
|
|
2309
|
+
faceCenterX,
|
|
2310
|
+
edgeAt,
|
|
2311
|
+
plateGuardRows,
|
|
2312
|
+
deg,
|
|
2313
|
+
slideSign
|
|
2314
|
+
);
|
|
2315
|
+
cap = Math.min(
|
|
2316
|
+
cap,
|
|
2317
|
+
Math.max(0, holdEdgeAt(deg, slideSign) - HOLD_CLEARANCE - towardReach) * HEAD_TURN_MAX_DEG / Math.abs(deg)
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
return cap;
|
|
2321
|
+
}
|
|
2322
|
+
function aitken(x0, x1, x2, cap) {
|
|
2323
|
+
const d1 = x1 - x0;
|
|
2324
|
+
const d2 = x2 - x1;
|
|
2325
|
+
const denominator = d2 - d1;
|
|
2326
|
+
if (denominator === 0) return x2;
|
|
2327
|
+
const x = x2 - d2 * d2 / denominator;
|
|
2328
|
+
return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
|
|
2329
|
+
}
|
|
2330
|
+
function evaluateTurnCandidate(ctx, radius) {
|
|
2331
|
+
const surfaceAt = (travel) => turnSurface({
|
|
2332
|
+
faceCenterX: ctx.faceCenterX,
|
|
2333
|
+
faceCenterY: ctx.faceCenterY,
|
|
2334
|
+
radius,
|
|
2335
|
+
travel,
|
|
2336
|
+
nodRadius: ctx.nodRadius,
|
|
2337
|
+
lattice: ctx.lattice,
|
|
2338
|
+
profile: ctx.profile,
|
|
2339
|
+
eyeRowY: ctx.eyeRowY
|
|
2340
|
+
});
|
|
2341
|
+
const bendOnlyLandingAt = plateLandingOn(ctx.plate, rowMapsOf(surfaceAt(0)));
|
|
2342
|
+
const measuredEdgeAt = (side) => {
|
|
2343
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2344
|
+
if (candidates === void 0 || candidates.length === 0) return void 0;
|
|
2345
|
+
const xs = candidates.map((c) => c.x);
|
|
2346
|
+
return side < 0 ? Math.min(...xs) : Math.max(...xs);
|
|
2347
|
+
};
|
|
2348
|
+
const shellHalfWidthAt = (side) => {
|
|
2349
|
+
const edge = measuredEdgeAt(side);
|
|
2350
|
+
return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
|
|
2351
|
+
};
|
|
2352
|
+
const unit = headTurnParallaxUnit(radius);
|
|
2353
|
+
const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
|
|
2354
|
+
const evaluateHold = (ratio, depths2) => {
|
|
2355
|
+
const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
|
|
2356
|
+
const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
|
|
2357
|
+
ctx.travel,
|
|
2358
|
+
shellTravelCap(
|
|
2359
|
+
ctx.faceCenterX,
|
|
2360
|
+
ctx.edgeAt,
|
|
2361
|
+
ctx.plateGuardRows,
|
|
2362
|
+
ctx.travel,
|
|
2363
|
+
bendOnlyLandingAt,
|
|
2364
|
+
(deg, side) => {
|
|
2365
|
+
const rest = shellHalfWidthAt(side);
|
|
2366
|
+
return Math.min(rest, holdEdgeFrom(rest)(deg));
|
|
2367
|
+
}
|
|
2368
|
+
)
|
|
2369
|
+
);
|
|
2370
|
+
const surface = surfaceAt(travel);
|
|
2371
|
+
const rowMaps = rowMapsOf(surface);
|
|
2372
|
+
const mapAt = (y) => rowMaps(-HEAD_TURN_MAX_DEG, y);
|
|
2373
|
+
const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
|
|
2374
|
+
const holdBase = ctx.headHalfWidth ?? plateReach(
|
|
2375
|
+
plateLandingAt,
|
|
2376
|
+
ctx.faceCenterX,
|
|
2377
|
+
ctx.edgeAt,
|
|
2378
|
+
ctx.plateGuardRows
|
|
2379
|
+
) + HOLD_CLEARANCE;
|
|
2380
|
+
const holdEdgeAt = holdEdgeFrom(holdBase);
|
|
2381
|
+
let foldLowerBound = -Infinity;
|
|
2382
|
+
let folds = false;
|
|
2383
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2384
|
+
if (deg === 0) continue;
|
|
2385
|
+
const reach = plateReachAt(
|
|
2386
|
+
plateLandingAt,
|
|
2387
|
+
ctx.faceCenterX,
|
|
2388
|
+
ctx.edgeAt,
|
|
2389
|
+
ctx.plateGuardRows,
|
|
2390
|
+
deg
|
|
2391
|
+
);
|
|
2392
|
+
foldLowerBound = Math.max(
|
|
2393
|
+
foldLowerBound,
|
|
2394
|
+
1 + ((reach + HOLD_CLEARANCE) / holdBase - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
|
|
2395
|
+
);
|
|
2396
|
+
if (holdEdgeAt(deg) <= reach) folds = true;
|
|
2397
|
+
}
|
|
2398
|
+
const restAt = (side) => {
|
|
2399
|
+
const edge = measuredEdgeAt(side);
|
|
2400
|
+
if (edge !== void 0) return edge;
|
|
2401
|
+
return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
|
|
2402
|
+
};
|
|
2403
|
+
const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
|
|
2404
|
+
x,
|
|
2405
|
+
ctx.eyeRowY,
|
|
2406
|
+
ctx.hairFrontSilhouette,
|
|
2407
|
+
(vx, vy) => hairFrontHoldTarget(
|
|
2408
|
+
vx,
|
|
2409
|
+
vy,
|
|
2410
|
+
-HEAD_TURN_MAX_DEG,
|
|
2411
|
+
ctx.faceCenterX,
|
|
2412
|
+
plateLandingAt,
|
|
2413
|
+
ctx.edgeAt,
|
|
2414
|
+
holdBase,
|
|
2415
|
+
holdEdgeAt(-HEAD_TURN_MAX_DEG)
|
|
2416
|
+
),
|
|
2417
|
+
leadTipShift
|
|
2418
|
+
);
|
|
2419
|
+
const landingOfRole = (role, x) => {
|
|
2420
|
+
if (role === "hair_front") return hairFrontAt(x);
|
|
2421
|
+
if (role === "hair_back") {
|
|
2422
|
+
return x;
|
|
2423
|
+
}
|
|
2424
|
+
if (role === "body") {
|
|
2425
|
+
return x - BODY_TURN_FOLLOW * travel;
|
|
2426
|
+
}
|
|
2427
|
+
if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
|
|
2428
|
+
const carrier = ctx.carriers.get(role);
|
|
2429
|
+
if (carrier === void 0) {
|
|
2430
|
+
throw new Error(
|
|
2431
|
+
`auto-rig: evaluateTurnCandidate: headEdges names "${role}", which this layer set has no layer for`
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
|
|
2435
|
+
return carrierLandingX(
|
|
2436
|
+
carrier,
|
|
2437
|
+
(nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
|
|
2438
|
+
x,
|
|
2439
|
+
ctx.eyeRowY
|
|
2440
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
throw new Error(
|
|
2443
|
+
`auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
|
|
2444
|
+
);
|
|
2445
|
+
};
|
|
2446
|
+
const landingAt = (side) => {
|
|
2447
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2448
|
+
if (candidates === void 0 || candidates.length === 0) {
|
|
2449
|
+
return hairFrontAt(restAt(side));
|
|
2450
|
+
}
|
|
2451
|
+
const landings = candidates.map((c) => landingOfRole(c.role, c.x));
|
|
2452
|
+
return side < 0 ? Math.min(...landings) : Math.max(...landings);
|
|
2453
|
+
};
|
|
2454
|
+
return {
|
|
2455
|
+
ratio,
|
|
2456
|
+
travel,
|
|
2457
|
+
mapAt,
|
|
2458
|
+
surface,
|
|
2459
|
+
holdBase,
|
|
2460
|
+
holdEdgeAt,
|
|
2461
|
+
folds,
|
|
2462
|
+
foldLowerBound,
|
|
2463
|
+
// How far the silhouette's own centre drifts off the face centre at full
|
|
2464
|
+
// turn, against how far it already sat off centre at rest (zero unless
|
|
2465
|
+
// hair_front's crop is itself off-centre) — the held shell keeps its own
|
|
2466
|
+
// place while the face slides inside it, so this is the drift the hold
|
|
2467
|
+
// and the bangs' lead leave behind, and the slide itself shows up in the
|
|
2468
|
+
// eye cue instead of cancelling out of it.
|
|
2469
|
+
// `measure_turn_reference` reads the eye pair against THIS moved centre,
|
|
2470
|
+
// not the face centre, so it is what the eye cue is corrected by.
|
|
2471
|
+
silhouetteCenterShift: (landingAt(1) + landingAt(-1)) / 2 - (restAt(1) + restAt(-1)) / 2,
|
|
2472
|
+
// What a render actually shows: the real landing span over the real rest
|
|
2473
|
+
// span, the same pair silhouetteCenterShift reads — the measure the
|
|
2474
|
+
// hold is FITTED against, see this function's own doc.
|
|
2475
|
+
renderedSilhouetteRatio: (landingAt(1) - landingAt(-1)) / (restAt(1) - restAt(-1)),
|
|
2476
|
+
restSpan: restAt(1) - restAt(-1)
|
|
2477
|
+
};
|
|
2478
|
+
};
|
|
2479
|
+
const holdRange = (depths2) => {
|
|
2480
|
+
const ceiling = evaluateHold(TURN_RATIO_MAX, depths2);
|
|
2481
|
+
if (ceiling.folds) return void 0;
|
|
2482
|
+
let floor = evaluateHold(
|
|
2483
|
+
Math.min(ceiling.foldLowerBound, ceiling.ratio),
|
|
2484
|
+
depths2
|
|
2485
|
+
);
|
|
2486
|
+
if (floor.folds) {
|
|
2487
|
+
let lo = floor;
|
|
2488
|
+
let hi = ceiling;
|
|
2489
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2490
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
|
|
2491
|
+
if (mid.folds) lo = mid;
|
|
2492
|
+
else hi = mid;
|
|
2493
|
+
}
|
|
2494
|
+
floor = hi;
|
|
2495
|
+
}
|
|
2496
|
+
const samples = [floor];
|
|
2497
|
+
for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
|
|
2498
|
+
const sample = evaluateHold(
|
|
2499
|
+
floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES,
|
|
2500
|
+
depths2
|
|
2501
|
+
);
|
|
2502
|
+
if (!sample.folds) samples.push(sample);
|
|
2503
|
+
}
|
|
2504
|
+
samples.push(ceiling);
|
|
2505
|
+
return samples;
|
|
2506
|
+
};
|
|
2507
|
+
const silhouetteMiss = (span) => {
|
|
2508
|
+
const lower = Math.max(span?.[0] ?? Infinity, SILHOUETTE_RATIO_MIN);
|
|
2509
|
+
const upper = Math.min(span?.[1] ?? -Infinity, TURN_RATIO_MAX);
|
|
2510
|
+
return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
|
|
2511
|
+
};
|
|
2512
|
+
const asked = ctx.silhouetteRatio;
|
|
2513
|
+
const fitHold = (depths2) => {
|
|
2514
|
+
const natural = evaluateHold(asked, depths2);
|
|
2515
|
+
const eps = TURN_DEPTH_EPS / natural.restSpan;
|
|
2516
|
+
if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
|
|
2517
|
+
return { hold: natural, silhouetteClamped: false };
|
|
2518
|
+
}
|
|
2519
|
+
const samples = holdRange(depths2);
|
|
2520
|
+
if (samples === void 0) return void 0;
|
|
2521
|
+
const rendered = samples.map((h) => h.renderedSilhouetteRatio);
|
|
2522
|
+
const span = [
|
|
2523
|
+
Math.min(...rendered),
|
|
2524
|
+
Math.max(...rendered)
|
|
2525
|
+
];
|
|
2526
|
+
const nearer = (a, b) => {
|
|
2527
|
+
const ea = Math.abs(a.renderedSilhouetteRatio - asked);
|
|
2528
|
+
const eb = Math.abs(b.renderedSilhouetteRatio - asked);
|
|
2529
|
+
if (ea <= eps && eb <= eps) {
|
|
2530
|
+
return Math.abs(a.ratio - asked) <= Math.abs(b.ratio - asked) ? a : b;
|
|
2531
|
+
}
|
|
2532
|
+
return ea <= eb ? a : b;
|
|
2533
|
+
};
|
|
2534
|
+
if (asked < span[0] - eps || asked > span[1] + eps) {
|
|
2535
|
+
const wanted = asked < span[0] ? span[0] : span[1];
|
|
2536
|
+
const hold = samples.reduce(
|
|
2537
|
+
(a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
|
|
2538
|
+
);
|
|
2539
|
+
return { hold, silhouetteClamped: true, span };
|
|
2540
|
+
}
|
|
2541
|
+
let best = samples.reduce(nearer);
|
|
2542
|
+
let lo;
|
|
2543
|
+
let hi;
|
|
2544
|
+
for (let i = 0; i + 1 < samples.length; i++) {
|
|
2545
|
+
const a = samples[i].renderedSilhouetteRatio - asked;
|
|
2546
|
+
const b = samples[i + 1].renderedSilhouetteRatio - asked;
|
|
2547
|
+
if (a * b <= 0) {
|
|
2548
|
+
lo = samples[i];
|
|
2549
|
+
hi = samples[i + 1];
|
|
2550
|
+
break;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
|
|
2554
|
+
if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
|
|
2555
|
+
break;
|
|
2556
|
+
}
|
|
2557
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
|
|
2558
|
+
if (!mid.folds) best = nearer(best, mid);
|
|
2559
|
+
if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
|
|
2560
|
+
lo = mid;
|
|
2561
|
+
} else {
|
|
2562
|
+
hi = mid;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
return { hold: best, silhouetteClamped: false };
|
|
2566
|
+
};
|
|
2567
|
+
let depths = { eye: 0, nose: 0, mouth: 0 };
|
|
2568
|
+
let seed;
|
|
2569
|
+
for (let pass = 0; ; pass++) {
|
|
2570
|
+
const fitted = fitHold(depths);
|
|
2571
|
+
if (fitted === void 0) return silhouetteMiss();
|
|
2572
|
+
const { hold, silhouetteClamped, span } = fitted;
|
|
2573
|
+
if (hold.folds) {
|
|
2574
|
+
throw new Error(
|
|
2575
|
+
"auto-rig: evaluateTurnCandidate: the hold it settled on folds"
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2578
|
+
const eye = solveTurnDepthSigned(
|
|
2579
|
+
hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
|
|
2580
|
+
ctx.landmarks.eye,
|
|
2581
|
+
unit,
|
|
2582
|
+
hold.mapAt,
|
|
2583
|
+
ctx.plateEdgeX
|
|
2584
|
+
);
|
|
2585
|
+
const features = solveFeatureDepths(
|
|
2586
|
+
ctx,
|
|
2587
|
+
(-eye.achieved + hold.silhouetteCenterShift) / ctx.hh,
|
|
2588
|
+
unit,
|
|
2589
|
+
hold.mapAt,
|
|
2590
|
+
hold.silhouetteCenterShift
|
|
2591
|
+
);
|
|
2592
|
+
const solved = {
|
|
2593
|
+
eye: eye.depth,
|
|
2594
|
+
nose: features.nose.depth,
|
|
2595
|
+
mouth: features.mouth.depth
|
|
2596
|
+
};
|
|
2597
|
+
const settled = evaluateHold(hold.ratio, solved);
|
|
2598
|
+
if (Math.abs(settled.silhouetteCenterShift - hold.silhouetteCenterShift) > TURN_DEPTH_EPS || Math.abs(settled.renderedSilhouetteRatio - hold.renderedSilhouetteRatio) * hold.restSpan > TURN_DEPTH_EPS) {
|
|
2599
|
+
if (pass + 1 >= TURN_SETTLE_PASSES) return { blocked: "settle" };
|
|
2600
|
+
if (seed === void 0) {
|
|
2601
|
+
seed = depths;
|
|
2602
|
+
depths = solved;
|
|
2603
|
+
} else {
|
|
2604
|
+
const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
|
|
2605
|
+
depths = {
|
|
2606
|
+
eye: aitken(
|
|
2607
|
+
seed.eye,
|
|
2608
|
+
depths.eye,
|
|
2609
|
+
solved.eye,
|
|
2610
|
+
capOf(ctx.landmarks.eye)
|
|
2611
|
+
),
|
|
2612
|
+
nose: aitken(
|
|
2613
|
+
seed.nose,
|
|
2614
|
+
depths.nose,
|
|
2615
|
+
solved.nose,
|
|
2616
|
+
capOf(ctx.landmarks.nose)
|
|
2617
|
+
),
|
|
2618
|
+
mouth: aitken(
|
|
2619
|
+
seed.mouth,
|
|
2620
|
+
depths.mouth,
|
|
2621
|
+
solved.mouth,
|
|
2622
|
+
capOf(ctx.landmarks.mouth)
|
|
2623
|
+
)
|
|
2624
|
+
};
|
|
2625
|
+
seed = void 0;
|
|
2626
|
+
}
|
|
2627
|
+
continue;
|
|
2628
|
+
}
|
|
2629
|
+
const {
|
|
2630
|
+
travel,
|
|
2631
|
+
mapAt,
|
|
2632
|
+
surface,
|
|
2633
|
+
holdBase,
|
|
2634
|
+
holdEdgeAt,
|
|
2635
|
+
silhouetteCenterShift,
|
|
2636
|
+
renderedSilhouetteRatio
|
|
2637
|
+
} = settled;
|
|
2638
|
+
if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
|
|
2639
|
+
return silhouetteMiss(span);
|
|
2640
|
+
}
|
|
2641
|
+
if (!eye.reached && !ctx.clampEyeShift) {
|
|
2642
|
+
return {
|
|
2643
|
+
blocked: "eyeShift",
|
|
2644
|
+
// eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
|
|
2645
|
+
// a negative lower bound here would advertise a value the field's own
|
|
2646
|
+
// contract already rules out; 0 is the true floor.
|
|
2647
|
+
offeredShift: [
|
|
2648
|
+
Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
|
|
2649
|
+
(-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
|
|
2650
|
+
]
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
const eyeShiftPx = -eye.depth * unit;
|
|
2654
|
+
const scaleOf = (l) => (landmarkLandingX(l, mapAt, eyeShiftPx, l.x + l.w / 2, l.y ?? 0) - landmarkLandingX(l, mapAt, eyeShiftPx, l.x - l.w / 2, l.y ?? 0)) / l.w;
|
|
2655
|
+
const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
|
|
2656
|
+
const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
|
|
2657
|
+
return {
|
|
2658
|
+
radius,
|
|
2659
|
+
ratio: scaleOf(far) / scaleOf(near),
|
|
2660
|
+
unit,
|
|
2661
|
+
travel,
|
|
2662
|
+
mapAt,
|
|
2663
|
+
surface,
|
|
2664
|
+
holdBase,
|
|
2665
|
+
holdEdgeAt,
|
|
2666
|
+
eye,
|
|
2667
|
+
nose: features.nose,
|
|
2668
|
+
mouth: features.mouth,
|
|
2669
|
+
silhouetteClamped,
|
|
2670
|
+
silhouetteCenterShift,
|
|
2671
|
+
renderedSilhouetteRatio
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
function sweepTurnRadii(ctx) {
|
|
2676
|
+
const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
2677
|
+
const maxRadius = ctx.faceHalfWidth * TURN_SWEEP_MAX_FACTOR;
|
|
2678
|
+
const candidates = [];
|
|
2679
|
+
let heldRatioLower = Infinity;
|
|
2680
|
+
let heldRatioUpper = -Infinity;
|
|
2681
|
+
let offeredShift;
|
|
2682
|
+
let unsettled = 0;
|
|
2683
|
+
for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
|
|
2684
|
+
const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
|
|
2685
|
+
const result = evaluateTurnCandidate(ctx, radius);
|
|
2686
|
+
if (!("blocked" in result)) {
|
|
2687
|
+
candidates.push({ ...result, sweepIndex: i });
|
|
2688
|
+
} else if (result.blocked === "silhouetteRatio") {
|
|
2689
|
+
heldRatioLower = Math.min(heldRatioLower, result.lower);
|
|
2690
|
+
heldRatioUpper = Math.max(heldRatioUpper, result.upper);
|
|
2691
|
+
} else if (result.blocked === "settle") {
|
|
2692
|
+
unsettled++;
|
|
2693
|
+
} else {
|
|
2694
|
+
offeredShift = offeredShift ? [
|
|
2695
|
+
Math.min(offeredShift[0], result.offeredShift[0]),
|
|
2696
|
+
Math.max(offeredShift[1], result.offeredShift[1])
|
|
2697
|
+
] : result.offeredShift;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return {
|
|
2701
|
+
candidates,
|
|
2702
|
+
heldRatioLower,
|
|
2703
|
+
heldRatioUpper,
|
|
2704
|
+
offeredShift,
|
|
2705
|
+
unsettled
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function ratioClusters(candidates) {
|
|
2709
|
+
const clusters = [];
|
|
2710
|
+
let run = [];
|
|
2711
|
+
const close = () => {
|
|
2712
|
+
if (run.length === 0) return;
|
|
2713
|
+
const ratios = run.map((c) => c.ratio);
|
|
2714
|
+
clusters.push({
|
|
2715
|
+
candidates: run,
|
|
2716
|
+
ratios: [Math.min(...ratios), Math.max(...ratios)]
|
|
2717
|
+
});
|
|
2718
|
+
run = [];
|
|
2719
|
+
};
|
|
2720
|
+
for (const candidate of candidates) {
|
|
2721
|
+
const previous = run[run.length - 1];
|
|
2722
|
+
if (previous !== void 0 && candidate.sweepIndex !== previous.sweepIndex + 1) {
|
|
2723
|
+
close();
|
|
2724
|
+
}
|
|
2725
|
+
run.push(candidate);
|
|
2726
|
+
}
|
|
2727
|
+
close();
|
|
2728
|
+
return clusters;
|
|
2729
|
+
}
|
|
2730
|
+
function fitTurnRadius(ctx, candidates, target) {
|
|
2731
|
+
let lo = candidates[0];
|
|
2732
|
+
let hi = candidates[candidates.length - 1];
|
|
2733
|
+
for (let i = candidates.length - 2; i >= 0; i--) {
|
|
2734
|
+
if ((candidates[i].ratio - target) * (candidates[i + 1].ratio - target) <= 0) {
|
|
2735
|
+
lo = candidates[i];
|
|
2736
|
+
hi = candidates[i + 1];
|
|
2737
|
+
break;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2741
|
+
const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
|
|
2742
|
+
if ("blocked" in mid) break;
|
|
2743
|
+
if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
|
|
2744
|
+
else lo = mid;
|
|
2745
|
+
}
|
|
2746
|
+
return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
|
|
2747
|
+
}
|
|
2748
|
+
function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
|
|
2749
|
+
const solve = (measured, marks, share) => solveTurnDepthSigned(
|
|
2750
|
+
silhouetteCenterShift - (measured ?? Math.abs(share * eyeShift)) * ctx.hh,
|
|
2751
|
+
marks,
|
|
2752
|
+
unit,
|
|
2753
|
+
mapAt,
|
|
2754
|
+
ctx.plateEdgeX
|
|
2755
|
+
);
|
|
2756
|
+
return {
|
|
2757
|
+
nose: solve(ctx.noseShift, ctx.landmarks.nose, NOSE_SHIFT_SHARE),
|
|
2758
|
+
mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
|
|
2762
|
+
if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
|
|
2763
|
+
throw new TurnTargetError(
|
|
2764
|
+
`auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
const eyeRowY = eyeRowOf(landmarks, faceCenterY);
|
|
2768
|
+
const ctx = {
|
|
2769
|
+
landmarks,
|
|
2770
|
+
lattice,
|
|
2771
|
+
faceCenterX,
|
|
2772
|
+
faceCenterY,
|
|
2773
|
+
nodRadius,
|
|
2774
|
+
eyeRowY,
|
|
2775
|
+
faceHalfWidth,
|
|
2776
|
+
// The slide's hard stop is the CROP's edge, profile or not: the far eye
|
|
2777
|
+
// may sit on the plate's transparent margin, as it does on the hero.
|
|
2778
|
+
plateEdgeX: faceCenterX - faceHalfWidth,
|
|
2779
|
+
carriers,
|
|
2780
|
+
profile,
|
|
2781
|
+
...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
|
|
2782
|
+
// What this plate asks the turn for; each candidate caps it to its own
|
|
2783
|
+
// shell — see TurnSolveContext.travel.
|
|
2784
|
+
travel: headTurnTravel(faceHalfWidth),
|
|
2785
|
+
headHalfWidth: targets.headHalfWidth,
|
|
2786
|
+
// The pixels the shift fractions are fractions of. Without a measured head
|
|
2787
|
+
// the face plate stands in for it — see TurnTargets.headHalfWidth.
|
|
2788
|
+
hh: targets.headHalfWidth ?? faceHalfWidth,
|
|
2789
|
+
silhouetteRatio: targets.silhouetteRatio,
|
|
2790
|
+
// The sign is ignored (see TurnTargets.eyeShift) — taken here, once, so
|
|
2791
|
+
// every downstream use already has the magnitude.
|
|
2792
|
+
eyeShift: Math.abs(targets.eyeShift),
|
|
2793
|
+
clampEyeShift: targets.defaulted.has("eyeShift"),
|
|
2794
|
+
noseShift: targets.defaulted.has("noseShift") ? void 0 : Math.abs(targets.noseShift),
|
|
2795
|
+
mouthShift: targets.defaulted.has("mouthShift") ? void 0 : Math.abs(targets.mouthShift),
|
|
2796
|
+
clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
|
|
2797
|
+
hairFrontSilhouette: hairFront,
|
|
2798
|
+
// A companion to a MEASURED headHalfWidth only — see its own doc.
|
|
2799
|
+
headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
|
|
2800
|
+
};
|
|
2801
|
+
const clamped = [];
|
|
2802
|
+
const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
|
|
2803
|
+
const pass = sweepTurnRadii(ctx);
|
|
2804
|
+
if (pass.candidates.length === 0) {
|
|
2805
|
+
if (pass.unsettled > 0) {
|
|
2806
|
+
const refused = TURN_SWEEP_SAMPLES - pass.unsettled;
|
|
2807
|
+
throw new TurnTargetError(
|
|
2808
|
+
`auto-rig: headEdges: no sampled radius carries the turn \u2014 the silhouette and the depths its headEdges owners ride on did not settle within ${TURN_SETTLE_PASSES} passes at ${pass.unsettled} of the ${TURN_SWEEP_SAMPLES} radii${refused > 0 ? `, and the targets refused the other ${refused}` : ""}`
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
return pass.offeredShift ? {
|
|
2812
|
+
unreachable: true,
|
|
2813
|
+
field: "eyeShift",
|
|
2814
|
+
value: targets.eyeShift,
|
|
2815
|
+
attainable: pass.offeredShift
|
|
2816
|
+
} : {
|
|
2817
|
+
unreachable: true,
|
|
2818
|
+
field: "silhouetteRatio",
|
|
2819
|
+
value: targets.silhouetteRatio,
|
|
2820
|
+
// Two different bounds, not one number twice, and computed the
|
|
2821
|
+
// same way regardless of which ratio was actually rejected: both
|
|
2822
|
+
// are ratios a RENDER could have shown (the measure the caller's
|
|
2823
|
+
// own number came from), the narrowest and the widest any radius
|
|
2824
|
+
// could be fitted to, with every ratio between them one it can be
|
|
2825
|
+
// fitted to exactly; see evaluateTurnCandidate, which clips each
|
|
2826
|
+
// radius' own range to the domain `resolveTurnTargets` accepts
|
|
2827
|
+
// before it gets here. Absent when that left every radius with
|
|
2828
|
+
// nothing: no ratio a caller is allowed to ask for renders at all.
|
|
2829
|
+
attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
const clusters = ratioClusters(pass.candidates);
|
|
2833
|
+
const distanceTo = (c) => Math.max(
|
|
2834
|
+
c.ratios[0] - targets.farEyeRatio,
|
|
2835
|
+
targets.farEyeRatio - c.ratios[1],
|
|
2836
|
+
0
|
|
2837
|
+
);
|
|
2838
|
+
let farEyeRatio = targets.farEyeRatio;
|
|
2839
|
+
let cluster = clusters.find(
|
|
2840
|
+
(c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
|
|
2841
|
+
);
|
|
2842
|
+
if (cluster === void 0) {
|
|
2843
|
+
cluster = clusters.reduce(
|
|
2844
|
+
(a, b) => distanceTo(b) < distanceTo(a) ? b : a
|
|
2845
|
+
);
|
|
2846
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2847
|
+
return {
|
|
2848
|
+
unreachable: true,
|
|
2849
|
+
field: "farEyeRatio",
|
|
2850
|
+
value: targets.farEyeRatio,
|
|
2851
|
+
attainable: cluster.ratios
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
|
|
2855
|
+
clamped.push("farEyeRatio");
|
|
2856
|
+
}
|
|
2857
|
+
const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
|
|
2858
|
+
if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
|
|
2859
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2860
|
+
return {
|
|
2861
|
+
unreachable: true,
|
|
2862
|
+
field: "farEyeRatio",
|
|
2863
|
+
value: targets.farEyeRatio,
|
|
2864
|
+
attainable: cluster.ratios
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
|
|
2868
|
+
}
|
|
2869
|
+
if (!best.eye.reached) clamped.push("eyeShift");
|
|
2870
|
+
if (best.silhouetteClamped) clamped.push("silhouetteRatio");
|
|
2871
|
+
const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
|
|
2872
|
+
for (const [field, solution] of [
|
|
2873
|
+
["noseShift", best.nose],
|
|
2874
|
+
["mouthShift", best.mouth]
|
|
2875
|
+
]) {
|
|
2876
|
+
if (solution.reached) continue;
|
|
2877
|
+
if (!targets.defaulted.has(field)) {
|
|
2878
|
+
return {
|
|
2879
|
+
unreachable: true,
|
|
2880
|
+
field,
|
|
2881
|
+
value: targets[field],
|
|
2882
|
+
// noseShift/mouthShift are MAGNITUDES too (see solveFeatureDepths),
|
|
2883
|
+
// so a negative lower bound here would advertise a value the field's
|
|
2884
|
+
// own contract already rules out; 0 is the true floor.
|
|
2885
|
+
attainable: [
|
|
2886
|
+
Math.max(
|
|
2887
|
+
0,
|
|
2888
|
+
(-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
|
|
2889
|
+
),
|
|
2890
|
+
(-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
|
|
2891
|
+
]
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
clamped.push(field);
|
|
2895
|
+
}
|
|
2896
|
+
return {
|
|
2897
|
+
unreachable: false,
|
|
2898
|
+
radius: best.radius,
|
|
2899
|
+
holdBase: best.holdBase,
|
|
2900
|
+
holdEdgeAt: best.holdEdgeAt,
|
|
2901
|
+
travel: best.travel,
|
|
2902
|
+
surface: best.surface,
|
|
2903
|
+
depths: {
|
|
2904
|
+
eye: best.eye.depth,
|
|
2905
|
+
nose: best.nose.depth,
|
|
2906
|
+
mouth: best.mouth.depth
|
|
2907
|
+
},
|
|
2908
|
+
achieved: {
|
|
2909
|
+
eyeShift,
|
|
2910
|
+
farEyeRatio: best.ratio,
|
|
2911
|
+
silhouetteRatio: best.renderedSilhouetteRatio
|
|
2912
|
+
},
|
|
2913
|
+
clamped
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
1839
2916
|
var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
|
|
1840
2917
|
var HAIR_FRONT_DEPTH = 0.1;
|
|
1841
|
-
var HAIR_BACK_DEPTH = -0.08;
|
|
1842
2918
|
var NOD_TRAVEL = 30;
|
|
1843
2919
|
var NOD_BEND = 0.5;
|
|
1844
2920
|
var HAIR_BACK_NOD_DEPTH = -0.135;
|
|
1845
2921
|
function headNodParallaxUnit(gridHalfHeight) {
|
|
1846
2922
|
return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
|
|
1847
2923
|
}
|
|
1848
|
-
var
|
|
2924
|
+
var FEATURE_NOD_DEPTH = {
|
|
1849
2925
|
eye: 0.04,
|
|
1850
2926
|
iris: 0.04,
|
|
1851
2927
|
pupil: 0.04,
|
|
@@ -1857,34 +2933,30 @@ var FEATURE_DEPTH = {
|
|
|
1857
2933
|
mouth_open: 0.04,
|
|
1858
2934
|
nose: 0.08
|
|
1859
2935
|
};
|
|
1860
|
-
var HAIR_FRONT_NOD_DEPTH =
|
|
2936
|
+
var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
|
|
1861
2937
|
function roleFamily(role) {
|
|
1862
2938
|
return role.replace(/_[LR]$/, "");
|
|
1863
2939
|
}
|
|
2940
|
+
function turnFamily(role) {
|
|
2941
|
+
const family = roleFamily(role);
|
|
2942
|
+
if (family === "nose") return "nose";
|
|
2943
|
+
if (family === "mouth" || family === "mouth_open") return "mouth";
|
|
2944
|
+
return "eye";
|
|
2945
|
+
}
|
|
1864
2946
|
function featureParallaxBindings(role, options) {
|
|
1865
2947
|
if (!options.hasNose) return [];
|
|
1866
|
-
const
|
|
1867
|
-
if (
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
parameter: StandardParameter.AngleX,
|
|
1873
|
-
channel: "translateX",
|
|
1874
|
-
from: -shiftX,
|
|
1875
|
-
to: shiftX
|
|
1876
|
-
});
|
|
1877
|
-
}
|
|
1878
|
-
const shiftY = depth * (options.parallaxUnitY ?? 0);
|
|
1879
|
-
if (shiftY !== 0) {
|
|
1880
|
-
bindings.push({
|
|
2948
|
+
const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
|
|
2949
|
+
if (nodDepth === void 0) return [];
|
|
2950
|
+
const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
|
|
2951
|
+
if (shiftY === 0) return [];
|
|
2952
|
+
return [
|
|
2953
|
+
{
|
|
1881
2954
|
parameter: StandardParameter.AngleY,
|
|
1882
2955
|
channel: "translateY",
|
|
1883
2956
|
from: -shiftY,
|
|
1884
2957
|
to: shiftY
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
return bindings;
|
|
2958
|
+
}
|
|
2959
|
+
];
|
|
1888
2960
|
}
|
|
1889
2961
|
function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
1890
2962
|
return [
|
|
@@ -1992,31 +3064,17 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
|
|
|
1992
3064
|
];
|
|
1993
3065
|
}
|
|
1994
3066
|
}
|
|
1995
|
-
if (role === "
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
if (shiftX !== 0) {
|
|
2001
|
-
parallax.push({
|
|
2002
|
-
parameter: StandardParameter.AngleX,
|
|
2003
|
-
channel: "translateX",
|
|
2004
|
-
from: -shiftX,
|
|
2005
|
-
to: shiftX
|
|
2006
|
-
});
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
|
|
2010
|
-
const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2011
|
-
if (shiftY !== 0) {
|
|
2012
|
-
parallax.push({
|
|
3067
|
+
if (role === "hair_back") {
|
|
3068
|
+
const shiftY = HAIR_BACK_NOD_DEPTH * (options.parallaxUnitY ?? 0);
|
|
3069
|
+
if (shiftY === 0) return [];
|
|
3070
|
+
return [
|
|
3071
|
+
{
|
|
2013
3072
|
parameter: StandardParameter.AngleY,
|
|
2014
3073
|
channel: "translateY",
|
|
2015
3074
|
from: -shiftY,
|
|
2016
3075
|
to: shiftY
|
|
2017
|
-
}
|
|
2018
|
-
|
|
2019
|
-
return parallax;
|
|
3076
|
+
}
|
|
3077
|
+
];
|
|
2020
3078
|
}
|
|
2021
3079
|
return [];
|
|
2022
3080
|
}
|
|
@@ -2039,40 +3097,106 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
2039
3097
|
]
|
|
2040
3098
|
};
|
|
2041
3099
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
3100
|
+
function bakeNodWarp(mesh, partY, surface, nodTravel) {
|
|
3101
|
+
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
3102
|
+
const t = nodTravel * deg / HEAD_TURN_MAX_DEG;
|
|
3103
|
+
const offsets = [];
|
|
3104
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
3105
|
+
const y = partY + mesh.vertices[i + 1];
|
|
3106
|
+
offsets.push(0, deg === 0 ? 0 : t + surface.nodBendAt(y + t, deg));
|
|
3107
|
+
}
|
|
3108
|
+
return { value: deg, offsets };
|
|
3109
|
+
});
|
|
3110
|
+
return { parameter: StandardParameter.AngleY, keyforms };
|
|
3111
|
+
}
|
|
3112
|
+
function plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) {
|
|
3113
|
+
let reach = 0;
|
|
3114
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
3115
|
+
reach = Math.max(
|
|
3116
|
+
reach,
|
|
3117
|
+
plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
return reach;
|
|
3121
|
+
}
|
|
3122
|
+
function plateSideReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg, side) {
|
|
3123
|
+
let reach = 0;
|
|
3124
|
+
for (const y of plateGuardRows) {
|
|
3125
|
+
const dest = plateLandingAt(deg, faceCenterX + side * edgeAt(y), y);
|
|
3126
|
+
reach = Math.max(reach, Math.abs(dest - faceCenterX));
|
|
3127
|
+
}
|
|
3128
|
+
return reach;
|
|
3129
|
+
}
|
|
3130
|
+
function plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg) {
|
|
3131
|
+
return Math.max(
|
|
3132
|
+
plateSideReachAt(
|
|
3133
|
+
plateLandingAt,
|
|
3134
|
+
faceCenterX,
|
|
3135
|
+
edgeAt,
|
|
3136
|
+
plateGuardRows,
|
|
3137
|
+
deg,
|
|
3138
|
+
-1
|
|
3139
|
+
),
|
|
3140
|
+
plateSideReachAt(
|
|
3141
|
+
plateLandingAt,
|
|
3142
|
+
faceCenterX,
|
|
3143
|
+
edgeAt,
|
|
3144
|
+
plateGuardRows,
|
|
3145
|
+
deg,
|
|
3146
|
+
1
|
|
3147
|
+
)
|
|
3148
|
+
);
|
|
3149
|
+
}
|
|
3150
|
+
function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows) {
|
|
3151
|
+
if (holdEdgeAt(0) !== holdBase) {
|
|
3152
|
+
throw new Error(
|
|
3153
|
+
`auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
|
|
3154
|
+
);
|
|
2050
3155
|
}
|
|
2051
|
-
const halfWidth = (right - left) / 2;
|
|
2052
|
-
const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
|
|
2053
|
-
const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
|
|
2054
|
-
const DEG_TO_RAD = Math.PI / 180;
|
|
2055
3156
|
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2056
|
-
const
|
|
2057
|
-
const
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
3157
|
+
const holdEdge = holdEdgeAt(deg);
|
|
3158
|
+
const reach = [-1, 1].map(
|
|
3159
|
+
(side) => plateSideReachAt(
|
|
3160
|
+
plateLandingAt,
|
|
3161
|
+
faceCenterX,
|
|
3162
|
+
edgeAt,
|
|
3163
|
+
plateGuardRows,
|
|
3164
|
+
deg,
|
|
3165
|
+
side
|
|
3166
|
+
)
|
|
3167
|
+
);
|
|
3168
|
+
const reached = Math.max(reach[0], reach[1]);
|
|
3169
|
+
if (holdEdge <= reached) {
|
|
3170
|
+
const side = reach[1] > reach[0] ? 1 : -1;
|
|
3171
|
+
throw new Error(
|
|
3172
|
+
`auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${holdEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
2061
3175
|
const offsets = [];
|
|
2062
3176
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2063
|
-
const x = mesh.vertices[i];
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
3177
|
+
const x = partX + mesh.vertices[i];
|
|
3178
|
+
const y = partY + mesh.vertices[i + 1];
|
|
3179
|
+
offsets.push(
|
|
3180
|
+
deg === 0 ? 0 : hairFrontHoldTarget(
|
|
3181
|
+
x,
|
|
3182
|
+
y,
|
|
3183
|
+
deg,
|
|
3184
|
+
faceCenterX,
|
|
3185
|
+
plateLandingAt,
|
|
3186
|
+
edgeAt,
|
|
3187
|
+
holdBase,
|
|
3188
|
+
holdEdge
|
|
3189
|
+
) - x,
|
|
3190
|
+
0
|
|
3191
|
+
);
|
|
2067
3192
|
}
|
|
2068
3193
|
return { value: deg, offsets };
|
|
2069
3194
|
});
|
|
2070
|
-
return { parameter, keyforms };
|
|
3195
|
+
return { parameter: StandardParameter.AngleX, keyforms };
|
|
2071
3196
|
}
|
|
2072
3197
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2073
3198
|
var HAIR_SWAY_CURL = 1.5;
|
|
2074
3199
|
var HAIR_SWAY_RANGE = 20;
|
|
2075
|
-
var HAIR_SWAY_PEAK_FRACTION = 0.56;
|
|
2076
3200
|
function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
2077
3201
|
let top = -Infinity;
|
|
2078
3202
|
let bottom = Infinity;
|
|
@@ -2095,116 +3219,385 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2095
3219
|
]
|
|
2096
3220
|
};
|
|
2097
3221
|
}
|
|
2098
|
-
var
|
|
3222
|
+
var HEAD_TURN_TRAVEL_RATIO = 0.25;
|
|
3223
|
+
function headTurnTravel(faceHalfWidth) {
|
|
3224
|
+
return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
|
|
3225
|
+
}
|
|
2099
3226
|
var BODY_TURN_FOLLOW = 0.3;
|
|
2100
3227
|
var HEAD_BREATH_BOB = -12;
|
|
2101
3228
|
var BODY_BREATH_FOLLOW = 0.5;
|
|
2102
|
-
|
|
3229
|
+
var STANDARD_PARAMETERS = [
|
|
3230
|
+
{
|
|
3231
|
+
id: StandardParameter.MouthOpen,
|
|
3232
|
+
name: "Mouth Open",
|
|
3233
|
+
min: 0,
|
|
3234
|
+
max: 1,
|
|
3235
|
+
default: 0
|
|
3236
|
+
},
|
|
3237
|
+
{
|
|
3238
|
+
id: StandardParameter.MouthForm,
|
|
3239
|
+
name: "Mouth Form",
|
|
3240
|
+
min: -1,
|
|
3241
|
+
max: 1,
|
|
3242
|
+
default: 0
|
|
3243
|
+
},
|
|
3244
|
+
{
|
|
3245
|
+
id: StandardParameter.EyeOpenLeft,
|
|
3246
|
+
name: "Eye L",
|
|
3247
|
+
min: 0,
|
|
3248
|
+
max: 1,
|
|
3249
|
+
default: 1
|
|
3250
|
+
},
|
|
3251
|
+
{
|
|
3252
|
+
id: StandardParameter.EyeOpenRight,
|
|
3253
|
+
name: "Eye R",
|
|
3254
|
+
min: 0,
|
|
3255
|
+
max: 1,
|
|
3256
|
+
default: 1
|
|
3257
|
+
},
|
|
3258
|
+
{
|
|
3259
|
+
id: StandardParameter.EyeballX,
|
|
3260
|
+
name: "Gaze X",
|
|
3261
|
+
min: -1,
|
|
3262
|
+
max: 1,
|
|
3263
|
+
default: 0
|
|
3264
|
+
},
|
|
3265
|
+
{
|
|
3266
|
+
id: StandardParameter.EyeballY,
|
|
3267
|
+
name: "Gaze Y",
|
|
3268
|
+
min: -1,
|
|
3269
|
+
max: 1,
|
|
3270
|
+
default: 0
|
|
3271
|
+
},
|
|
3272
|
+
{
|
|
3273
|
+
id: StandardParameter.AngleX,
|
|
3274
|
+
name: "Head Angle",
|
|
3275
|
+
min: -30,
|
|
3276
|
+
max: 30,
|
|
3277
|
+
default: 0
|
|
3278
|
+
},
|
|
3279
|
+
{
|
|
3280
|
+
id: StandardParameter.AngleY,
|
|
3281
|
+
name: "Head Angle Y",
|
|
3282
|
+
min: -30,
|
|
3283
|
+
max: 30,
|
|
3284
|
+
default: 0
|
|
3285
|
+
},
|
|
3286
|
+
{
|
|
3287
|
+
id: StandardParameter.AngleZ,
|
|
3288
|
+
name: "Head Angle Z",
|
|
3289
|
+
min: -30,
|
|
3290
|
+
max: 30,
|
|
3291
|
+
default: 0
|
|
3292
|
+
},
|
|
3293
|
+
{
|
|
3294
|
+
id: StandardParameter.Breath,
|
|
3295
|
+
name: "Breath",
|
|
3296
|
+
min: 0,
|
|
3297
|
+
max: 1,
|
|
3298
|
+
default: 0
|
|
3299
|
+
},
|
|
3300
|
+
{
|
|
3301
|
+
id: StandardParameter.BrowLeftY,
|
|
3302
|
+
name: "Brow L Y",
|
|
3303
|
+
min: -1,
|
|
3304
|
+
max: 1,
|
|
3305
|
+
default: 0
|
|
3306
|
+
},
|
|
3307
|
+
{
|
|
3308
|
+
id: StandardParameter.BrowRightY,
|
|
3309
|
+
name: "Brow R Y",
|
|
3310
|
+
min: -1,
|
|
3311
|
+
max: 1,
|
|
3312
|
+
default: 0
|
|
3313
|
+
},
|
|
3314
|
+
{
|
|
3315
|
+
id: StandardParameter.BrowLeftAngle,
|
|
3316
|
+
name: "Brow L Angle",
|
|
3317
|
+
min: -1,
|
|
3318
|
+
max: 1,
|
|
3319
|
+
default: 0
|
|
3320
|
+
},
|
|
3321
|
+
{
|
|
3322
|
+
id: StandardParameter.BrowRightAngle,
|
|
3323
|
+
name: "Brow R Angle",
|
|
3324
|
+
min: -1,
|
|
3325
|
+
max: 1,
|
|
3326
|
+
default: 0
|
|
3327
|
+
}
|
|
3328
|
+
];
|
|
3329
|
+
function transformUnder(bindings, valueOf) {
|
|
3330
|
+
const trs = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 };
|
|
3331
|
+
bindings.forEach((binding, i) => {
|
|
3332
|
+
const value = valueOf(binding, i);
|
|
3333
|
+
switch (binding.channel) {
|
|
3334
|
+
case "translateX":
|
|
3335
|
+
trs.x += value;
|
|
3336
|
+
break;
|
|
3337
|
+
case "translateY":
|
|
3338
|
+
trs.y += value;
|
|
3339
|
+
break;
|
|
3340
|
+
case "rotate":
|
|
3341
|
+
trs.rotation += value;
|
|
3342
|
+
break;
|
|
3343
|
+
case "scaleX":
|
|
3344
|
+
trs.scaleX += value;
|
|
3345
|
+
break;
|
|
3346
|
+
case "scaleY":
|
|
3347
|
+
trs.scaleY += value;
|
|
3348
|
+
break;
|
|
3349
|
+
case "opacity":
|
|
3350
|
+
break;
|
|
3351
|
+
}
|
|
3352
|
+
});
|
|
3353
|
+
return trs;
|
|
3354
|
+
}
|
|
3355
|
+
function defaultPoseOf(bindings) {
|
|
3356
|
+
return transformUnder(bindings, (binding) => {
|
|
3357
|
+
const p = STANDARD_PARAMETERS.find((p2) => p2.id === binding.parameter);
|
|
3358
|
+
if (p === void 0) {
|
|
3359
|
+
throw new Error(
|
|
3360
|
+
`auto-rig: defaultPoseOf: "${binding.parameter}" is not a standard parameter`
|
|
3361
|
+
);
|
|
3362
|
+
}
|
|
3363
|
+
const t = (p.default - p.min) / (p.max - p.min);
|
|
3364
|
+
return binding.from + (binding.to - binding.from) * t;
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
|
|
3368
|
+
let minX = Infinity;
|
|
3369
|
+
let maxX = -Infinity;
|
|
3370
|
+
let minY = Infinity;
|
|
3371
|
+
let maxY = -Infinity;
|
|
3372
|
+
for (const member of members) {
|
|
3373
|
+
const rest = member.mesh.vertices;
|
|
3374
|
+
const shapes = [rest];
|
|
3375
|
+
for (const warp of member.warps) {
|
|
3376
|
+
for (const keyform of warp.keyforms) {
|
|
3377
|
+
shapes.push(rest.map((v, i) => v + keyform.offsets[i]));
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
const geometric = member.bindings.filter((b) => b.channel !== "opacity");
|
|
3381
|
+
for (let corner = 0; corner < 1 << geometric.length; corner++) {
|
|
3382
|
+
const trs = transformUnder(
|
|
3383
|
+
geometric,
|
|
3384
|
+
(b, i) => (corner & 1 << i) === 0 ? b.from : b.to
|
|
3385
|
+
);
|
|
3386
|
+
const cos = Math.cos(trs.rotation * (Math.PI / 180));
|
|
3387
|
+
const sin = Math.sin(trs.rotation * (Math.PI / 180));
|
|
3388
|
+
for (const shape of shapes) {
|
|
3389
|
+
for (let i = 0; i < shape.length; i += 2) {
|
|
3390
|
+
const sx = shape[i] * trs.scaleX;
|
|
3391
|
+
const sy = shape[i + 1] * trs.scaleY;
|
|
3392
|
+
const x = member.transform.x + trs.x + cos * sx - sin * sy;
|
|
3393
|
+
const y = member.transform.y + trs.y + sin * sx + cos * sy;
|
|
3394
|
+
minX = Math.min(minX, x);
|
|
3395
|
+
maxX = Math.max(maxX, x);
|
|
3396
|
+
minY = Math.min(minY, y);
|
|
3397
|
+
maxY = Math.max(maxY, y);
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
minX -= turnShiftPx;
|
|
3403
|
+
maxX += turnShiftPx;
|
|
3404
|
+
minY -= nodShiftPx;
|
|
3405
|
+
maxY += nodShiftPx;
|
|
3406
|
+
const marginX = (maxX - minX) * GRID_MARGIN + 1;
|
|
3407
|
+
const marginY = (maxY - minY) * GRID_MARGIN + 1;
|
|
3408
|
+
return {
|
|
3409
|
+
cols: cells,
|
|
3410
|
+
rows: cells,
|
|
3411
|
+
points: generateGridPoints(
|
|
3412
|
+
cells,
|
|
3413
|
+
cells,
|
|
3414
|
+
minX - marginX,
|
|
3415
|
+
maxX + marginX,
|
|
3416
|
+
minY - marginY,
|
|
3417
|
+
maxY + marginY
|
|
3418
|
+
)
|
|
3419
|
+
};
|
|
3420
|
+
}
|
|
3421
|
+
function eyelidFoldFor(layers, layer, mesh, t) {
|
|
3422
|
+
const side = ROLE_TABLE[layer.role].eyeSide;
|
|
3423
|
+
const isLash = layer.role.startsWith("lash_");
|
|
3424
|
+
if (side === void 0 || !(layer.role.startsWith("eye_") || isLash)) {
|
|
3425
|
+
return void 0;
|
|
3426
|
+
}
|
|
3427
|
+
const white = layers.find((l) => l.role === `eye_${side}`);
|
|
3428
|
+
const creaseWorldY = bboxToTransform(white.bbox, white.canvasW, white.canvasH, white.role).y - EYELID_FOLD_CREASE * white.cropH;
|
|
3429
|
+
const hasLash = layers.some((l) => l.role === `lash_${side}`);
|
|
3430
|
+
return bakeEyelidFoldWarp(
|
|
3431
|
+
mesh,
|
|
3432
|
+
side === "L" ? StandardParameter.EyeOpenLeft : StandardParameter.EyeOpenRight,
|
|
3433
|
+
creaseWorldY - t.y,
|
|
3434
|
+
isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
function turnSetup(layers) {
|
|
3438
|
+
const faceLayer = layers.find((l) => l.role === "face");
|
|
3439
|
+
const faceTransform = bboxToTransform(
|
|
3440
|
+
faceLayer.bbox,
|
|
3441
|
+
faceLayer.canvasW,
|
|
3442
|
+
faceLayer.canvasH,
|
|
3443
|
+
"face"
|
|
3444
|
+
);
|
|
3445
|
+
const faceCenterX = faceTransform.x;
|
|
3446
|
+
const faceCenterY = faceTransform.y;
|
|
3447
|
+
const faceHalfWidth = faceLayer.cropW / 2;
|
|
3448
|
+
const plateEdgeX = faceCenterX - faceHalfWidth;
|
|
3449
|
+
const hasNose = layers.some((l) => l.role === "nose");
|
|
3450
|
+
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
3451
|
+
const turnLayers = layers.filter(
|
|
3452
|
+
(l) => isTurnGroup(ROLE_TABLE[l.role].deformer) || l.role === "hair_front"
|
|
3453
|
+
);
|
|
3454
|
+
const turnYs = turnLayers.map((l) => ({
|
|
3455
|
+
y: bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role).y,
|
|
3456
|
+
h: l.cropH
|
|
3457
|
+
}));
|
|
3458
|
+
let unionMinY = Math.min(...turnYs.map(({ y, h }) => y - h / 2));
|
|
3459
|
+
let unionMaxY = Math.max(...turnYs.map(({ y, h }) => y + h / 2));
|
|
3460
|
+
const spanY = unionMaxY - unionMinY;
|
|
3461
|
+
unionMinY -= spanY * GRID_MARGIN;
|
|
3462
|
+
unionMaxY += spanY * GRID_MARGIN;
|
|
3463
|
+
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
3464
|
+
const nodRadius = halfH * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
3465
|
+
const parallaxUnitY = headNodParallaxUnit(halfH);
|
|
3466
|
+
const members = [];
|
|
3467
|
+
for (const layer of layers) {
|
|
3468
|
+
const spec = ROLE_TABLE[layer.role];
|
|
3469
|
+
if (!isTurnGroup(spec.deformer)) continue;
|
|
3470
|
+
const { role, cropW, cropH } = layer;
|
|
3471
|
+
const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
|
|
3472
|
+
const { cols, rows } = meshCellsFor(cropW, cropH);
|
|
3473
|
+
const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
|
|
3474
|
+
const bindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
3475
|
+
hasMouthOpen
|
|
3476
|
+
});
|
|
3477
|
+
const fold = eyelidFoldFor(layers, layer, mesh, t);
|
|
3478
|
+
const pose = defaultPoseOf(bindings);
|
|
3479
|
+
members.push({
|
|
3480
|
+
role,
|
|
3481
|
+
group: spec.deformer,
|
|
3482
|
+
transform: t,
|
|
3483
|
+
part: {
|
|
3484
|
+
x: t.x + pose.x,
|
|
3485
|
+
y: t.y + pose.y,
|
|
3486
|
+
cropW,
|
|
3487
|
+
cropH,
|
|
3488
|
+
cols,
|
|
3489
|
+
rows,
|
|
3490
|
+
scaleX: pose.scaleX,
|
|
3491
|
+
scaleY: pose.scaleY,
|
|
3492
|
+
rotation: pose.rotation
|
|
3493
|
+
},
|
|
3494
|
+
mesh,
|
|
3495
|
+
bindings,
|
|
3496
|
+
warps: fold === void 0 ? [] : [fold]
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
const landmarks = turnLandmarks(layers);
|
|
3500
|
+
const turnShiftOf = (marks) => hasNose && marks.length > 0 ? familyReachPx(marks, plateEdgeX) : 0;
|
|
3501
|
+
const turnShift = {
|
|
3502
|
+
eye: turnShiftOf(landmarks.eye),
|
|
3503
|
+
nose: turnShiftOf(landmarks.nose),
|
|
3504
|
+
mouth: turnShiftOf(landmarks.mouth)
|
|
3505
|
+
};
|
|
3506
|
+
const groupGrids = /* @__PURE__ */ new Map();
|
|
3507
|
+
for (const { group, role } of members) {
|
|
3508
|
+
if (groupGrids.has(group)) continue;
|
|
3509
|
+
const own = members.filter((m) => m.group === group);
|
|
3510
|
+
const isPlate = group === "faceWarp";
|
|
3511
|
+
const nodShiftPx = hasNose ? Math.max(
|
|
3512
|
+
...own.map((m) => FEATURE_NOD_DEPTH[roleFamily(m.role)] ?? 0)
|
|
3513
|
+
) * parallaxUnitY : 0;
|
|
3514
|
+
groupGrids.set(
|
|
3515
|
+
group,
|
|
3516
|
+
groupGridFor(
|
|
3517
|
+
own,
|
|
3518
|
+
isPlate ? FACE_PLATE_CELLS : FEATURE_GRID_CELLS,
|
|
3519
|
+
isPlate ? 0 : turnShift[turnFamily(role)],
|
|
3520
|
+
nodShiftPx
|
|
3521
|
+
)
|
|
3522
|
+
);
|
|
3523
|
+
}
|
|
3524
|
+
const carriers = new Map(
|
|
3525
|
+
members.map((m) => [
|
|
3526
|
+
m.role,
|
|
3527
|
+
{ grid: groupGrids.get(m.group), part: m.part }
|
|
3528
|
+
])
|
|
3529
|
+
);
|
|
3530
|
+
const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
|
|
3531
|
+
let reach = 0;
|
|
3532
|
+
for (const grid of groupGrids.values()) {
|
|
3533
|
+
reach = Math.max(
|
|
3534
|
+
reach,
|
|
3535
|
+
faceCenterX - grid.points[0] + maxShift,
|
|
3536
|
+
grid.points[grid.cols * 2] - faceCenterX + maxShift
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
const halfCells = Math.floor(reach / TURN_LATTICE_CELL_PX) + 1;
|
|
3540
|
+
const points = [];
|
|
3541
|
+
for (const y of [unionMaxY, unionMinY]) {
|
|
3542
|
+
for (let k = -halfCells; k <= halfCells; k++) {
|
|
3543
|
+
points.push(faceCenterX + k * TURN_LATTICE_CELL_PX, y);
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
const lattice = { cols: 2 * halfCells, rows: 1, points };
|
|
3547
|
+
const hairFrontLayer = layers.find((l) => l.role === "hair_front");
|
|
3548
|
+
const hairFront = hairFrontLayer && (() => {
|
|
3549
|
+
const t = bboxToTransform(
|
|
3550
|
+
hairFrontLayer.bbox,
|
|
3551
|
+
hairFrontLayer.canvasW,
|
|
3552
|
+
hairFrontLayer.canvasH,
|
|
3553
|
+
"hair_front"
|
|
3554
|
+
);
|
|
3555
|
+
return {
|
|
3556
|
+
x: t.x,
|
|
3557
|
+
centerY: t.y,
|
|
3558
|
+
cropW: hairFrontLayer.cropW,
|
|
3559
|
+
cropH: hairFrontLayer.cropH
|
|
3560
|
+
};
|
|
3561
|
+
})();
|
|
3562
|
+
const eyeRowY = eyeRowOf(landmarks, faceCenterY);
|
|
3563
|
+
const profile = faceRowProfile(faceLayer, faceCenterY);
|
|
3564
|
+
return {
|
|
3565
|
+
landmarks: turnLandmarks(layers, carriers),
|
|
3566
|
+
lattice,
|
|
3567
|
+
faceCenterX,
|
|
3568
|
+
faceHalfWidth,
|
|
3569
|
+
faceCenterY,
|
|
3570
|
+
nodRadius,
|
|
3571
|
+
carriers,
|
|
3572
|
+
profile,
|
|
3573
|
+
hairFront,
|
|
3574
|
+
eyeRowY,
|
|
3575
|
+
...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
|
|
3576
|
+
members,
|
|
3577
|
+
groupGrids,
|
|
3578
|
+
parallaxUnitY,
|
|
3579
|
+
hasNose,
|
|
3580
|
+
hasMouthOpen
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
function solveInputsOf(s) {
|
|
3584
|
+
return [
|
|
3585
|
+
s.landmarks,
|
|
3586
|
+
s.lattice,
|
|
3587
|
+
s.faceCenterX,
|
|
3588
|
+
s.faceHalfWidth,
|
|
3589
|
+
s.faceCenterY,
|
|
3590
|
+
s.nodRadius,
|
|
3591
|
+
s.carriers,
|
|
3592
|
+
s.profile,
|
|
3593
|
+
s.hairFront
|
|
3594
|
+
];
|
|
3595
|
+
}
|
|
3596
|
+
function generateIkiFromLayerSet(layers, canvas, options = {}) {
|
|
2103
3597
|
validateLayerInputs(layers, canvas);
|
|
2104
3598
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2105
|
-
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
2106
|
-
const hasNose = layers.some((l) => l.role === "nose");
|
|
2107
3599
|
const bodyLayer = layers.find((l) => l.role === "body");
|
|
2108
|
-
const parameters = [
|
|
2109
|
-
{
|
|
2110
|
-
id: StandardParameter.MouthOpen,
|
|
2111
|
-
name: "Mouth Open",
|
|
2112
|
-
min: 0,
|
|
2113
|
-
max: 1,
|
|
2114
|
-
default: 0
|
|
2115
|
-
},
|
|
2116
|
-
{
|
|
2117
|
-
id: StandardParameter.MouthForm,
|
|
2118
|
-
name: "Mouth Form",
|
|
2119
|
-
min: -1,
|
|
2120
|
-
max: 1,
|
|
2121
|
-
default: 0
|
|
2122
|
-
},
|
|
2123
|
-
{
|
|
2124
|
-
id: StandardParameter.EyeOpenLeft,
|
|
2125
|
-
name: "Eye L",
|
|
2126
|
-
min: 0,
|
|
2127
|
-
max: 1,
|
|
2128
|
-
default: 1
|
|
2129
|
-
},
|
|
2130
|
-
{
|
|
2131
|
-
id: StandardParameter.EyeOpenRight,
|
|
2132
|
-
name: "Eye R",
|
|
2133
|
-
min: 0,
|
|
2134
|
-
max: 1,
|
|
2135
|
-
default: 1
|
|
2136
|
-
},
|
|
2137
|
-
{
|
|
2138
|
-
id: StandardParameter.EyeballX,
|
|
2139
|
-
name: "Gaze X",
|
|
2140
|
-
min: -1,
|
|
2141
|
-
max: 1,
|
|
2142
|
-
default: 0
|
|
2143
|
-
},
|
|
2144
|
-
{
|
|
2145
|
-
id: StandardParameter.EyeballY,
|
|
2146
|
-
name: "Gaze Y",
|
|
2147
|
-
min: -1,
|
|
2148
|
-
max: 1,
|
|
2149
|
-
default: 0
|
|
2150
|
-
},
|
|
2151
|
-
{
|
|
2152
|
-
id: StandardParameter.AngleX,
|
|
2153
|
-
name: "Head Angle",
|
|
2154
|
-
min: -30,
|
|
2155
|
-
max: 30,
|
|
2156
|
-
default: 0
|
|
2157
|
-
},
|
|
2158
|
-
{
|
|
2159
|
-
id: StandardParameter.AngleY,
|
|
2160
|
-
name: "Head Angle Y",
|
|
2161
|
-
min: -30,
|
|
2162
|
-
max: 30,
|
|
2163
|
-
default: 0
|
|
2164
|
-
},
|
|
2165
|
-
{
|
|
2166
|
-
id: StandardParameter.AngleZ,
|
|
2167
|
-
name: "Head Angle Z",
|
|
2168
|
-
min: -30,
|
|
2169
|
-
max: 30,
|
|
2170
|
-
default: 0
|
|
2171
|
-
},
|
|
2172
|
-
{
|
|
2173
|
-
id: StandardParameter.Breath,
|
|
2174
|
-
name: "Breath",
|
|
2175
|
-
min: 0,
|
|
2176
|
-
max: 1,
|
|
2177
|
-
default: 0
|
|
2178
|
-
},
|
|
2179
|
-
{
|
|
2180
|
-
id: StandardParameter.BrowLeftY,
|
|
2181
|
-
name: "Brow L Y",
|
|
2182
|
-
min: -1,
|
|
2183
|
-
max: 1,
|
|
2184
|
-
default: 0
|
|
2185
|
-
},
|
|
2186
|
-
{
|
|
2187
|
-
id: StandardParameter.BrowRightY,
|
|
2188
|
-
name: "Brow R Y",
|
|
2189
|
-
min: -1,
|
|
2190
|
-
max: 1,
|
|
2191
|
-
default: 0
|
|
2192
|
-
},
|
|
2193
|
-
{
|
|
2194
|
-
id: StandardParameter.BrowLeftAngle,
|
|
2195
|
-
name: "Brow L Angle",
|
|
2196
|
-
min: -1,
|
|
2197
|
-
max: 1,
|
|
2198
|
-
default: 0
|
|
2199
|
-
},
|
|
2200
|
-
{
|
|
2201
|
-
id: StandardParameter.BrowRightAngle,
|
|
2202
|
-
name: "Brow R Angle",
|
|
2203
|
-
min: -1,
|
|
2204
|
-
max: 1,
|
|
2205
|
-
default: 0
|
|
2206
|
-
}
|
|
2207
|
-
];
|
|
3600
|
+
const parameters = [...STANDARD_PARAMETERS];
|
|
2208
3601
|
if (hasHair) {
|
|
2209
3602
|
parameters.push(
|
|
2210
3603
|
{
|
|
@@ -2223,97 +3616,88 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2223
3616
|
}
|
|
2224
3617
|
);
|
|
2225
3618
|
}
|
|
2226
|
-
const
|
|
2227
|
-
const
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
)
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
3619
|
+
const setup = turnSetup(layers);
|
|
3620
|
+
const {
|
|
3621
|
+
lattice,
|
|
3622
|
+
faceCenterX,
|
|
3623
|
+
faceHalfWidth,
|
|
3624
|
+
faceCenterY,
|
|
3625
|
+
nodRadius,
|
|
3626
|
+
profile,
|
|
3627
|
+
eyeRowY,
|
|
3628
|
+
plate,
|
|
3629
|
+
edgeAt,
|
|
3630
|
+
plateGuardRows,
|
|
3631
|
+
members,
|
|
3632
|
+
groupGrids,
|
|
3633
|
+
parallaxUnitY,
|
|
3634
|
+
hasNose,
|
|
3635
|
+
hasMouthOpen
|
|
3636
|
+
} = setup;
|
|
3637
|
+
const faceCropH = plate.part.cropH;
|
|
3638
|
+
const turn = hasNose ? solveTurnModel(
|
|
3639
|
+
resolveTurnTargets(options.turnTargets),
|
|
3640
|
+
...solveInputsOf(setup),
|
|
3641
|
+
options.headEdges
|
|
3642
|
+
) : void 0;
|
|
3643
|
+
if (turn?.unreachable) {
|
|
3644
|
+
throw new TurnTargetError(
|
|
3645
|
+
turn.attainable === void 0 ? (
|
|
3646
|
+
// Only a silhouette can land here: nothing the field's own accepted
|
|
3647
|
+
// range allows renders on this layer set, so there is no interval to
|
|
3648
|
+
// offer instead — see `silhouetteMiss` in evaluateTurnCandidate.
|
|
3649
|
+
`auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set, and so is every ${turn.field} in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
|
|
3650
|
+
) : `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set (attainable ${turn.attainable[0]}\u2026${turn.attainable[1]})`
|
|
2258
3651
|
);
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
const
|
|
2270
|
-
|
|
2271
|
-
cols
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
const
|
|
2284
|
-
const
|
|
2285
|
-
const
|
|
2286
|
-
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
3652
|
+
}
|
|
3653
|
+
if (turn) {
|
|
3654
|
+
options.onTurnSolved?.({
|
|
3655
|
+
radius: turn.radius,
|
|
3656
|
+
holdBase: turn.holdBase,
|
|
3657
|
+
depths: turn.depths,
|
|
3658
|
+
achieved: turn.achieved,
|
|
3659
|
+
clamped: turn.clamped
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
const latticeReach = Math.max(
|
|
3663
|
+
faceCenterX - lattice.points[0],
|
|
3664
|
+
lattice.points[lattice.cols * 2] - faceCenterX
|
|
3665
|
+
);
|
|
3666
|
+
const surface = turn?.surface ?? turnSurface({
|
|
3667
|
+
faceCenterX,
|
|
3668
|
+
faceCenterY,
|
|
3669
|
+
radius: latticeReach * HEAD_CYLINDER_RADIUS_FACTOR,
|
|
3670
|
+
travel: headTurnTravel(faceHalfWidth),
|
|
3671
|
+
nodRadius,
|
|
3672
|
+
lattice,
|
|
3673
|
+
profile,
|
|
3674
|
+
eyeRowY
|
|
3675
|
+
});
|
|
3676
|
+
const parallaxUnit = headTurnParallaxUnit(surface.radius);
|
|
3677
|
+
const plateLandingAt = plateLandingOn(plate, rowMapsOf(surface));
|
|
3678
|
+
const faceBottom = faceCenterY - faceCropH / 2;
|
|
2287
3679
|
const neckPivot = {
|
|
2288
3680
|
x: faceCenterX,
|
|
2289
3681
|
y: faceBottom - faceCropH * 0.15
|
|
2290
3682
|
// 15% below face bottom = neck
|
|
2291
3683
|
};
|
|
2292
|
-
const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
|
|
2293
|
-
faceGrid,
|
|
2294
|
-
StandardParameter.AngleX,
|
|
2295
|
-
StandardParameter.AngleY,
|
|
2296
|
-
faceCenterX,
|
|
2297
|
-
faceCenterY
|
|
2298
|
-
);
|
|
2299
3684
|
const deformers = [
|
|
2300
3685
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
2301
|
-
// neck pivot
|
|
3686
|
+
// neck pivot. Same bindings as sample-model.ts apart from the turn, which
|
|
3687
|
+
// moves the face inside the head rather than the head itself (see below).
|
|
2302
3688
|
{
|
|
2303
3689
|
id: "headDeformer",
|
|
2304
3690
|
pivot: neckPivot,
|
|
2305
3691
|
bindings: [
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
to: HEAD_TURN_TRAVEL
|
|
2316
|
-
},
|
|
3692
|
+
// No AngleX binding: the turn moves NOTHING rigidly. Its sideways
|
|
3693
|
+
// travel is baked into every group grid (the surface's `travel`, see
|
|
3694
|
+
// bakeTurnGroupWarp2D) so the face slides inside a silhouette the
|
|
3695
|
+
// bangs hold; a translate here would take the hair shell with it. Nor
|
|
3696
|
+
// is the turn a roll — the sample model's ±6° "lean into the turn" was
|
|
3697
|
+
// tried here and dropped, since rotating about the neck pivot swings
|
|
3698
|
+
// the crown (~500px above it) far more than the chin, so the top of
|
|
3699
|
+
// the head appeared to lunge ahead of the face on every turn. Roll is
|
|
3700
|
+
// AngleZ's job, below.
|
|
2317
3701
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2318
3702
|
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2319
3703
|
// poses, collapsing pitch into roll.
|
|
@@ -2342,19 +3726,25 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2342
3726
|
to: HEAD_BREATH_BOB
|
|
2343
3727
|
}
|
|
2344
3728
|
]
|
|
2345
|
-
},
|
|
2346
|
-
// faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
|
|
2347
|
-
// about faceCenterX so the bake's cylinder axis aligns with the face center.
|
|
2348
|
-
// One 2D warp carries both the turn and the nod (a deformer holds either
|
|
2349
|
-
// `warps` or `warp2d`, never both).
|
|
2350
|
-
{
|
|
2351
|
-
kind: "warp",
|
|
2352
|
-
id: "faceWarp",
|
|
2353
|
-
parent: "headDeformer",
|
|
2354
|
-
grid: faceGrid,
|
|
2355
|
-
warp2d: faceWarp2d
|
|
2356
3729
|
}
|
|
2357
3730
|
];
|
|
3731
|
+
for (const [group, grid] of groupGrids) {
|
|
3732
|
+
const family = turnFamily(members.find((m) => m.group === group).role);
|
|
3733
|
+
const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
|
|
3734
|
+
deformers.push({
|
|
3735
|
+
kind: "warp",
|
|
3736
|
+
id: group,
|
|
3737
|
+
parent: "headDeformer",
|
|
3738
|
+
grid,
|
|
3739
|
+
warp2d: bakeTurnGroupWarp2D(
|
|
3740
|
+
grid,
|
|
3741
|
+
StandardParameter.AngleX,
|
|
3742
|
+
StandardParameter.AngleY,
|
|
3743
|
+
surface,
|
|
3744
|
+
(deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
|
|
3745
|
+
)
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
2358
3748
|
if (bodyLayer) {
|
|
2359
3749
|
const bt = bboxToTransform(
|
|
2360
3750
|
bodyLayer.bbox,
|
|
@@ -2366,12 +3756,13 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2366
3756
|
id: "bodyDeformer",
|
|
2367
3757
|
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2368
3758
|
bindings: [
|
|
2369
|
-
// Low-weight follow of the turn, same direction as the
|
|
3759
|
+
// Low-weight follow of the turn, same direction as the face's own
|
|
3760
|
+
// slide — the only rigid part of the travel left.
|
|
2370
3761
|
{
|
|
2371
3762
|
parameter: StandardParameter.AngleX,
|
|
2372
3763
|
channel: "translateX",
|
|
2373
|
-
from: -BODY_TURN_FOLLOW *
|
|
2374
|
-
to: BODY_TURN_FOLLOW *
|
|
3764
|
+
from: -BODY_TURN_FOLLOW * surface.travel,
|
|
3765
|
+
to: BODY_TURN_FOLLOW * surface.travel
|
|
2375
3766
|
},
|
|
2376
3767
|
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2377
3768
|
{
|
|
@@ -2383,33 +3774,21 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2383
3774
|
]
|
|
2384
3775
|
});
|
|
2385
3776
|
}
|
|
2386
|
-
const
|
|
2387
|
-
for (const layer of layers) {
|
|
2388
|
-
const side = ROLE_TABLE[layer.role].eyeSide;
|
|
2389
|
-
if ((layer.role === "eye_L" || layer.role === "eye_R") && side) {
|
|
2390
|
-
const ey = bboxToTransform(
|
|
2391
|
-
layer.bbox,
|
|
2392
|
-
layer.canvasW,
|
|
2393
|
-
layer.canvasH,
|
|
2394
|
-
layer.role
|
|
2395
|
-
).y;
|
|
2396
|
-
eyeCreaseBySide[side] = ey - EYELID_FOLD_CREASE * layer.cropH;
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
3777
|
+
const memberByRole = new Map(members.map((m) => [m.role, m]));
|
|
2399
3778
|
const parts = layers.map((layer) => {
|
|
2400
3779
|
const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
|
|
2401
3780
|
const spec = ROLE_TABLE[role];
|
|
2402
3781
|
const t = bboxToTransform(bbox, canvasW, canvasH, role);
|
|
2403
3782
|
const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
2404
3783
|
hasMouthOpen,
|
|
2405
|
-
parallaxUnit,
|
|
2406
3784
|
parallaxUnitY,
|
|
2407
3785
|
hasNose
|
|
2408
3786
|
});
|
|
2409
3787
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2410
3788
|
if (spec.mesh) {
|
|
3789
|
+
const member = memberByRole.get(role);
|
|
2411
3790
|
const { cols, rows } = meshCellsFor(cropW, cropH);
|
|
2412
|
-
const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
|
|
3791
|
+
const mesh = member?.mesh ?? createPixelGridMesh(cols, rows, cropW, cropH);
|
|
2413
3792
|
const part = {
|
|
2414
3793
|
id: role,
|
|
2415
3794
|
color: [1, 1, 1, 1],
|
|
@@ -2424,23 +3803,38 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2424
3803
|
part.bindings = roleBindings;
|
|
2425
3804
|
}
|
|
2426
3805
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2427
|
-
|
|
2428
|
-
|
|
3806
|
+
const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
3807
|
+
const ownWarps = [];
|
|
2429
3808
|
if (role === "hair_front") {
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
|
|
3809
|
+
ownWarps.push(
|
|
3810
|
+
bakeHairSwayWarp(
|
|
3811
|
+
mesh,
|
|
3812
|
+
StandardParameter.AngleX,
|
|
3813
|
+
HAIR_FRONT_DEPTH * parallaxUnit,
|
|
3814
|
+
HEAD_TURN_MAX_DEG
|
|
3815
|
+
)
|
|
2438
3816
|
);
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
3817
|
+
const holdBase = turn?.holdBase ?? plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) + HOLD_CLEARANCE;
|
|
3818
|
+
ownWarps.push(
|
|
3819
|
+
bakeHairFrontSilhouetteWarp(
|
|
3820
|
+
mesh,
|
|
3821
|
+
t.x,
|
|
3822
|
+
t.y,
|
|
3823
|
+
faceCenterX,
|
|
3824
|
+
plateLandingAt,
|
|
3825
|
+
edgeAt,
|
|
3826
|
+
holdBase,
|
|
3827
|
+
turn?.holdEdgeAt ?? (() => holdBase),
|
|
3828
|
+
plateGuardRows
|
|
3829
|
+
)
|
|
3830
|
+
);
|
|
3831
|
+
ownWarps.push(
|
|
3832
|
+
bakeNodWarp(
|
|
3833
|
+
mesh,
|
|
3834
|
+
t.y,
|
|
3835
|
+
surface,
|
|
3836
|
+
hasNose ? HAIR_FRONT_NOD_DEPTH * parallaxUnitY : 0
|
|
3837
|
+
)
|
|
2444
3838
|
);
|
|
2445
3839
|
}
|
|
2446
3840
|
part.warps = [
|
|
@@ -2456,29 +3850,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2456
3850
|
tipShift,
|
|
2457
3851
|
HAIR_SWAY_RANGE
|
|
2458
3852
|
),
|
|
2459
|
-
...
|
|
2460
|
-
];
|
|
2461
|
-
}
|
|
2462
|
-
if (role === "hair_back") {
|
|
2463
|
-
part.warps = [
|
|
2464
|
-
...part.warps ?? [],
|
|
2465
|
-
bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
|
|
3853
|
+
...ownWarps
|
|
2466
3854
|
];
|
|
2467
3855
|
}
|
|
2468
3856
|
if (spec.eyeSide !== void 0) {
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
const openParam = spec.eyeSide === "L" ? StandardParameter.EyeOpenLeft : StandardParameter.EyeOpenRight;
|
|
2472
|
-
const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
|
|
2473
|
-
const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
|
|
2474
|
-
part.warps = [
|
|
2475
|
-
bakeEyelidFoldWarp(
|
|
2476
|
-
mesh,
|
|
2477
|
-
openParam,
|
|
2478
|
-
creaseWorldY - t.y,
|
|
2479
|
-
isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
|
|
2480
|
-
)
|
|
2481
|
-
];
|
|
3857
|
+
if (role.startsWith("eye_") || role.startsWith("lash_")) {
|
|
3858
|
+
part.warps = [...member.warps];
|
|
2482
3859
|
} else {
|
|
2483
3860
|
part.clip = { masks: [`eye_${spec.eyeSide}`] };
|
|
2484
3861
|
}
|
|
@@ -2542,6 +3919,7 @@ export {
|
|
|
2542
3919
|
AddPart,
|
|
2543
3920
|
AddPhysicsRig,
|
|
2544
3921
|
CaptureGridKeyform,
|
|
3922
|
+
DEFAULT_TURN_TARGETS,
|
|
2545
3923
|
DeleteDeformer,
|
|
2546
3924
|
DeletePart,
|
|
2547
3925
|
DeletePhysicsRig,
|
|
@@ -2561,6 +3939,7 @@ export {
|
|
|
2561
3939
|
SetPartTransform,
|
|
2562
3940
|
SetPartWidth,
|
|
2563
3941
|
SetPhysicsRig,
|
|
3942
|
+
TurnTargetError,
|
|
2564
3943
|
UV_INSET_PX,
|
|
2565
3944
|
captureBindingEndpoint,
|
|
2566
3945
|
computeGridOffsets,
|