@ikijs/editor 0.5.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,6 +1603,21 @@ 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
1623
  // whose grid it would stretch across the shoulders) as a MESH, so its ends
@@ -1616,34 +1631,43 @@ var ROLE_TABLE = {
1616
1631
  // reading as a floating head. Drawn over the back hair so long hair falls
1617
1632
  // behind the shoulders.
1618
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).
1619
1641
  face: { deformer: "faceWarp", order: 10, mesh: true },
1620
- nose: { deformer: "faceWarp", order: 15, mesh: true },
1621
- blush_L: { deformer: "faceWarp", order: 20, mesh: true },
1622
- blush_R: { deformer: "faceWarp", order: 20, mesh: true },
1623
- 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 },
1624
1646
  // An OPTIONAL second mouth drawing, open. When present the two cross-fade on
1625
1647
  // MouthOpen instead of the closed one being stretched, which is the
1626
1648
  // difference between a portrait rig and one that can lip-sync.
1627
- mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
1628
- eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
1629
- eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
1630
- iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
1631
- iris_R: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "R" },
1632
- pupil_L: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "L" },
1633
- pupil_R: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "R" },
1634
- highlight_L: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "L" },
1635
- 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" },
1636
1658
  // Upper lashes: an OPTIONAL separate layer ABOVE the iris that folds down to
1637
1659
  // the closed-eye seam (the same crease the white folds to), covering the cut
1638
1660
  // eyeball cleanly. When absent, the white's own fold is the only closed line.
1639
- lash_L: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "L" },
1640
- lash_R: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "R" },
1641
- brow_L: { deformer: "faceWarp", order: 40, mesh: true },
1642
- brow_R: { deformer: "faceWarp", order: 40, mesh: true },
1643
- // Front hair rides faceWarp (mesh) so it follows the head-turn curvature with
1644
- // the face instead of detaching as a rigid blob; its bbox joins the faceWarp
1645
- // grid union so the grid covers it.
1646
- 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 }
1647
1671
  };
1648
1672
  var REQUIRED_ROLES = ["face", "eye_L", "eye_R", "mouth"];
1649
1673
  var ALIAS_MAP = {
@@ -1731,6 +1755,27 @@ function validateLayerInputs(layers, canvas) {
1731
1755
  `auto-rig: validateLayerInputs: role "${role}" canvas size (${canvasW}\xD7${canvasH}) does not match canvas arg (${canvas.width}\xD7${canvas.height})`
1732
1756
  );
1733
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
+ }
1734
1779
  }
1735
1780
  }
1736
1781
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1786,20 +1831,16 @@ function meshCellsFor(w, h) {
1786
1831
  );
1787
1832
  return { cols: cells(w), rows: cells(h) };
1788
1833
  }
1789
- 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;
1790
1838
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1791
1839
  var HEAD_TURN_MAX_DEG = 30;
1792
1840
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1793
1841
  function headTurnParallaxUnit(radius) {
1794
1842
  return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1795
1843
  }
