@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/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: it bends on
1609
- // the turn through its own part warp (bakeHairBackTurnWarp) and swings its
1610
- // ends on the hair-sway warps.
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: "faceWarp", order: 15, mesh: true },
1620
- blush_L: { deformer: "faceWarp", order: 20, mesh: true },
1621
- blush_R: { deformer: "faceWarp", order: 20, mesh: true },
1622
- mouth: { deformer: "faceWarp", order: 25, mesh: true },
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: "faceWarp", order: 26, mesh: true },
1627
- eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
1628
- eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
1629
- iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
1630
- iris_R: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "R" },
1631
- pupil_L: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "L" },
1632
- pupil_R: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "R" },
1633
- highlight_L: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "L" },
1634
- highlight_R: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "R" },
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: "faceWarp", order: 34, mesh: true, eyeSide: "L" },
1639
- lash_R: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "R" },
1640
- brow_L: { deformer: "faceWarp", order: 40, mesh: true },
1641
- brow_R: { deformer: "faceWarp", order: 40, mesh: true },
1642
- // Front hair rides faceWarp (mesh) so it follows the head-turn curvature with
1643
- // the face instead of detaching as a rigid blob; its bbox joins the faceWarp
1644
- // grid union so the grid covers it.
1645
- hair_front: { deformer: "faceWarp", order: 50, mesh: true }
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 FACE_GRID_CELLS = 6;
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(gridHalfWidth) {
1793
- return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
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 bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
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 thetaX = angleX * DEG_TO_RAD;
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
- pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
1821
- pinnedCylinderBend(
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 FEATURE_DEPTH = {
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 = FEATURE_DEPTH.brow;
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 depth = FEATURE_DEPTH[roleFamily(role)];
1867
- if (depth === void 0) return [];
1868
- const bindings = [];
1869
- const shiftX = depth * (options.parallaxUnit ?? 0);
1870
- if (shiftX !== 0) {
1871
- bindings.push({
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 === "hair_front" || role === "hair_back") {
1996
- const isFront = role === "hair_front";
1997
- const parallax = [];
1998
- if (!isFront) {
1999
- const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
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
- var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
2043
- var HAIR_BACK_FAR_BULGE = 0.22;
2044
- function bakeHairBackTurnWarp(mesh, parameter) {
2045
- let left = Infinity;
2046
- let right = -Infinity;
2047
- for (let i = 0; i < mesh.vertices.length; i += 2) {
2048
- left = Math.min(left, mesh.vertices[i]);
2049
- right = Math.max(right, mesh.vertices[i]);
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 theta = deg * DEG_TO_RAD;
2057
- const s = Math.sign(deg);
2058
- const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
2059
- const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
2060
- const bulgeAtStop = bulge * deg / HEAD_TURN_MAX_DEG;
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 far = Math.max(0, -s * x / halfWidth);
2065
- const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
2066
- offsets.push(bend - bulgeAtStop * far, 0);
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 HEAD_TURN_TRAVEL = 50;
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
- function generateIkiFromLayerSet(layers, canvas) {
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 faceLayers = layers.filter((l) => l.role === "face");
2227
- const faceLayer = faceLayers[0];
2228
- const faceTransform = bboxToTransform(
2229
- faceLayer.bbox,
2230
- faceLayer.canvasW,
2231
- faceLayer.canvasH,
2232
- "face"
2233
- );
2234
- const faceCenterX = faceTransform.x;
2235
- const faceCropH = faceLayer.cropH;
2236
- const faceWarpLayers = layers.filter(
2237
- (l) => ROLE_TABLE[l.role].deformer === "faceWarp"
2238
- );
2239
- let unionMinX = -canvas.width / 2;
2240
- let unionMaxX = canvas.width / 2;
2241
- let unionMinY = -canvas.height / 2;
2242
- let unionMaxY = canvas.height / 2;
2243
- if (faceWarpLayers.length > 0) {
2244
- const transforms = faceWarpLayers.map(
2245
- (l) => bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role)
2246
- );
2247
- unionMinX = Math.min(
2248
- ...transforms.map((t, i) => t.x - faceWarpLayers[i].cropW / 2)
2249
- );
2250
- unionMaxX = Math.max(
2251
- ...transforms.map((t, i) => t.x + faceWarpLayers[i].cropW / 2)
2252
- );
2253
- unionMinY = Math.min(
2254
- ...transforms.map((t, i) => t.y - faceWarpLayers[i].cropH / 2)
2255
- );
2256
- unionMaxY = Math.max(
2257
- ...transforms.map((t, i) => t.y + faceWarpLayers[i].cropH / 2)
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
- const spanX = unionMaxX - unionMinX;
2260
- const spanY = unionMaxY - unionMinY;
2261
- const MARGIN = 0.12;
2262
- unionMinX -= spanX * MARGIN;
2263
- unionMaxX += spanX * MARGIN;
2264
- unionMinY -= spanY * MARGIN;
2265
- unionMaxY += spanY * MARGIN;
2266
- }
2267
- const halfW = Math.max(faceCenterX - unionMinX, unionMaxX - faceCenterX);
2268
- const faceGridMinX = faceCenterX - halfW;
2269
- const faceGridMaxX = faceCenterX + halfW;
2270
- const faceGrid = {
2271
- cols: FACE_GRID_CELLS,
2272
- rows: FACE_GRID_CELLS,
2273
- points: generateGridPoints(
2274
- FACE_GRID_CELLS,
2275
- FACE_GRID_CELLS,
2276
- faceGridMinX,
2277
- faceGridMaxX,
2278
- unionMinY,
2279
- unionMaxY
2280
- )
2281
- };
2282
- const faceCenterY = faceTransform.y;
2283
- const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
2284
- const parallaxUnit = headTurnParallaxUnit(halfW);
2285
- const parallaxUnitY = headNodParallaxUnit(halfH);
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; bindings mirror sample-model.ts exactly.
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
- // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2307
- // ±6° "lean into the turn" was tried here and dropped — rotating about
2308
- // the neck pivot swings the crown (~500px above it) far more than the
2309
- // chin, so the top of the head appeared to lunge ahead of the face on
2310
- // every turn. Roll is AngleZ's job, below.
2311
- {
2312
- parameter: StandardParameter.AngleX,
2313
- channel: "translateX",
2314
- from: -HEAD_TURN_TRAVEL,
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 head.
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 * HEAD_TURN_TRAVEL,
2374
- to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
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 eyeCreaseBySide = {};
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
- let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2428
- let leadWarp;
3806
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3807
+ const ownWarps = [];
2429
3808
  if (role === "hair_front") {
2430
- const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2431
- const headroom = Math.min(
2432
- faceGridMaxX - (t.x + cropW / 2),
2433
- t.x - cropW / 2 - faceGridMinX
2434
- ) - shift;
2435
- tipShift = Math.min(
2436
- tipShift,
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
- leadWarp = bakeHairSwayWarp(
2440
- mesh,
2441
- StandardParameter.AngleX,
2442
- shift,
2443
- HEAD_TURN_MAX_DEG
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
- ...leadWarp ? [leadWarp] : []
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
- const isLash = role.startsWith("lash_");
2470
- if (role.startsWith("eye_") || isLash) {
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,