1796
- function gridReach(grid, axis, center) {
1797
- let reach = 0;
1798
- for (let i = axis; i < grid.points.length; i += 2) {
1799
- reach = Math.max(reach, Math.abs(grid.points[i] - center));
1800
- }
1801
- return reach;
1802
- }
1803
1844
  function pinnedCylinderBend(local, radius, theta) {
1804
1845
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1805
1846
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
@@ -1812,26 +1853,101 @@ function boundedCylinderBend(local, radius, theta) {
1812
1853
  function turnSlide(travel, angleX) {
1813
1854
  return travel * angleX / HEAD_TURN_MAX_DEG;
1814
1855
  }
1815
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
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) {
1816
1938
  const STOPS = [...HEAD_TURN_STOPS];
1817
- const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1818
1939
  const pointCount = grid.points.length / 2;
1819
- const DEG_TO_RAD = Math.PI / 180;
1820
1940
  const keyforms2d = [];
1821
1941
  for (const angleY of STOPS) {
1822
- const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1823
1942
  for (const angleX of STOPS) {
1824
- const thetaX = angleX * DEG_TO_RAD;
1825
- const slide = turnSlide(travel, angleX);
1943
+ const shift = shiftAt(angleX);
1826
1944
  const offsets = [];
1827
1945
  for (let i = 0; i < pointCount; i++) {
1946
+ const x = grid.points[i * 2];
1947
+ const y = grid.points[i * 2 + 1];
1828
1948
  offsets.push(
1829
- boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
1830
- pinnedCylinderBend(
1831
- grid.points[i * 2 + 1] - centerY,
1832
- RADIUS_Y,
1833
- thetaY
1834
- )
1949
+ angleX === 0 ? 0 : surface.mapAt(angleX, y).mapX(x + shift) - x,
1950
+ angleY === 0 ? 0 : surface.nodBendAt(y, angleY)
1835
1951
  );
1836
1952
  }
1837
1953
  keyforms2d.push({ offsets });
@@ -1863,30 +1979,147 @@ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1863
1979
  );
1864
1980
  }
1865
1981
  const cellFor = (v, edges) => {
1866
- for (let c = 0; c < grid.cols; c++) {
1867
- if (v < edges[c + 1]) return c;
1868
- }
1869
- return grid.cols - 1;
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;
1870
2000
  };
1871
2001
  return {
1872
2002
  restX,
1873
2003
  warpedX,
1874
- mapX(x) {
1875
- const c = cellFor(x, restX);
1876
- const s = Math.max(
1877
- 0,
1878
- Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
1879
- );
1880
- return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
1881
- },
1882
- invertX(X) {
1883
- const c = cellFor(X, warpedX);
1884
- const s = Math.max(
1885
- 0,
1886
- Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
1887
- );
1888
- return restX[c] + (restX[c + 1] - restX[c]) * s;
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;
1889
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)
1890
2123
  };
1891
2124
  }
1892
2125
  var TurnTargetError = class extends Error {
@@ -1957,12 +2190,15 @@ function resolveTurnTargets(targets = {}) {
1957
2190
  defaulted
1958
2191
  };
1959
2192
  }
1960
- function turnLandmarks(layers) {
2193
+ function turnLandmarks(layers, carriers) {
1961
2194
  const markOf = (role) => {
1962
2195
  const layer = layers.find((l) => l.role === role);
1963
2196
  if (!layer) return void 0;
1964
2197
  const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1965
- return { x: t.x, y: t.y, w: layer.cropW };
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;
1966
2202
  };
1967
2203
  const pairOf = (left, right) => {
1968
2204
  const l = markOf(left);
@@ -1979,16 +2215,34 @@ function turnLandmarks(layers) {
1979
2215
  mouth: one("mouth")
1980
2216
  };
1981
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
+ }
1982
2222
  var TURN_BISECT_STEPS = 40;
1983
2223
  var TURN_DEPTH_EPS = 1e-6;
1984
- function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
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) {
1985
2238
  if (landmarks.length === 0) {
1986
2239
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
1987
2240
  }
1988
- const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
1989
- const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
1990
- const cap = Math.max(0, (farEdge - floor) / unit);
1991
- const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
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;
1992
2246
  const atCap = achieved(cap);
1993
2247
  const atRest = achieved(0);
1994
2248
  const attainable = [atCap, atRest];
@@ -2011,68 +2265,52 @@ var TURN_SWEEP_MAX_FACTOR = 8;
2011
2265
  var TURN_SWEEP_SAMPLES = 32;
2012
2266
  var HOLD_CLEARANCE = 1;
2013
2267
  var TURN_HOLD_SAMPLES = 16;
2014
- var TURN_HOLD_SATURATION_PASSES = 4;
2015
- function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2268
+ var TURN_SETTLE_PASSES = 24;
2269
+ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
2016
2270
  const local = x - faceCenterX;
2017
2271
  const dist = Math.abs(local);
2018
2272
  const side = Math.sign(local);
2019
- const sideIdx = side < 0 ? 0 : 1;
2020
- if (dist <= faceHalfWidth) return map.mapX(x);
2273
+ const painted = edgeAt(y);
2274
+ if (dist <= painted) return plateLandingAt(deg, x, y);
2021
2275
  if (dist <= holdBase) {
2022
- const inner = map.mapX(faceCenterX + side * faceHalfWidth);
2023
- const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
2024
- return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
2276
+ const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
2277
+ const u = (dist - painted) / (holdBase - painted);
2278
+ return inner + (faceCenterX + side * holdEdge - inner) * u;
2025
2279
  }
2026
- return x + side * (holdEdge[sideIdx] - holdBase);
2280
+ return x + side * (holdEdge - holdBase);
2027
2281
  }
2028
- function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2282
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2029
2283
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2030
- const preBindAt = (col2) => {
2031
- const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
2032
- const target = hairFrontHoldTarget(
2033
- colX,
2034
- faceCenterX,
2035
- faceHalfWidth,
2036
- holdBase,
2037
- cappedHoldEdge,
2038
- map
2039
- );
2040
- return map.invertX(target);
2041
- };
2042
- const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
2043
- const bracket = (v, cells) => {
2044
- const lo = Math.max(0, Math.min(cells, Math.floor(v)));
2045
- return {
2046
- lo,
2047
- hi: Math.max(0, Math.min(cells, lo + 1)),
2048
- t: Math.max(0, Math.min(1, v - lo))
2049
- };
2050
- };
2051
- const col = bracket(
2052
- (x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
2053
- cols
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
2054
2300
  );
2055
- const row = bracket(leadRow * rows, rows);
2056
- const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
2057
- const tl = landingAt(col.lo, row.lo);
2058
- const br = landingAt(col.hi, row.hi);
2059
- if (col.t <= row.t) {
2060
- const bl = landingAt(col.lo, row.hi);
2061
- return tl + row.t * (bl - tl) + col.t * (br - bl);
2062
- }
2063
- const tr = landingAt(col.hi, row.lo);
2064
- return tl + col.t * (tr - tl) + row.t * (br - tr);
2065
- }
2066
- function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
2301
+ }
2302
+ function shellTravelCap(faceCenterX, edgeAt, plateGuardRows, askedTravel, bendOnlyLandingAt, holdEdgeAt) {
2067
2303
  let cap = Infinity;
2068
2304
  for (const deg of HEAD_TURN_STOPS) {
2069
2305
  if (deg === 0) continue;
2070
- const map = bendOnlyMapAt(deg);
2071
2306
  const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
2072
- const towardReach = Math.max(
2073
- ...[-1, 1].map(
2074
- (side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
2075
- )
2307
+ const towardReach = plateSideReachAt(
2308
+ bendOnlyLandingAt,
2309
+ faceCenterX,
2310
+ edgeAt,
2311
+ plateGuardRows,
2312
+ deg,
2313
+ slideSign
2076
2314
  );
2077
2315
  cap = Math.min(
2078
2316
  cap,
@@ -2081,8 +2319,26 @@ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt,
2081
2319
  }
2082
2320
  return cap;
2083
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
+ }
2084
2330
  function evaluateTurnCandidate(ctx, radius) {
2085
- const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
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)));
2086
2342
  const measuredEdgeAt = (side) => {
2087
2343
  const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2088
2344
  if (candidates === void 0 || candidates.length === 0) return void 0;
@@ -2094,66 +2350,71 @@ function evaluateTurnCandidate(ctx, radius) {
2094
2350
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2095
2351
  };
2096
2352
  const unit = headTurnParallaxUnit(radius);
2097
- const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2098
- const leadRow = ctx.hairFrontLeadFraction ?? 0;
2099
- const evaluateHold = (ratio) => {
2353
+ const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2354
+ const evaluateHold = (ratio, depths2) => {
2100
2355
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2101
- const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2356
+ const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2102
2357
  ctx.travel,
2103
2358
  shellTravelCap(
2104
2359
  ctx.faceCenterX,
2105
- ctx.faceHalfWidth,
2360
+ ctx.edgeAt,
2361
+ ctx.plateGuardRows,
2106
2362
  ctx.travel,
2107
- bendOnlyMapAt,
2363
+ bendOnlyLandingAt,
2108
2364
  (deg, side) => {
2109
2365
  const rest = shellHalfWidthAt(side);
2110
2366
  return Math.min(rest, holdEdgeFrom(rest)(deg));
2111
2367
  }
2112
2368
  )
2113
2369
  );
2114
- const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
2115
- const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
2116
- const holdEdgeAt2 = holdEdgeFrom(holdBase2);
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);
2117
2381
  let foldLowerBound = -Infinity;
2118
2382
  let folds = false;
2119
2383
  for (const deg of HEAD_TURN_STOPS) {
2120
2384
  if (deg === 0) continue;
2121
2385
  const reach = plateReachAt(
2122
- columnMapAt(deg),
2386
+ plateLandingAt,
2123
2387
  ctx.faceCenterX,
2124
- ctx.faceHalfWidth
2388
+ ctx.edgeAt,
2389
+ ctx.plateGuardRows,
2390
+ deg
2125
2391
  );
2126
2392
  foldLowerBound = Math.max(
2127
2393
  foldLowerBound,
2128
- 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2394
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2129
2395
  );
2130
- if (holdEdgeAt2(deg) <= reach) folds = true;
2396
+ if (holdEdgeAt(deg) <= reach) folds = true;
2131
2397
  }
2132
- const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
2133
- const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
2134
- const gridReach2 = [
2135
- Math.abs(map2.warpedX[0] - ctx.faceCenterX),
2136
- Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
2137
- ];
2138
- const cappedHoldEdge = [
2139
- Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
2140
- Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
2141
- ];
2142
2398
  const restAt = (side) => {
2143
2399
  const edge = measuredEdgeAt(side);
2144
2400
  if (edge !== void 0) return edge;
2145
- return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2401
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2146
2402
  };
2147
2403
  const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2148
2404
  x,
2405
+ ctx.eyeRowY,
2149
2406
  ctx.hairFrontSilhouette,
2150
- ctx.faceCenterX,
2151
- ctx.faceHalfWidth,
2152
- holdBase2,
2153
- cappedHoldEdge,
2154
- map2,
2155
- leadTipShift,
2156
- leadRow
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
2157
2418
  );
2158
2419
  const landingOfRole = (role, x) => {
2159
2420
  if (role === "hair_front") return hairFrontAt(x);
@@ -2161,10 +2422,22 @@ function evaluateTurnCandidate(ctx, radius) {
2161
2422
  return x;
2162
2423
  }
2163
2424
  if (role === "body") {
2164
- return x - BODY_TURN_FOLLOW * travel2;
2425
+ return x - BODY_TURN_FOLLOW * travel;
2165
2426
  }
2166
2427
  if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2167
- return map2.mapX(x);
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
+ );
2168
2441
  }
2169
2442
  throw new Error(
2170
2443
  `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
@@ -2180,13 +2453,13 @@ function evaluateTurnCandidate(ctx, radius) {
2180
2453
  };
2181
2454
  return {
2182
2455
  ratio,
2183
- travel: travel2,
2184
- map: map2,
2185
- holdBase: holdBase2,
2186
- holdEdgeAt: holdEdgeAt2,
2456
+ travel,
2457
+ mapAt,
2458
+ surface,
2459
+ holdBase,
2460
+ holdEdgeAt,
2187
2461
  folds,
2188
2462
  foldLowerBound,
2189
- gridReach: gridReach2,
2190
2463
  // How far the silhouette's own centre drifts off the face centre at full
2191
2464
  // turn, against how far it already sat off centre at rest (zero unless
2192
2465
  // hair_front's crop is itself off-centre) — the held shell keeps its own
@@ -2203,24 +2476,18 @@ function evaluateTurnCandidate(ctx, radius) {
2203
2476
  restSpan: restAt(1) - restAt(-1)
2204
2477
  };
2205
2478
  };
2206
- const saturatedHold = () => {
2207
- let hold2 = evaluateHold(1);
2208
- for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
2209
- const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
2210
- if (needed <= hold2.ratio) break;
2211
- hold2 = evaluateHold(needed);
2212
- }
2213
- return hold2;
2214
- };
2215
- const holdRange = () => {
2216
- const ceiling = saturatedHold();
2479
+ const holdRange = (depths2) => {
2480
+ const ceiling = evaluateHold(TURN_RATIO_MAX, depths2);
2217
2481
  if (ceiling.folds) return void 0;
2218
- let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2482
+ let floor = evaluateHold(
2483
+ Math.min(ceiling.foldLowerBound, ceiling.ratio),
2484
+ depths2
2485
+ );
2219
2486
  if (floor.folds) {
2220
2487
  let lo = floor;
2221
2488
  let hi = ceiling;
2222
2489
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2223
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2490
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2224
2491
  if (mid.folds) lo = mid;
2225
2492
  else hi = mid;
2226
2493
  }
@@ -2229,7 +2496,8 @@ function evaluateTurnCandidate(ctx, radius) {
2229
2496
  const samples = [floor];
2230
2497
  for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2231
2498
  const sample = evaluateHold(
2232
- floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2499
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES,
2500
+ depths2
2233
2501
  );
2234
2502
  if (!sample.folds) samples.push(sample);
2235
2503
  }
@@ -2242,15 +2510,14 @@ function evaluateTurnCandidate(ctx, radius) {
2242
2510
  return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2243
2511
  };
2244
2512
  const asked = ctx.silhouetteRatio;
2245
- const natural = evaluateHold(asked);
2246
- const eps = TURN_DEPTH_EPS / natural.restSpan;
2247
- let hold;
2248
- let silhouetteClamped = false;
2249
- if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2250
- hold = natural;
2251
- } else {
2252
- const samples = holdRange();
2253
- if (samples === void 0) return silhouetteMiss();
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;
2254
2521
  const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2255
2522
  const span = [
2256
2523
  Math.min(...rendered),
@@ -2265,91 +2532,145 @@ function evaluateTurnCandidate(ctx, radius) {
2265
2532
  return ea <= eb ? a : b;
2266
2533
  };
2267
2534
  if (asked < span[0] - eps || asked > span[1] + eps) {
2268
- if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2269
2535
  const wanted = asked < span[0] ? span[0] : span[1];
2270
- hold = samples.reduce(
2536
+ const hold = samples.reduce(
2271
2537
  (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2272
2538
  );
2273
- silhouetteClamped = true;
2274
- } else {
2275
- let best = samples.reduce(nearer);
2276
- let lo;
2277
- let hi;
2278
- for (let i = 0; i + 1 < samples.length; i++) {
2279
- const a = samples[i].renderedSilhouetteRatio - asked;
2280
- const b = samples[i + 1].renderedSilhouetteRatio - asked;
2281
- if (a * b <= 0) {
2282
- lo = samples[i];
2283
- hi = samples[i + 1];
2284
- break;
2285
- }
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;
2286
2551
  }
2287
- for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2288
- if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2289
- break;
2290
- }
2291
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2292
- if (!mid.folds) best = nearer(best, mid);
2293
- if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2294
- lo = mid;
2295
- } else {
2296
- hi = mid;
2297
- }
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;
2298
2563
  }
2299
- hold = best;
2300
2564
  }
2301
- }
2302
- if (hold.folds) {
2303
- throw new Error(
2304
- "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
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
2305
2584
  );
2306
- }
2307
- const {
2308
- travel,
2309
- map,
2310
- holdBase,
2311
- holdEdgeAt,
2312
- silhouetteCenterShift,
2313
- renderedSilhouetteRatio
2314
- } = hold;
2315
- const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
2316
- const eye = solveTurnDepthSigned(
2317
- silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2318
- ctx.landmarks.eye,
2319
- unit,
2320
- map,
2321
- holdEdgeX,
2322
- ctx.plateEdgeX
2323
- );
2324
- if (!eye.reached && !ctx.clampEyeShift) {
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);
2325
2657
  return {
2326
- blocked: "eyeShift",
2327
- // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
2328
- // negative lower bound here would advertise a value the field's own
2329
- // contract already rules out; 0 is the true floor.
2330
- offeredShift: [
2331
- Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2332
- (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2333
- ]
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
2334
2672
  };
2335
2673
  }
2336
- const scaleOf = (l) => (map.mapX(l.x + l.w / 2 - eye.depth * unit) - map.mapX(l.x - l.w / 2 - eye.depth * unit)) / l.w;
2337
- const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2338
- const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2339
- return {
2340
- radius,
2341
- ratio: scaleOf(far) / scaleOf(near),
2342
- unit,
2343
- travel,
2344
- map,
2345
- holdBase,
2346
- holdEdgeAt,
2347
- holdEdgeX,
2348
- eye,
2349
- silhouetteClamped,
2350
- silhouetteCenterShift,
2351
- renderedSilhouetteRatio
2352
- };
2353
2674
  }
2354
2675
  function sweepTurnRadii(ctx) {
2355
2676
  const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
@@ -2358,6 +2679,7 @@ function sweepTurnRadii(ctx) {
2358
2679
  let heldRatioLower = Infinity;
2359
2680
  let heldRatioUpper = -Infinity;
2360
2681
  let offeredShift;
2682
+ let unsettled = 0;
2361
2683
  for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2362
2684
  const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2363
2685
  const result = evaluateTurnCandidate(ctx, radius);
@@ -2366,6 +2688,8 @@ function sweepTurnRadii(ctx) {
2366
2688
  } else if (result.blocked === "silhouetteRatio") {
2367
2689
  heldRatioLower = Math.min(heldRatioLower, result.lower);
2368
2690
  heldRatioUpper = Math.max(heldRatioUpper, result.upper);
2691
+ } else if (result.blocked === "settle") {
2692
+ unsettled++;
2369
2693
  } else {
2370
2694
  offeredShift = offeredShift ? [
2371
2695
  Math.min(offeredShift[0], result.offeredShift[0]),
@@ -2373,7 +2697,13 @@ function sweepTurnRadii(ctx) {
2373
2697
  ] : result.offeredShift;
2374
2698
  }
2375
2699
  }
2376
- return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
2700
+ return {
2701
+ candidates,
2702
+ heldRatioLower,
2703
+ heldRatioUpper,
2704
+ offeredShift,
2705
+ unsettled
2706
+ };
2377
2707
  }
2378
2708
  function ratioClusters(candidates) {
2379
2709
  const clusters = [];
@@ -2415,71 +2745,40 @@ function fitTurnRadius(ctx, candidates, target) {
2415
2745
  }
2416
2746
  return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
2417
2747
  }
2418
- function solveFeatureDepths(ctx, targets, best, eyeShift) {
2419
- const clamped = [];
2420
- const solved = {};
2421
- const families = [
2422
- ["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
2423
- ["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
2424
- ];
2425
- for (const [field, marks, share] of families) {
2426
- const m = Math.abs(
2427
- targets.defaulted.has(field) ? share * eyeShift : targets[field]
2428
- );
2429
- const solution = solveTurnDepthSigned(
2430
- best.silhouetteCenterShift - m * ctx.hh,
2431
- marks,
2432
- best.unit,
2433
- best.map,
2434
- best.holdEdgeX,
2435
- ctx.plateEdgeX
2436
- );
2437
- if (!solution.reached) {
2438
- if (!targets.defaulted.has(field)) {
2439
- return {
2440
- blocked: field,
2441
- // noseShift/mouthShift are MAGNITUDES too (see the correction
2442
- // above), so a negative lower bound here would advertise a value
2443
- // the field's own contract already rules out; 0 is the true floor.
2444
- attainable: [
2445
- Math.max(
2446
- 0,
2447
- (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2448
- ),
2449
- (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2450
- ]
2451
- };
2452
- }
2453
- clamped.push(field);
2454
- }
2455
- solved[field] = solution.depth;
2456
- }
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
+ );
2457
2756
  return {
2458
- nose: solved.noseShift,
2459
- mouth: solved.mouthShift,
2460
- clamped
2757
+ nose: solve(ctx.noseShift, ctx.landmarks.nose, NOSE_SHIFT_SHARE),
2758
+ mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2461
2759
  };
2462
2760
  }
2463
- function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
2761
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
2464
2762
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2465
2763
  throw new TurnTargetError(
2466
2764
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2467
2765
  );
2468
2766
  }
2469
- const eyeRowY = landmarks.eye.length === 2 && landmarks.eye[0].y !== void 0 && landmarks.eye[1].y !== void 0 ? (landmarks.eye[0].y + landmarks.eye[1].y) / 2 : void 0;
2470
- const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
2471
- 0,
2472
- Math.min(
2473
- 1,
2474
- (hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
2475
- )
2476
- );
2767
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
2477
2768
  const ctx = {
2478
2769
  landmarks,
2479
- faceGrid,
2770
+ lattice,
2480
2771
  faceCenterX,
2772
+ faceCenterY,
2773
+ nodRadius,
2774
+ eyeRowY,
2481
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.
2482
2778
  plateEdgeX: faceCenterX - faceHalfWidth,
2779
+ carriers,
2780
+ profile,
2781
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
2483
2782
  // What this plate asks the turn for; each candidate caps it to its own
2484
2783
  // shell — see TurnSolveContext.travel.
2485
2784
  travel: headTurnTravel(faceHalfWidth),
@@ -2492,9 +2791,10 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2492
2791
  // every downstream use already has the magnitude.
2493
2792
  eyeShift: Math.abs(targets.eyeShift),
2494
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),
2495
2796
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2496
- hairFrontLeadFraction,
2497
- hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
2797
+ hairFrontSilhouette: hairFront,
2498
2798
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2499
2799
  headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
2500
2800
  };
@@ -2502,6 +2802,12 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2502
2802
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2503
2803
  const pass = sweepTurnRadii(ctx);
2504
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
+ }
2505
2811
  return pass.offeredShift ? {
2506
2812
  unreachable: true,
2507
2813
  field: "eyeShift",
@@ -2563,14 +2869,29 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2563
2869
  if (!best.eye.reached) clamped.push("eyeShift");
2564
2870
  if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2565
2871
  const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2566
- const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2567
- if ("blocked" in features) {
2568
- return {
2569
- unreachable: true,
2570
- field: features.blocked,
2571
- value: targets[features.blocked],
2572
- attainable: features.attainable
2573
- };
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);
2574
2895
  }
2575
2896
  return {
2576
2897
  unreachable: false,
@@ -2578,13 +2899,18 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2578
2899
  holdBase: best.holdBase,
2579
2900
  holdEdgeAt: best.holdEdgeAt,
2580
2901
  travel: best.travel,
2581
- depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2902
+ surface: best.surface,
2903
+ depths: {
2904
+ eye: best.eye.depth,
2905
+ nose: best.nose.depth,
2906
+ mouth: best.mouth.depth
2907
+ },
2582
2908
  achieved: {
2583
2909
  eyeShift,
2584
2910
  farEyeRatio: best.ratio,
2585
2911
  silhouetteRatio: best.renderedSilhouetteRatio
2586
2912
  },
2587
- clamped: [...clamped, ...features.clamped]
2913
+ clamped
2588
2914
  };
2589
2915
  }
2590
2916
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
@@ -2621,27 +2947,16 @@ function featureParallaxBindings(role, options) {
2621
2947
  if (!options.hasNose) return [];
2622
2948
  const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2623
2949
  if (nodDepth === void 0) return [];
2624
- const bindings = [];
2625
- const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2626
- const shiftX = turnDepth * (options.parallaxUnit ?? 0);
2627
- if (shiftX !== 0) {
2628
- bindings.push({
2629
- parameter: StandardParameter.AngleX,
2630
- channel: "translateX",
2631
- from: -shiftX,
2632
- to: shiftX
2633
- });
2634
- }
2635
2950
  const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2636
- if (shiftY !== 0) {
2637
- bindings.push({
2951
+ if (shiftY === 0) return [];
2952
+ return [
2953
+ {
2638
2954
  parameter: StandardParameter.AngleY,
2639
2955
  channel: "translateY",
2640
2956
  from: -shiftY,
2641
2957
  to: shiftY
2642
- });
2643
- }
2644
- return bindings;
2958
+ }
2959
+ ];
2645
2960
  }
2646
2961
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2647
2962
  return [
@@ -2749,20 +3064,17 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
2749
3064
  ];
2750
3065
  }
2751
3066
  }
2752
- if (role === "hair_front" || role === "hair_back") {
2753
- const isFront = role === "hair_front";
2754
- const parallax = [];
2755
- const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2756
- const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2757
- if (shiftY !== 0) {
2758
- 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
+ {
2759
3072
  parameter: StandardParameter.AngleY,
2760
3073
  channel: "translateY",
2761
3074
  from: -shiftY,
2762
3075
  to: shiftY
2763
- });
2764
- }
2765
- return parallax;
3076
+ }
3077
+ ];
2766
3078
  }
2767
3079
  return [];
2768
3080
  }
@@ -2785,64 +3097,98 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2785
3097
  ]
2786
3098
  };
2787
3099
  }
2788
- function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
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) {
2789
3113
  let reach = 0;
2790
3114
  for (const deg of HEAD_TURN_STOPS) {
2791
3115
  reach = Math.max(
2792
3116
  reach,
2793
- plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
3117
+ plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
2794
3118
  );
2795
3119
  }
2796
3120
  return reach;
2797
3121
  }
2798
- function plateReachAt(map, faceCenterX, faceHalfWidth) {
3122
+ function plateSideReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg, side) {
2799
3123
  let reach = 0;
2800
- for (const side of [-1, 1]) {
2801
- const dest = map.mapX(faceCenterX + side * faceHalfWidth);
3124
+ for (const y of plateGuardRows) {
3125
+ const dest = plateLandingAt(deg, faceCenterX + side * edgeAt(y), y);
2802
3126
  reach = Math.max(reach, Math.abs(dest - faceCenterX));
2803
3127
  }
2804
3128
  return reach;
2805
3129
  }
2806
- function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
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) {
2807
3151
  if (holdEdgeAt(0) !== holdBase) {
2808
3152
  throw new Error(
2809
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`
2810
3154
  );
2811
3155
  }
2812
- const SIDES = [-1, 1];
2813
3156
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2814
- const map = columnMapAt(deg);
2815
- const requestedHoldEdge = holdEdgeAt(deg);
2816
- const plateDest = SIDES.map(
2817
- (side) => map.mapX(faceCenterX + side * faceHalfWidth)
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
+ )
2818
3167
  );
2819
- const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
2820
- if (requestedHoldEdge <= reached) {
2821
- const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
3168
+ const reached = Math.max(reach[0], reach[1]);
3169
+ if (holdEdge <= reached) {
3170
+ const side = reach[1] > reach[0] ? 1 : -1;
2822
3171
  throw new Error(
2823
- `auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${requestedHoldEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
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`
2824
3173
  );
2825
3174
  }
2826
- const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
2827
- (_side, idx) => Math.min(
2828
- requestedHoldEdge,
2829
- Math.abs(
2830
- map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
2831
- ) - HOLD_CLEARANCE
2832
- )
2833
- );
2834
3175
  const offsets = [];
2835
3176
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2836
3177
  const x = partX + mesh.vertices[i];
2837
- const target = hairFrontHoldTarget(
2838
- x,
2839
- faceCenterX,
2840
- faceHalfWidth,
2841
- holdBase,
2842
- holdEdge,
2843
- map
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
2844
3191
  );
2845
- offsets.push(map.invertX(target) - x, 0);
2846
3192
  }
2847
3193
  return { value: deg, offsets };
2848
3194
  });
@@ -2851,7 +3197,6 @@ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, ho
2851
3197
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2852
3198
  var HAIR_SWAY_CURL = 1.5;
2853
3199
  var HAIR_SWAY_RANGE = 20;
2854
- var HAIR_SWAY_PEAK_FRACTION = 0.56;
2855
3200
  function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2856
3201
  let top = -Infinity;
2857
3202
  let bottom = Infinity;
@@ -2881,112 +3226,378 @@ function headTurnTravel(faceHalfWidth) {
2881
3226
  var BODY_TURN_FOLLOW = 0.3;
2882
3227
  var HEAD_BREATH_BOB = -12;
2883
3228
  var BODY_BREATH_FOLLOW = 0.5;
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
+ }
2884
3596
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
2885
3597
  validateLayerInputs(layers, canvas);
2886
3598
  const hasHair = layers.some((l) => l.role === "hair_front");
2887
- const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2888
- const hasNose = layers.some((l) => l.role === "nose");
2889
3599
  const bodyLayer = layers.find((l) => l.role === "body");
2890
- const parameters = [
2891
- {
2892
- id: StandardParameter.MouthOpen,
2893
- name: "Mouth Open",
2894
- min: 0,
2895
- max: 1,
2896
- default: 0
2897
- },
2898
- {
2899
- id: StandardParameter.MouthForm,
2900
- name: "Mouth Form",
2901
- min: -1,
2902
- max: 1,
2903
- default: 0
2904
- },
2905
- {
2906
- id: StandardParameter.EyeOpenLeft,
2907
- name: "Eye L",
2908
- min: 0,
2909
- max: 1,
2910
- default: 1
2911
- },
2912
- {
2913
- id: StandardParameter.EyeOpenRight,
2914
- name: "Eye R",
2915
- min: 0,
2916
- max: 1,
2917
- default: 1
2918
- },
2919
- {
2920
- id: StandardParameter.EyeballX,
2921
- name: "Gaze X",
2922
- min: -1,
2923
- max: 1,
2924
- default: 0
2925
- },
2926
- {
2927
- id: StandardParameter.EyeballY,
2928
- name: "Gaze Y",
2929
- min: -1,
2930
- max: 1,
2931
- default: 0
2932
- },
2933
- {
2934
- id: StandardParameter.AngleX,
2935
- name: "Head Angle",
2936
- min: -30,
2937
- max: 30,
2938
- default: 0
2939
- },
2940
- {
2941
- id: StandardParameter.AngleY,
2942
- name: "Head Angle Y",
2943
- min: -30,
2944
- max: 30,
2945
- default: 0
2946
- },
2947
- {
2948
- id: StandardParameter.AngleZ,
2949
- name: "Head Angle Z",
2950
- min: -30,
2951
- max: 30,
2952
- default: 0
2953
- },
2954
- {
2955
- id: StandardParameter.Breath,
2956
- name: "Breath",
2957
- min: 0,
2958
- max: 1,
2959
- default: 0
2960
- },
2961
- {
2962
- id: StandardParameter.BrowLeftY,
2963
- name: "Brow L Y",
2964
- min: -1,
2965
- max: 1,
2966
- default: 0
2967
- },
2968
- {
2969
- id: StandardParameter.BrowRightY,
2970
- name: "Brow R Y",
2971
- min: -1,
2972
- max: 1,
2973
- default: 0
2974
- },
2975
- {
2976
- id: StandardParameter.BrowLeftAngle,
2977
- name: "Brow L Angle",
2978
- min: -1,
2979
- max: 1,
2980
- default: 0
2981
- },
2982
- {
2983
- id: StandardParameter.BrowRightAngle,
2984
- name: "Brow R Angle",
2985
- min: -1,
2986
- max: 1,
2987
- default: 0
2988
- }
2989
- ];
3600
+ const parameters = [...STANDARD_PARAMETERS];
2990
3601
  if (hasHair) {
2991
3602
  parameters.push(
2992
3603
  {
@@ -3005,86 +3616,28 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3005
3616
  }
3006
3617
  );
3007
3618
  }
3008
- const faceLayers = layers.filter((l) => l.role === "face");
3009
- const faceLayer = faceLayers[0];
3010
- const faceTransform = bboxToTransform(
3011
- faceLayer.bbox,
3012
- faceLayer.canvasW,
3013
- faceLayer.canvasH,
3014
- "face"
3015
- );
3016
- const faceCenterX = faceTransform.x;
3017
- const faceCropH = faceLayer.cropH;
3018
- const faceWarpLayers = layers.filter(
3019
- (l) => ROLE_TABLE[l.role].deformer === "faceWarp"
3020
- );
3021
- let unionMinX = -canvas.width / 2;
3022
- let unionMaxX = canvas.width / 2;
3023
- let unionMinY = -canvas.height / 2;
3024
- let unionMaxY = canvas.height / 2;
3025
- if (faceWarpLayers.length > 0) {
3026
- const transforms = faceWarpLayers.map(
3027
- (l) => bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role)
3028
- );
3029
- unionMinX = Math.min(
3030
- ...transforms.map((t, i) => t.x - faceWarpLayers[i].cropW / 2)
3031
- );
3032
- unionMaxX = Math.max(
3033
- ...transforms.map((t, i) => t.x + faceWarpLayers[i].cropW / 2)
3034
- );
3035
- unionMinY = Math.min(
3036
- ...transforms.map((t, i) => t.y - faceWarpLayers[i].cropH / 2)
3037
- );
3038
- unionMaxY = Math.max(
3039
- ...transforms.map((t, i) => t.y + faceWarpLayers[i].cropH / 2)
3040
- );
3041
- const spanX = unionMaxX - unionMinX;
3042
- const spanY = unionMaxY - unionMinY;
3043
- const MARGIN = 0.12;
3044
- unionMinX -= spanX * MARGIN;
3045
- unionMaxX += spanX * MARGIN;
3046
- unionMinY -= spanY * MARGIN;
3047
- unionMaxY += spanY * MARGIN;
3048
- }
3049
- const halfW = Math.max(faceCenterX - unionMinX, unionMaxX - faceCenterX);
3050
- const faceGridMinX = faceCenterX - halfW;
3051
- const faceGridMaxX = faceCenterX + halfW;
3052
- const faceGrid = {
3053
- cols: FACE_GRID_CELLS,
3054
- rows: FACE_GRID_CELLS,
3055
- points: generateGridPoints(
3056
- FACE_GRID_CELLS,
3057
- FACE_GRID_CELLS,
3058
- faceGridMinX,
3059
- faceGridMaxX,
3060
- unionMinY,
3061
- unionMaxY
3062
- )
3063
- };
3064
- const faceCenterY = faceTransform.y;
3065
- const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3066
- const faceHalfWidth = faceLayer.cropW / 2;
3067
- const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3068
- const turn = hasNose ? solveTurnModel(
3069
- resolveTurnTargets(options.turnTargets),
3070
- turnLandmarks(layers),
3071
- faceGrid,
3619
+ const setup = turnSetup(layers);
3620
+ const {
3621
+ lattice,
3072
3622
  faceCenterX,
3073
3623
  faceHalfWidth,
3074
- hairFrontLayer && (() => {
3075
- const t = bboxToTransform(
3076
- hairFrontLayer.bbox,
3077
- hairFrontLayer.canvasW,
3078
- hairFrontLayer.canvasH,
3079
- "hair_front"
3080
- );
3081
- return {
3082
- x: t.x,
3083
- centerY: t.y,
3084
- cropW: hairFrontLayer.cropW,
3085
- cropH: hairFrontLayer.cropH
3086
- };
3087
- })(),
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),
3088
3641
  options.headEdges
3089
3642
  ) : void 0;
3090
3643
  if (turn?.unreachable) {
@@ -3106,25 +3659,28 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3106
3659
  clamped: turn.clamped
3107
3660
  });
3108
3661
  }
3109
- const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
3110
- const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
3111
- const parallaxUnit = headTurnParallaxUnit(faceRadius);
3112
- const parallaxUnitY = headNodParallaxUnit(halfH);
3113
- const faceBottom = faceTransform.y - faceCropH / 2;
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;
3114
3679
  const neckPivot = {
3115
3680
  x: faceCenterX,
3116
3681
  y: faceBottom - faceCropH * 0.15
3117
3682
  // 15% below face bottom = neck
3118
3683
  };
3119
- const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
3120
- faceGrid,
3121
- StandardParameter.AngleX,
3122
- StandardParameter.AngleY,
3123
- faceCenterX,
3124
- faceCenterY,
3125
- faceRadius,
3126
- headTravel
3127
- );
3128
3684
  const deformers = [
3129
3685
  // headDeformer: rigid matrix rotating/translating the whole head about the
3130
3686
  // neck pivot. Same bindings as sample-model.ts apart from the turn, which
@@ -3134,14 +3690,14 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3134
3690
  pivot: neckPivot,
3135
3691
  bindings: [
3136
3692
  // No AngleX binding: the turn moves NOTHING rigidly. Its sideways
3137
- // travel is baked into faceWarp's own grid (see
3138
- // bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
3139
- // a silhouette the bangs hold; a translate here would take the hair
3140
- // shell with it. Nor is the turn a roll — the sample model's ±6° "lean
3141
- // into the turn" was tried here and dropped, since rotating about the
3142
- // neck pivot swings the crown (~500px above it) far more than the chin,
3143
- // so the top of the head appeared to lunge ahead of the face on every
3144
- // turn. Roll is AngleZ's job, below.
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.
3145
3701
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
3146
3702
  // rigid rotation would sum with the AngleZ roll below at diagonal
3147
3703
  // poses, collapsing pitch into roll.
@@ -3170,19 +3726,25 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3170
3726
  to: HEAD_BREATH_BOB
3171
3727
  }
3172
3728
  ]
3173
- },
3174
- // faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
3175
- // about faceCenterX so the bake's cylinder axis aligns with the face center.
3176
- // One 2D warp carries both the turn and the nod (a deformer holds either
3177
- // `warps` or `warp2d`, never both).
3178
- {
3179
- kind: "warp",
3180
- id: "faceWarp",
3181
- parent: "headDeformer",
3182
- grid: faceGrid,
3183
- warp2d: faceWarp2d
3184
3729
  }
3185
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
+ }
3186
3748
  if (bodyLayer) {
3187
3749
  const bt = bboxToTransform(
3188
3750
  bodyLayer.bbox,
@@ -3199,8 +3761,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3199
3761
  {
3200
3762
  parameter: StandardParameter.AngleX,
3201
3763
  channel: "translateX",
3202
- from: -BODY_TURN_FOLLOW * headTravel,
3203
- to: BODY_TURN_FOLLOW * headTravel
3764
+ from: -BODY_TURN_FOLLOW * surface.travel,
3765
+ to: BODY_TURN_FOLLOW * surface.travel
3204
3766
  },
3205
3767
  // Breath: follow the head's bob, same direction, half amplitude.
3206
3768
  {
@@ -3212,34 +3774,21 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3212
3774
  ]
3213
3775
  });
3214
3776
  }
3215
- const eyeCreaseBySide = {};
3216
- for (const layer of layers) {
3217
- const side = ROLE_TABLE[layer.role].eyeSide;
3218
- if ((layer.role === "eye_L" || layer.role === "eye_R") && side) {
3219
- const ey = bboxToTransform(
3220
- layer.bbox,
3221
- layer.canvasW,
3222
- layer.canvasH,
3223
- layer.role
3224
- ).y;
3225
- eyeCreaseBySide[side] = ey - EYELID_FOLD_CREASE * layer.cropH;
3226
- }
3227
- }
3777
+ const memberByRole = new Map(members.map((m) => [m.role, m]));
3228
3778
  const parts = layers.map((layer) => {
3229
3779
  const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
3230
3780
  const spec = ROLE_TABLE[role];
3231
3781
  const t = bboxToTransform(bbox, canvasW, canvasH, role);
3232
3782
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
3233
3783
  hasMouthOpen,
3234
- parallaxUnit,
3235
3784
  parallaxUnitY,
3236
- hasNose,
3237
- turnDepths: turn?.depths
3785
+ hasNose
3238
3786
  });
3239
3787
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
3240
3788
  if (spec.mesh) {
3789
+ const member = memberByRole.get(role);
3241
3790
  const { cols, rows } = meshCellsFor(cropW, cropH);
3242
- const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
3791
+ const mesh = member?.mesh ?? createPixelGridMesh(cols, rows, cropW, cropH);
3243
3792
  const part = {
3244
3793
  id: role,
3245
3794
  color: [1, 1, 1, 1],
@@ -3254,35 +3803,38 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3254
3803
  part.bindings = roleBindings;
3255
3804
  }
3256
3805
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
3257
- let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3258
- let leadWarp;
3259
- let silhouetteWarp;
3806
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3807
+ const ownWarps = [];
3260
3808
  if (role === "hair_front") {
3261
- const shift = HAIR_FRONT_DEPTH * parallaxUnit;
3262
- const headroom = Math.min(
3263
- faceGridMaxX - (t.x + cropW / 2),
3264
- t.x - cropW / 2 - faceGridMinX
3265
- ) - shift;
3266
- tipShift = Math.min(
3267
- tipShift,
3268
- 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
+ )
3269
3816
  );
3270
- leadWarp = bakeHairSwayWarp(
3271
- mesh,
3272
- StandardParameter.AngleX,
3273
- shift,
3274
- 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
+ )
3275
3830
  );
3276
- const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
3277
- const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
3278
- silhouetteWarp = bakeHairFrontSilhouetteWarp(
3279
- mesh,
3280
- t.x,
3281
- faceCenterX,
3282
- faceHalfWidth,
3283
- holdBase,
3284
- turn?.holdEdgeAt ?? (() => holdBase),
3285
- columnMapAt
3831
+ ownWarps.push(
3832
+ bakeNodWarp(
3833
+ mesh,
3834
+ t.y,
3835
+ surface,
3836
+ hasNose ? HAIR_FRONT_NOD_DEPTH * parallaxUnitY : 0
3837
+ )
3286
3838
  );
3287
3839
  }
3288
3840
  part.warps = [
@@ -3298,24 +3850,12 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3298
3850
  tipShift,
3299
3851
  HAIR_SWAY_RANGE
3300
3852
  ),
3301
- ...leadWarp ? [leadWarp] : [],
3302
- ...silhouetteWarp ? [silhouetteWarp] : []
3853
+ ...ownWarps
3303
3854
  ];
3304
3855
  }
3305
3856
  if (spec.eyeSide !== void 0) {
3306
- const isLash = role.startsWith("lash_");
3307
- if (role.startsWith("eye_") || isLash) {
3308
- const openParam = spec.eyeSide === "L" ? StandardParameter.EyeOpenLeft : StandardParameter.EyeOpenRight;
3309
- const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
3310
- const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
3311
- part.warps = [
3312
- bakeEyelidFoldWarp(
3313
- mesh,
3314
- openParam,
3315
- creaseWorldY - t.y,
3316
- isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3317
- )
3318
- ];
3857
+ if (role.startsWith("eye_") || role.startsWith("lash_")) {
3858
+ part.warps = [...member.warps];
3319
3859
  } else {
3320
3860
  part.clip = { masks: [`eye_${spec.eyeSide}`] };
3321
3861
  }