@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.js CHANGED
@@ -1664,6 +1664,21 @@ function createDefaultWarpDeformer(model) {
1664
1664
 
1665
1665
  // src/auto-rig.ts
1666
1666
  var import_format3 = require("@ikijs/format");
1667
+ var TURN_GROUP_IDS = [
1668
+ "faceWarp",
1669
+ "eyeWarp_L",
1670
+ "eyeWarp_R",
1671
+ "mouthWarp",
1672
+ "noseWarp",
1673
+ "browWarp_L",
1674
+ "browWarp_R",
1675
+ "blushWarp_L",
1676
+ "blushWarp_R"
1677
+ ];
1678
+ var TURN_GROUP_ID_SET = new Set(TURN_GROUP_IDS);
1679
+ function isTurnGroup(id) {
1680
+ return TURN_GROUP_ID_SET.has(id);
1681
+ }
1667
1682
  var ROLE_TABLE = {
1668
1683
  // The silhouette behind the face. It rides the rigid head (not faceWarp,
1669
1684
  // whose grid it would stretch across the shoulders) as a MESH, so its ends
@@ -1677,34 +1692,43 @@ var ROLE_TABLE = {
1677
1692
  // reading as a floating head. Drawn over the back hair so long hair falls
1678
1693
  // behind the shoulders.
1679
1694
  body: { deformer: "bodyDeformer", order: 5, mesh: false },
1695
+ // The face plate and everything painted on it ride per-group turn warps
1696
+ // under the head (TurnGroupId): the plate its own, each eye stack its
1697
+ // side's, both mouth drawings one, the nose, each brow and each blush their
1698
+ // own. A group's grid is sized to its members alone and baked from the one
1699
+ // solved surface with the family's turn depth in its keyforms, so a feature
1700
+ // slides across the plate on its own grid instead of a translate binding
1701
+ // (see generateIkiFromLayerSet).
1680
1702
  face: { deformer: "faceWarp", order: 10, mesh: true },
1681
- nose: { deformer: "faceWarp", order: 15, mesh: true },
1682
- blush_L: { deformer: "faceWarp", order: 20, mesh: true },
1683
- blush_R: { deformer: "faceWarp", order: 20, mesh: true },
1684
- mouth: { deformer: "faceWarp", order: 25, mesh: true },
1703
+ nose: { deformer: "noseWarp", order: 15, mesh: true },
1704
+ blush_L: { deformer: "blushWarp_L", order: 20, mesh: true },
1705
+ blush_R: { deformer: "blushWarp_R", order: 20, mesh: true },
1706
+ mouth: { deformer: "mouthWarp", order: 25, mesh: true },
1685
1707
  // An OPTIONAL second mouth drawing, open. When present the two cross-fade on
1686
1708
  // MouthOpen instead of the closed one being stretched, which is the
1687
1709
  // difference between a portrait rig and one that can lip-sync.
1688
- mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
1689
- eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
1690
- eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
1691
- iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
1692
- iris_R: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "R" },
1693
- pupil_L: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "L" },
1694
- pupil_R: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "R" },
1695
- highlight_L: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "L" },
1696
- highlight_R: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "R" },
1710
+ mouth_open: { deformer: "mouthWarp", order: 26, mesh: true },
1711
+ eye_L: { deformer: "eyeWarp_L", order: 30, mesh: true, eyeSide: "L" },
1712
+ eye_R: { deformer: "eyeWarp_R", order: 30, mesh: true, eyeSide: "R" },
1713
+ iris_L: { deformer: "eyeWarp_L", order: 31, mesh: true, eyeSide: "L" },
1714
+ iris_R: { deformer: "eyeWarp_R", order: 31, mesh: true, eyeSide: "R" },
1715
+ pupil_L: { deformer: "eyeWarp_L", order: 32, mesh: true, eyeSide: "L" },
1716
+ pupil_R: { deformer: "eyeWarp_R", order: 32, mesh: true, eyeSide: "R" },
1717
+ highlight_L: { deformer: "eyeWarp_L", order: 33, mesh: true, eyeSide: "L" },
1718
+ highlight_R: { deformer: "eyeWarp_R", order: 33, mesh: true, eyeSide: "R" },
1697
1719
  // Upper lashes: an OPTIONAL separate layer ABOVE the iris that folds down to
1698
1720
  // the closed-eye seam (the same crease the white folds to), covering the cut
1699
1721
  // eyeball cleanly. When absent, the white's own fold is the only closed line.
1700
- lash_L: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "L" },
1701
- lash_R: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "R" },
1702
- brow_L: { deformer: "faceWarp", order: 40, mesh: true },
1703
- brow_R: { deformer: "faceWarp", order: 40, mesh: true },
1704
- // Front hair rides faceWarp (mesh) so it follows the head-turn curvature with
1705
- // the face instead of detaching as a rigid blob; its bbox joins the faceWarp
1706
- // grid union so the grid covers it.
1707
- hair_front: { deformer: "faceWarp", order: 50, mesh: true }
1722
+ lash_L: { deformer: "eyeWarp_L", order: 34, mesh: true, eyeSide: "L" },
1723
+ lash_R: { deformer: "eyeWarp_R", order: 34, mesh: true, eyeSide: "R" },
1724
+ brow_L: { deformer: "browWarp_L", order: 40, mesh: true },
1725
+ brow_R: { deformer: "browWarp_R", order: 40, mesh: true },
1726
+ // The bangs ride the rigid head, like the back hair, and bind to no grid:
1727
+ // every motion of their own — the silhouette hold, the turn lead, the nod
1728
+ // and the sway — is a per-vertex warp attached in generateIkiFromLayerSet,
1729
+ // so they draw the head's outline where the plate under them bends and
1730
+ // slides (see bakeHairFrontSilhouetteWarp).
1731
+ hair_front: { deformer: "headDeformer", order: 50, mesh: true }
1708
1732
  };
1709
1733
  var REQUIRED_ROLES = ["face", "eye_L", "eye_R", "mouth"];
1710
1734
  var ALIAS_MAP = {
@@ -1792,6 +1816,27 @@ function validateLayerInputs(layers, canvas) {
1792
1816
  `auto-rig: validateLayerInputs: role "${role}" canvas size (${canvasW}\xD7${canvasH}) does not match canvas arg (${canvas.width}\xD7${canvas.height})`
1793
1817
  );
1794
1818
  }
1819
+ if (layer.rowHalfWidths !== void 0) {
1820
+ const rows = layer.rowHalfWidths;
1821
+ if (!Array.isArray(rows)) {
1822
+ throw new Error(
1823
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths must be an array with one entry per crop row`
1824
+ );
1825
+ }
1826
+ if (rows.length !== cropH) {
1827
+ throw new Error(
1828
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths has ${rows.length} entries, not one per crop row (cropH ${cropH})`
1829
+ );
1830
+ }
1831
+ for (let i = 0; i < rows.length; i++) {
1832
+ const a = rows[i];
1833
+ if (typeof a !== "number" || !Number.isFinite(a) || a < 0 || a > cropW / 2) {
1834
+ throw new Error(
1835
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths[${i}] is ${String(a)}, not a finite number in [0, ${cropW / 2}] (half the crop's width)`
1836
+ );
1837
+ }
1838
+ }
1839
+ }
1795
1840
  }
1796
1841
  }
1797
1842
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1847,20 +1892,16 @@ function meshCellsFor(w, h) {
1847
1892
  );
1848
1893
  return { cols: cells(w), rows: cells(h) };
1849
1894
  }
1850
- var FACE_GRID_CELLS = 6;
1895
+ var FACE_PLATE_CELLS = 10;
1896
+ var FEATURE_GRID_CELLS = 4;
1897
+ var TURN_LATTICE_CELL_PX = 16;
1898
+ var GRID_MARGIN = 0.12;
1851
1899
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1852
1900
  var HEAD_TURN_MAX_DEG = 30;
1853
1901
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1854
1902
  function headTurnParallaxUnit(radius) {
1855
1903
  return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1856
1904
  }
1857
- function gridReach(grid, axis, center) {
1858
- let reach = 0;
1859
- for (let i = axis; i < grid.points.length; i += 2) {
1860
- reach = Math.max(reach, Math.abs(grid.points[i] - center));
1861
- }
1862
- return reach;
1863
- }
1864
1905
  function pinnedCylinderBend(local, radius, theta) {
1865
1906
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1866
1907
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
@@ -1873,26 +1914,101 @@ function boundedCylinderBend(local, radius, theta) {
1873
1914
  function turnSlide(travel, angleX) {
1874
1915
  return travel * angleX / HEAD_TURN_MAX_DEG;
1875
1916
  }
1876
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
1917
+ var FACE_ROW_MIN_FRACTION = 0.15;
1918
+ var FACE_ROW_SMOOTHING = 0.05;
1919
+ var CHIN_SWING = 0.08;
1920
+ function faceRowProfile(face, faceCenterY) {
1921
+ const raw = face.rowHalfWidths;
1922
+ if (raw === void 0) return void 0;
1923
+ const n = raw.length;
1924
+ let aMax = 0;
1925
+ let widest = -1;
1926
+ for (let i = 0; i < n; i++) {
1927
+ if (raw[i] > 0 && raw[i] >= aMax) {
1928
+ aMax = raw[i];
1929
+ widest = i;
1930
+ }
1931
+ }
1932
+ if (widest < 0) return void 0;
1933
+ const filled = [];
1934
+ for (let i = 0; i < n; i++) {
1935
+ filled.push(i < widest ? aMax : raw[i] > 0 ? raw[i] : filled[i - 1]);
1936
+ }
1937
+ const half = Math.max(1, Math.round(FACE_ROW_SMOOTHING * n / 2));
1938
+ const floor = FACE_ROW_MIN_FRACTION * aMax;
1939
+ const rows = [];
1940
+ for (let i = 0; i < n; i++) {
1941
+ if (i < widest) {
1942
+ rows.push(aMax);
1943
+ continue;
1944
+ }
1945
+ let sum = 0;
1946
+ for (let k = i - half; k <= i + half; k++) {
1947
+ sum += filled[Math.max(0, Math.min(n - 1, k))];
1948
+ }
1949
+ rows.push(Math.max(floor, sum / (2 * half + 1)));
1950
+ }
1951
+ const top = faceCenterY + face.cropH / 2;
1952
+ const rowAt = (y) => Math.max(0, Math.min(n - 1, top - y - 0.5));
1953
+ return {
1954
+ at: (y) => {
1955
+ const r = rowAt(y);
1956
+ const i = Math.floor(r);
1957
+ if (i + 1 >= n) return rows[n - 1];
1958
+ return rows[i] + (rows[i + 1] - rows[i]) * (r - i);
1959
+ },
1960
+ aMax,
1961
+ widestY: top - widest - 0.5,
1962
+ faceHalfWidth: face.cropW / 2
1963
+ };
1964
+ }
1965
+ function turnSurface(spec) {
1966
+ const { faceCenterX, faceCenterY, radius, travel, nodRadius, lattice } = spec;
1967
+ const { profile } = spec;
1968
+ let radiusAt = (_y) => radius;
1969
+ let swingAt = (_y) => 0;
1970
+ if (profile !== void 0) {
1971
+ const aEye = profile.at(spec.eyeRowY ?? faceCenterY);
1972
+ radiusAt = (y) => radius * (profile.at(y) / aEye);
1973
+ swingAt = (y) => y < profile.widestY ? -CHIN_SWING * profile.faceHalfWidth * Math.max(0, 1 - profile.at(y) / profile.aMax) : 0;
1974
+ }
1975
+ return {
1976
+ faceCenterX,
1977
+ faceCenterY,
1978
+ radius,
1979
+ travel,
1980
+ nodRadius,
1981
+ lattice,
1982
+ radiusAt,
1983
+ swingAt,
1984
+ mapAt: (deg, y) => turnColumnMap(
1985
+ lattice,
1986
+ faceCenterX,
1987
+ radiusAt(y),
1988
+ deg,
1989
+ travel + swingAt(y)
1990
+ ),
1991
+ nodBendAt: (y, angleY) => pinnedCylinderBend(
1992
+ y - faceCenterY,
1993
+ nodRadius,
1994
+ angleY * NOD_BEND * (Math.PI / 180)
1995
+ )
1996
+ };
1997
+ }
1998
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
1877
1999
  const STOPS = [...HEAD_TURN_STOPS];
1878
- const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1879
2000
  const pointCount = grid.points.length / 2;
1880
- const DEG_TO_RAD = Math.PI / 180;
1881
2001
  const keyforms2d = [];
1882
2002
  for (const angleY of STOPS) {
1883
- const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1884
2003
  for (const angleX of STOPS) {
1885
- const thetaX = angleX * DEG_TO_RAD;
1886
- const slide = turnSlide(travel, angleX);
2004
+ const shift = shiftAt(angleX);
1887
2005
  const offsets = [];
1888
2006
  for (let i = 0; i < pointCount; i++) {
2007
+ const x = grid.points[i * 2];
2008
+ const y = grid.points[i * 2 + 1];
1889
2009
  offsets.push(
1890
- boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
1891
- pinnedCylinderBend(
1892
- grid.points[i * 2 + 1] - centerY,
1893
- RADIUS_Y,
1894
- thetaY
1895
- )
2010
+ angleX === 0 ? 0 : surface.mapAt(angleX, y).mapX(x + shift) - x,
2011
+ angleY === 0 ? 0 : surface.nodBendAt(y, angleY)
1896
2012
  );
1897
2013
  }
1898
2014
  keyforms2d.push({ offsets });
@@ -1924,30 +2040,147 @@ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1924
2040
  );
1925
2041
  }
1926
2042
  const cellFor = (v, edges) => {
1927
- for (let c = 0; c < grid.cols; c++) {
1928
- if (v < edges[c + 1]) return c;
1929
- }
1930
- return grid.cols - 1;
2043
+ let lo = 0;
2044
+ let hi = grid.cols - 1;
2045
+ while (lo < hi) {
2046
+ const mid = lo + hi >> 1;
2047
+ if (v < edges[mid + 1]) hi = mid;
2048
+ else lo = mid + 1;
2049
+ }
2050
+ return lo;
2051
+ };
2052
+ const sample = (edges, values, v) => {
2053
+ const c = cellFor(v, edges);
2054
+ const s = Math.max(
2055
+ 0,
2056
+ Math.min(1, (v - edges[c]) / (edges[c + 1] - edges[c]))
2057
+ );
2058
+ if (s === 0) return values[c];
2059
+ if (s === 1) return values[c + 1];
2060
+ return values[c] + (values[c + 1] - values[c]) * s;
1931
2061
  };
1932
2062
  return {
1933
2063
  restX,
1934
2064
  warpedX,
1935
- mapX(x) {
1936
- const c = cellFor(x, restX);
1937
- const s = Math.max(
1938
- 0,
1939
- Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
1940
- );
1941
- return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
1942
- },
1943
- invertX(X) {
1944
- const c = cellFor(X, warpedX);
1945
- const s = Math.max(
1946
- 0,
1947
- Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
1948
- );
1949
- return restX[c] + (restX[c + 1] - restX[c]) * s;
2065
+ mapX: (x) => sample(restX, warpedX, x),
2066
+ invertX: (X) => sample(warpedX, restX, X)
2067
+ };
2068
+ }
2069
+ function renderedLandingX(part, vertexLanding, px, py) {
2070
+ const { cols, rows } = part;
2071
+ const u = (px - part.x + part.cropW / 2) / part.cropW * cols;
2072
+ const v = (part.y + part.cropH / 2 - py) / part.cropH * rows;
2073
+ const col = Math.max(0, Math.min(cols - 1, Math.floor(u)));
2074
+ const row = Math.max(0, Math.min(rows - 1, Math.floor(v)));
2075
+ const fx = Math.max(0, Math.min(1, u - col));
2076
+ const fy = Math.max(0, Math.min(1, v - row));
2077
+ const cos = Math.cos(part.rotation * (Math.PI / 180));
2078
+ const sin = Math.sin(part.rotation * (Math.PI / 180));
2079
+ const landingAt = (c, r) => {
2080
+ const lx = (-part.cropW / 2 + c / cols * part.cropW) * part.scaleX;
2081
+ const ly = (part.cropH / 2 - r / rows * part.cropH) * part.scaleY;
2082
+ return vertexLanding(
2083
+ part.x + cos * lx - sin * ly,
2084
+ part.y + sin * lx + cos * ly
2085
+ );
2086
+ };
2087
+ const tl = landingAt(col, row);
2088
+ const br = landingAt(col + 1, row + 1);
2089
+ if (fx <= fy) {
2090
+ const bl = landingAt(col, row + 1);
2091
+ return tl + fy * (bl - tl) + fx * (br - bl);
2092
+ }
2093
+ const tr = landingAt(col + 1, row);
2094
+ return tl + fx * (tr - tl) + fy * (br - tr);
2095
+ }
2096
+ function gridChildLandingX(grid, nodeLanding, x, y) {
2097
+ const { cols, rows, points } = grid;
2098
+ const stride = cols + 1;
2099
+ let col = cols - 1;
2100
+ for (let c = 0; c < cols; c++) {
2101
+ if (x < points[(c + 1) * 2]) {
2102
+ col = c;
2103
+ break;
1950
2104
  }
2105
+ }
2106
+ let row = rows - 1;
2107
+ for (let r = 0; r < rows; r++) {
2108
+ if (y > points[(r + 1) * stride * 2 + 1]) {
2109
+ row = r;
2110
+ break;
2111
+ }
2112
+ }
2113
+ const xLeft = points[col * 2];
2114
+ const xRight = points[(col + 1) * 2];
2115
+ const yTop = points[row * stride * 2 + 1];
2116
+ const yBottom = points[(row + 1) * stride * 2 + 1];
2117
+ const s = Math.max(0, Math.min(1, (x - xLeft) / (xRight - xLeft)));
2118
+ const t = Math.max(0, Math.min(1, (yTop - y) / (yTop - yBottom)));
2119
+ const at = (c, r) => nodeLanding(points[(r * stride + c) * 2], points[(r * stride + c) * 2 + 1]);
2120
+ const top = at(col, row) + (at(col + 1, row) - at(col, row)) * s;
2121
+ const bot = at(col, row + 1) + (at(col + 1, row + 1) - at(col, row + 1)) * s;
2122
+ return top + (bot - top) * t;
2123
+ }
2124
+ function carrierLandingX(carrier, nodeLanding, px, py) {
2125
+ return renderedLandingX(
2126
+ carrier.part,
2127
+ (vx, vy) => gridChildLandingX(carrier.grid, nodeLanding, vx, vy),
2128
+ px,
2129
+ py
2130
+ );
2131
+ }
2132
+ function rowMapsOf(surface) {
2133
+ const maps = /* @__PURE__ */ new Map();
2134
+ return (deg, nodeY) => {
2135
+ let byRow = maps.get(deg);
2136
+ if (byRow === void 0) {
2137
+ byRow = /* @__PURE__ */ new Map();
2138
+ maps.set(deg, byRow);
2139
+ }
2140
+ let map = byRow.get(nodeY);
2141
+ if (map === void 0) {
2142
+ map = surface.mapAt(deg, nodeY);
2143
+ byRow.set(nodeY, map);
2144
+ }
2145
+ return map;
2146
+ };
2147
+ }
2148
+ function plateLandingOn(plate, mapAt) {
2149
+ return (deg, x, y) => carrierLandingX(
2150
+ plate,
2151
+ (nodeX, nodeY) => mapAt(deg, nodeY).mapX(nodeX),
2152
+ x,
2153
+ y
2154
+ );
2155
+ }
2156
+ function plateGuardRowsFor(face, hairFront) {
2157
+ const rowsOf = (part, y) => {
2158
+ const { rows } = meshCellsFor(part.cropW, part.cropH);
2159
+ return Array.from(
2160
+ { length: rows + 1 },
2161
+ (_, r) => y + (part.cropH / 2 - r / rows * part.cropH)
2162
+ );
2163
+ };
2164
+ const faceRows = rowsOf(face, face.y);
2165
+ if (hairFront === void 0) return faceRows;
2166
+ const top = face.y + face.cropH / 2;
2167
+ const bottom = face.y - face.cropH / 2;
2168
+ return [
2169
+ ...faceRows,
2170
+ ...rowsOf(hairFront, hairFront.centerY).map(
2171
+ (y) => Math.max(bottom, Math.min(top, y))
2172
+ )
2173
+ ];
2174
+ }
2175
+ function plateGuardsOf(carriers, faceHalfWidth, profile, hairFront) {
2176
+ const plate = carriers.get("face");
2177
+ if (plate === void 0) {
2178
+ throw new Error("auto-rig: plateGuardsOf: the carriers have no face");
2179
+ }
2180
+ return {
2181
+ plate,
2182
+ edgeAt: profile === void 0 ? () => faceHalfWidth : profile.at,
2183
+ plateGuardRows: plateGuardRowsFor(plate.part, hairFront)
1951
2184
  };
1952
2185
  }
1953
2186
  var TurnTargetError = class extends Error {
@@ -2018,12 +2251,15 @@ function resolveTurnTargets(targets = {}) {
2018
2251
  defaulted
2019
2252
  };
2020
2253
  }
2021
- function turnLandmarks(layers) {
2254
+ function turnLandmarks(layers, carriers) {
2022
2255
  const markOf = (role) => {
2023
2256
  const layer = layers.find((l) => l.role === role);
2024
2257
  if (!layer) return void 0;
2025
2258
  const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2026
- return { x: t.x, y: t.y, w: layer.cropW };
2259
+ const mark = { x: t.x, y: t.y, w: layer.cropW };
2260
+ const carrier = carriers?.get(role);
2261
+ if (carrier !== void 0) mark.carrier = carrier;
2262
+ return mark;
2027
2263
  };
2028
2264
  const pairOf = (left, right) => {
2029
2265
  const l = markOf(left);
@@ -2040,16 +2276,34 @@ function turnLandmarks(layers) {
2040
2276
  mouth: one("mouth")
2041
2277
  };
2042
2278
  }
2279
+ function eyeRowOf(landmarks, fallback) {
2280
+ const [l, r] = landmarks.eye;
2281
+ return landmarks.eye.length === 2 && l.y !== void 0 && r.y !== void 0 ? (l.y + r.y) / 2 : fallback;
2282
+ }
2043
2283
  var TURN_BISECT_STEPS = 40;
2044
2284
  var TURN_DEPTH_EPS = 1e-6;
2045
- function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
2285
+ function landmarkLandingX(landmark, mapAt, shift, px, py) {
2286
+ if (landmark.carrier === void 0) return mapAt(py).mapX(px + shift);
2287
+ return carrierLandingX(
2288
+ landmark.carrier,
2289
+ (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2290
+ px,
2291
+ py
2292
+ );
2293
+ }
2294
+ function familyReachPx(landmarks, plateEdgeX) {
2295
+ const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2296
+ return Math.max(0, farEdge - plateEdgeX);
2297
+ }
2298
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
2046
2299
  if (landmarks.length === 0) {
2047
2300
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2048
2301
  }
2049
- const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2050
- const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
2051
- const cap = Math.max(0, (farEdge - floor) / unit);
2052
- const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
2302
+ const cap = familyReachPx(landmarks, plateEdgeX) / unit;
2303
+ const achieved = (d) => landmarks.reduce(
2304
+ (sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
2305
+ 0
2306
+ ) / landmarks.length;
2053
2307
  const atCap = achieved(cap);
2054
2308
  const atRest = achieved(0);
2055
2309
  const attainable = [atCap, atRest];
@@ -2072,68 +2326,52 @@ var TURN_SWEEP_MAX_FACTOR = 8;
2072
2326
  var TURN_SWEEP_SAMPLES = 32;
2073
2327
  var HOLD_CLEARANCE = 1;
2074
2328
  var TURN_HOLD_SAMPLES = 16;
2075
- var TURN_HOLD_SATURATION_PASSES = 4;
2076
- function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2329
+ var TURN_SETTLE_PASSES = 24;
2330
+ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
2077
2331
  const local = x - faceCenterX;
2078
2332
  const dist = Math.abs(local);
2079
2333
  const side = Math.sign(local);
2080
- const sideIdx = side < 0 ? 0 : 1;
2081
- if (dist <= faceHalfWidth) return map.mapX(x);
2334
+ const painted = edgeAt(y);
2335
+ if (dist <= painted) return plateLandingAt(deg, x, y);
2082
2336
  if (dist <= holdBase) {
2083
- const inner = map.mapX(faceCenterX + side * faceHalfWidth);
2084
- const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
2085
- return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
2337
+ const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
2338
+ const u = (dist - painted) / (holdBase - painted);
2339
+ return inner + (faceCenterX + side * holdEdge - inner) * u;
2086
2340
  }
2087
- return x + side * (holdEdge[sideIdx] - holdBase);
2341
+ return x + side * (holdEdge - holdBase);
2088
2342
  }
2089
- function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2343
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2090
2344
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2091
- const preBindAt = (col2) => {
2092
- const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
2093
- const target = hairFrontHoldTarget(
2094
- colX,
2095
- faceCenterX,
2096
- faceHalfWidth,
2097
- holdBase,
2098
- cappedHoldEdge,
2099
- map
2100
- );
2101
- return map.invertX(target);
2102
- };
2103
- const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
2104
- const bracket = (v, cells) => {
2105
- const lo = Math.max(0, Math.min(cells, Math.floor(v)));
2106
- return {
2107
- lo,
2108
- hi: Math.max(0, Math.min(cells, lo + 1)),
2109
- t: Math.max(0, Math.min(1, v - lo))
2110
- };
2111
- };
2112
- const col = bracket(
2113
- (x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
2114
- cols
2345
+ const top = hairFront.centerY + hairFront.cropH / 2;
2346
+ return renderedLandingX(
2347
+ {
2348
+ x: hairFront.x,
2349
+ y: hairFront.centerY,
2350
+ cropW: hairFront.cropW,
2351
+ cropH: hairFront.cropH,
2352
+ cols,
2353
+ rows,
2354
+ scaleX: 1,
2355
+ scaleY: 1,
2356
+ rotation: 0
2357
+ },
2358
+ (vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2359
+ x,
2360
+ y
2115
2361
  );
2116
- const row = bracket(leadRow * rows, rows);
2117
- const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
2118
- const tl = landingAt(col.lo, row.lo);
2119
- const br = landingAt(col.hi, row.hi);
2120
- if (col.t <= row.t) {
2121
- const bl = landingAt(col.lo, row.hi);
2122
- return tl + row.t * (bl - tl) + col.t * (br - bl);
2123
- }
2124
- const tr = landingAt(col.hi, row.lo);
2125
- return tl + col.t * (tr - tl) + row.t * (br - tr);
2126
- }
2127
- function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
2362
+ }
2363
+ function shellTravelCap(faceCenterX, edgeAt, plateGuardRows, askedTravel, bendOnlyLandingAt, holdEdgeAt) {
2128
2364
  let cap = Infinity;
2129
2365
  for (const deg of HEAD_TURN_STOPS) {
2130
2366
  if (deg === 0) continue;
2131
- const map = bendOnlyMapAt(deg);
2132
2367
  const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
2133
- const towardReach = Math.max(
2134
- ...[-1, 1].map(
2135
- (side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
2136
- )
2368
+ const towardReach = plateSideReachAt(
2369
+ bendOnlyLandingAt,
2370
+ faceCenterX,
2371
+ edgeAt,
2372
+ plateGuardRows,
2373
+ deg,
2374
+ slideSign
2137
2375
  );
2138
2376
  cap = Math.min(
2139
2377
  cap,
@@ -2142,8 +2380,26 @@ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt,
2142
2380
  }
2143
2381
  return cap;
2144
2382
  }
2383
+ function aitken(x0, x1, x2, cap) {
2384
+ const d1 = x1 - x0;
2385
+ const d2 = x2 - x1;
2386
+ const denominator = d2 - d1;
2387
+ if (denominator === 0) return x2;
2388
+ const x = x2 - d2 * d2 / denominator;
2389
+ return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2390
+ }
2145
2391
  function evaluateTurnCandidate(ctx, radius) {
2146
- const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
2392
+ const surfaceAt = (travel) => turnSurface({
2393
+ faceCenterX: ctx.faceCenterX,
2394
+ faceCenterY: ctx.faceCenterY,
2395
+ radius,
2396
+ travel,
2397
+ nodRadius: ctx.nodRadius,
2398
+ lattice: ctx.lattice,
2399
+ profile: ctx.profile,
2400
+ eyeRowY: ctx.eyeRowY
2401
+ });
2402
+ const bendOnlyLandingAt = plateLandingOn(ctx.plate, rowMapsOf(surfaceAt(0)));
2147
2403
  const measuredEdgeAt = (side) => {
2148
2404
  const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2149
2405
  if (candidates === void 0 || candidates.length === 0) return void 0;
@@ -2155,66 +2411,71 @@ function evaluateTurnCandidate(ctx, radius) {
2155
2411
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2156
2412
  };
2157
2413
  const unit = headTurnParallaxUnit(radius);
2158
- const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2159
- const leadRow = ctx.hairFrontLeadFraction ?? 0;
2160
- const evaluateHold = (ratio) => {
2414
+ const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2415
+ const evaluateHold = (ratio, depths2) => {
2161
2416
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2162
- const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2417
+ const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2163
2418
  ctx.travel,
2164
2419
  shellTravelCap(
2165
2420
  ctx.faceCenterX,
2166
- ctx.faceHalfWidth,
2421
+ ctx.edgeAt,
2422
+ ctx.plateGuardRows,
2167
2423
  ctx.travel,
2168
- bendOnlyMapAt,
2424
+ bendOnlyLandingAt,
2169
2425
  (deg, side) => {
2170
2426
  const rest = shellHalfWidthAt(side);
2171
2427
  return Math.min(rest, holdEdgeFrom(rest)(deg));
2172
2428
  }
2173
2429
  )
2174
2430
  );
2175
- const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
2176
- const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
2177
- const holdEdgeAt2 = holdEdgeFrom(holdBase2);
2431
+ const surface = surfaceAt(travel);
2432
+ const rowMaps = rowMapsOf(surface);
2433
+ const mapAt = (y) => rowMaps(-HEAD_TURN_MAX_DEG, y);
2434
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2435
+ const holdBase = ctx.headHalfWidth ?? plateReach(
2436
+ plateLandingAt,
2437
+ ctx.faceCenterX,
2438
+ ctx.edgeAt,
2439
+ ctx.plateGuardRows
2440
+ ) + HOLD_CLEARANCE;
2441
+ const holdEdgeAt = holdEdgeFrom(holdBase);
2178
2442
  let foldLowerBound = -Infinity;
2179
2443
  let folds = false;
2180
2444
  for (const deg of HEAD_TURN_STOPS) {
2181
2445
  if (deg === 0) continue;
2182
2446
  const reach = plateReachAt(
2183
- columnMapAt(deg),
2447
+ plateLandingAt,
2184
2448
  ctx.faceCenterX,
2185
- ctx.faceHalfWidth
2449
+ ctx.edgeAt,
2450
+ ctx.plateGuardRows,
2451
+ deg
2186
2452
  );
2187
2453
  foldLowerBound = Math.max(
2188
2454
  foldLowerBound,
2189
- 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2455
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2190
2456
  );
2191
- if (holdEdgeAt2(deg) <= reach) folds = true;
2457
+ if (holdEdgeAt(deg) <= reach) folds = true;
2192
2458
  }
2193
- const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
2194
- const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
2195
- const gridReach2 = [
2196
- Math.abs(map2.warpedX[0] - ctx.faceCenterX),
2197
- Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
2198
- ];
2199
- const cappedHoldEdge = [
2200
- Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
2201
- Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
2202
- ];
2203
2459
  const restAt = (side) => {
2204
2460
  const edge = measuredEdgeAt(side);
2205
2461
  if (edge !== void 0) return edge;
2206
- return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2462
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2207
2463
  };
2208
2464
  const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2209
2465
  x,
2466
+ ctx.eyeRowY,
2210
2467
  ctx.hairFrontSilhouette,
2211
- ctx.faceCenterX,
2212
- ctx.faceHalfWidth,
2213
- holdBase2,
2214
- cappedHoldEdge,
2215
- map2,
2216
- leadTipShift,
2217
- leadRow
2468
+ (vx, vy) => hairFrontHoldTarget(
2469
+ vx,
2470
+ vy,
2471
+ -HEAD_TURN_MAX_DEG,
2472
+ ctx.faceCenterX,
2473
+ plateLandingAt,
2474
+ ctx.edgeAt,
2475
+ holdBase,
2476
+ holdEdgeAt(-HEAD_TURN_MAX_DEG)
2477
+ ),
2478
+ leadTipShift
2218
2479
  );
2219
2480
  const landingOfRole = (role, x) => {
2220
2481
  if (role === "hair_front") return hairFrontAt(x);
@@ -2222,10 +2483,22 @@ function evaluateTurnCandidate(ctx, radius) {
2222
2483
  return x;
2223
2484
  }
2224
2485
  if (role === "body") {
2225
- return x - BODY_TURN_FOLLOW * travel2;
2486
+ return x - BODY_TURN_FOLLOW * travel;
2226
2487
  }
2227
2488
  if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2228
- return map2.mapX(x);
2489
+ const carrier = ctx.carriers.get(role);
2490
+ if (carrier === void 0) {
2491
+ throw new Error(
2492
+ `auto-rig: evaluateTurnCandidate: headEdges names "${role}", which this layer set has no layer for`
2493
+ );
2494
+ }
2495
+ const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2496
+ return carrierLandingX(
2497
+ carrier,
2498
+ (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2499
+ x,
2500
+ ctx.eyeRowY
2501
+ );
2229
2502
  }
2230
2503
  throw new Error(
2231
2504
  `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
@@ -2241,13 +2514,13 @@ function evaluateTurnCandidate(ctx, radius) {
2241
2514
  };
2242
2515
  return {
2243
2516
  ratio,
2244
- travel: travel2,
2245
- map: map2,
2246
- holdBase: holdBase2,
2247
- holdEdgeAt: holdEdgeAt2,
2517
+ travel,
2518
+ mapAt,
2519
+ surface,
2520
+ holdBase,
2521
+ holdEdgeAt,
2248
2522
  folds,
2249
2523
  foldLowerBound,
2250
- gridReach: gridReach2,
2251
2524
  // How far the silhouette's own centre drifts off the face centre at full
2252
2525
  // turn, against how far it already sat off centre at rest (zero unless
2253
2526
  // hair_front's crop is itself off-centre) — the held shell keeps its own
@@ -2264,24 +2537,18 @@ function evaluateTurnCandidate(ctx, radius) {
2264
2537
  restSpan: restAt(1) - restAt(-1)
2265
2538
  };
2266
2539
  };
2267
- const saturatedHold = () => {
2268
- let hold2 = evaluateHold(1);
2269
- for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
2270
- const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
2271
- if (needed <= hold2.ratio) break;
2272
- hold2 = evaluateHold(needed);
2273
- }
2274
- return hold2;
2275
- };
2276
- const holdRange = () => {
2277
- const ceiling = saturatedHold();
2540
+ const holdRange = (depths2) => {
2541
+ const ceiling = evaluateHold(TURN_RATIO_MAX, depths2);
2278
2542
  if (ceiling.folds) return void 0;
2279
- let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2543
+ let floor = evaluateHold(
2544
+ Math.min(ceiling.foldLowerBound, ceiling.ratio),
2545
+ depths2
2546
+ );
2280
2547
  if (floor.folds) {
2281
2548
  let lo = floor;
2282
2549
  let hi = ceiling;
2283
2550
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2284
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2551
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2285
2552
  if (mid.folds) lo = mid;
2286
2553
  else hi = mid;
2287
2554
  }
@@ -2290,7 +2557,8 @@ function evaluateTurnCandidate(ctx, radius) {
2290
2557
  const samples = [floor];
2291
2558
  for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2292
2559
  const sample = evaluateHold(
2293
- floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2560
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES,
2561
+ depths2
2294
2562
  );
2295
2563
  if (!sample.folds) samples.push(sample);
2296
2564
  }
@@ -2303,15 +2571,14 @@ function evaluateTurnCandidate(ctx, radius) {
2303
2571
  return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2304
2572
  };
2305
2573
  const asked = ctx.silhouetteRatio;
2306
- const natural = evaluateHold(asked);
2307
- const eps = TURN_DEPTH_EPS / natural.restSpan;
2308
- let hold;
2309
- let silhouetteClamped = false;
2310
- if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2311
- hold = natural;
2312
- } else {
2313
- const samples = holdRange();
2314
- if (samples === void 0) return silhouetteMiss();
2574
+ const fitHold = (depths2) => {
2575
+ const natural = evaluateHold(asked, depths2);
2576
+ const eps = TURN_DEPTH_EPS / natural.restSpan;
2577
+ if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2578
+ return { hold: natural, silhouetteClamped: false };
2579
+ }
2580
+ const samples = holdRange(depths2);
2581
+ if (samples === void 0) return void 0;
2315
2582
  const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2316
2583
  const span = [
2317
2584
  Math.min(...rendered),
@@ -2326,91 +2593,145 @@ function evaluateTurnCandidate(ctx, radius) {
2326
2593
  return ea <= eb ? a : b;
2327
2594
  };
2328
2595
  if (asked < span[0] - eps || asked > span[1] + eps) {
2329
- if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2330
2596
  const wanted = asked < span[0] ? span[0] : span[1];
2331
- hold = samples.reduce(
2597
+ const hold = samples.reduce(
2332
2598
  (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2333
2599
  );
2334
- silhouetteClamped = true;
2335
- } else {
2336
- let best = samples.reduce(nearer);
2337
- let lo;
2338
- let hi;
2339
- for (let i = 0; i + 1 < samples.length; i++) {
2340
- const a = samples[i].renderedSilhouetteRatio - asked;
2341
- const b = samples[i + 1].renderedSilhouetteRatio - asked;
2342
- if (a * b <= 0) {
2343
- lo = samples[i];
2344
- hi = samples[i + 1];
2345
- break;
2346
- }
2600
+ return { hold, silhouetteClamped: true, span };
2601
+ }
2602
+ let best = samples.reduce(nearer);
2603
+ let lo;
2604
+ let hi;
2605
+ for (let i = 0; i + 1 < samples.length; i++) {
2606
+ const a = samples[i].renderedSilhouetteRatio - asked;
2607
+ const b = samples[i + 1].renderedSilhouetteRatio - asked;
2608
+ if (a * b <= 0) {
2609
+ lo = samples[i];
2610
+ hi = samples[i + 1];
2611
+ break;
2347
2612
  }
2348
- for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2349
- if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2350
- break;
2351
- }
2352
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2353
- if (!mid.folds) best = nearer(best, mid);
2354
- if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2355
- lo = mid;
2356
- } else {
2357
- hi = mid;
2358
- }
2613
+ }
2614
+ for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2615
+ if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2616
+ break;
2617
+ }
2618
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2619
+ if (!mid.folds) best = nearer(best, mid);
2620
+ if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2621
+ lo = mid;
2622
+ } else {
2623
+ hi = mid;
2359
2624
  }
2360
- hold = best;
2361
2625
  }
2362
- }
2363
- if (hold.folds) {
2364
- throw new Error(
2365
- "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2626
+ return { hold: best, silhouetteClamped: false };
2627
+ };
2628
+ let depths = { eye: 0, nose: 0, mouth: 0 };
2629
+ let seed;
2630
+ for (let pass = 0; ; pass++) {
2631
+ const fitted = fitHold(depths);
2632
+ if (fitted === void 0) return silhouetteMiss();
2633
+ const { hold, silhouetteClamped, span } = fitted;
2634
+ if (hold.folds) {
2635
+ throw new Error(
2636
+ "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2637
+ );
2638
+ }
2639
+ const eye = solveTurnDepthSigned(
2640
+ hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2641
+ ctx.landmarks.eye,
2642
+ unit,
2643
+ hold.mapAt,
2644
+ ctx.plateEdgeX
2366
2645
  );
2367
- }
2368
- const {
2369
- travel,
2370
- map,
2371
- holdBase,
2372
- holdEdgeAt,
2373
- silhouetteCenterShift,
2374
- renderedSilhouetteRatio
2375
- } = hold;
2376
- const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
2377
- const eye = solveTurnDepthSigned(
2378
- silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2379
- ctx.landmarks.eye,
2380
- unit,
2381
- map,
2382
- holdEdgeX,
2383
- ctx.plateEdgeX
2384
- );
2385
- if (!eye.reached && !ctx.clampEyeShift) {
2646
+ const features = solveFeatureDepths(
2647
+ ctx,
2648
+ (-eye.achieved + hold.silhouetteCenterShift) / ctx.hh,
2649
+ unit,
2650
+ hold.mapAt,
2651
+ hold.silhouetteCenterShift
2652
+ );
2653
+ const solved = {
2654
+ eye: eye.depth,
2655
+ nose: features.nose.depth,
2656
+ mouth: features.mouth.depth
2657
+ };
2658
+ const settled = evaluateHold(hold.ratio, solved);
2659
+ if (Math.abs(settled.silhouetteCenterShift - hold.silhouetteCenterShift) > TURN_DEPTH_EPS || Math.abs(settled.renderedSilhouetteRatio - hold.renderedSilhouetteRatio) * hold.restSpan > TURN_DEPTH_EPS) {
2660
+ if (pass + 1 >= TURN_SETTLE_PASSES) return { blocked: "settle" };
2661
+ if (seed === void 0) {
2662
+ seed = depths;
2663
+ depths = solved;
2664
+ } else {
2665
+ const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
2666
+ depths = {
2667
+ eye: aitken(
2668
+ seed.eye,
2669
+ depths.eye,
2670
+ solved.eye,
2671
+ capOf(ctx.landmarks.eye)
2672
+ ),
2673
+ nose: aitken(
2674
+ seed.nose,
2675
+ depths.nose,
2676
+ solved.nose,
2677
+ capOf(ctx.landmarks.nose)
2678
+ ),
2679
+ mouth: aitken(
2680
+ seed.mouth,
2681
+ depths.mouth,
2682
+ solved.mouth,
2683
+ capOf(ctx.landmarks.mouth)
2684
+ )
2685
+ };
2686
+ seed = void 0;
2687
+ }
2688
+ continue;
2689
+ }
2690
+ const {
2691
+ travel,
2692
+ mapAt,
2693
+ surface,
2694
+ holdBase,
2695
+ holdEdgeAt,
2696
+ silhouetteCenterShift,
2697
+ renderedSilhouetteRatio
2698
+ } = settled;
2699
+ if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2700
+ return silhouetteMiss(span);
2701
+ }
2702
+ if (!eye.reached && !ctx.clampEyeShift) {
2703
+ return {
2704
+ blocked: "eyeShift",
2705
+ // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
2706
+ // a negative lower bound here would advertise a value the field's own
2707
+ // contract already rules out; 0 is the true floor.
2708
+ offeredShift: [
2709
+ Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2710
+ (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2711
+ ]
2712
+ };
2713
+ }
2714
+ const eyeShiftPx = -eye.depth * unit;
2715
+ 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;
2716
+ const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2717
+ const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2386
2718
  return {
2387
- blocked: "eyeShift",
2388
- // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
2389
- // negative lower bound here would advertise a value the field's own
2390
- // contract already rules out; 0 is the true floor.
2391
- offeredShift: [
2392
- Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2393
- (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2394
- ]
2719
+ radius,
2720
+ ratio: scaleOf(far) / scaleOf(near),
2721
+ unit,
2722
+ travel,
2723
+ mapAt,
2724
+ surface,
2725
+ holdBase,
2726
+ holdEdgeAt,
2727
+ eye,
2728
+ nose: features.nose,
2729
+ mouth: features.mouth,
2730
+ silhouetteClamped,
2731
+ silhouetteCenterShift,
2732
+ renderedSilhouetteRatio
2395
2733
  };
2396
2734
  }
2397
- 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;
2398
- const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2399
- const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2400
- return {
2401
- radius,
2402
- ratio: scaleOf(far) / scaleOf(near),
2403
- unit,
2404
- travel,
2405
- map,
2406
- holdBase,
2407
- holdEdgeAt,
2408
- holdEdgeX,
2409
- eye,
2410
- silhouetteClamped,
2411
- silhouetteCenterShift,
2412
- renderedSilhouetteRatio
2413
- };
2414
2735
  }
2415
2736
  function sweepTurnRadii(ctx) {
2416
2737
  const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
@@ -2419,6 +2740,7 @@ function sweepTurnRadii(ctx) {
2419
2740
  let heldRatioLower = Infinity;
2420
2741
  let heldRatioUpper = -Infinity;
2421
2742
  let offeredShift;
2743
+ let unsettled = 0;
2422
2744
  for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2423
2745
  const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2424
2746
  const result = evaluateTurnCandidate(ctx, radius);
@@ -2427,6 +2749,8 @@ function sweepTurnRadii(ctx) {
2427
2749
  } else if (result.blocked === "silhouetteRatio") {
2428
2750
  heldRatioLower = Math.min(heldRatioLower, result.lower);
2429
2751
  heldRatioUpper = Math.max(heldRatioUpper, result.upper);
2752
+ } else if (result.blocked === "settle") {
2753
+ unsettled++;
2430
2754
  } else {
2431
2755
  offeredShift = offeredShift ? [
2432
2756
  Math.min(offeredShift[0], result.offeredShift[0]),
@@ -2434,7 +2758,13 @@ function sweepTurnRadii(ctx) {
2434
2758
  ] : result.offeredShift;
2435
2759
  }
2436
2760
  }
2437
- return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
2761
+ return {
2762
+ candidates,
2763
+ heldRatioLower,
2764
+ heldRatioUpper,
2765
+ offeredShift,
2766
+ unsettled
2767
+ };
2438
2768
  }
2439
2769
  function ratioClusters(candidates) {
2440
2770
  const clusters = [];
@@ -2476,71 +2806,40 @@ function fitTurnRadius(ctx, candidates, target) {
2476
2806
  }
2477
2807
  return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
2478
2808
  }
2479
- function solveFeatureDepths(ctx, targets, best, eyeShift) {
2480
- const clamped = [];
2481
- const solved = {};
2482
- const families = [
2483
- ["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
2484
- ["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
2485
- ];
2486
- for (const [field, marks, share] of families) {
2487
- const m = Math.abs(
2488
- targets.defaulted.has(field) ? share * eyeShift : targets[field]
2489
- );
2490
- const solution = solveTurnDepthSigned(
2491
- best.silhouetteCenterShift - m * ctx.hh,
2492
- marks,
2493
- best.unit,
2494
- best.map,
2495
- best.holdEdgeX,
2496
- ctx.plateEdgeX
2497
- );
2498
- if (!solution.reached) {
2499
- if (!targets.defaulted.has(field)) {
2500
- return {
2501
- blocked: field,
2502
- // noseShift/mouthShift are MAGNITUDES too (see the correction
2503
- // above), so a negative lower bound here would advertise a value
2504
- // the field's own contract already rules out; 0 is the true floor.
2505
- attainable: [
2506
- Math.max(
2507
- 0,
2508
- (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2509
- ),
2510
- (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2511
- ]
2512
- };
2513
- }
2514
- clamped.push(field);
2515
- }
2516
- solved[field] = solution.depth;
2517
- }
2809
+ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2810
+ const solve = (measured, marks, share) => solveTurnDepthSigned(
2811
+ silhouetteCenterShift - (measured ?? Math.abs(share * eyeShift)) * ctx.hh,
2812
+ marks,
2813
+ unit,
2814
+ mapAt,
2815
+ ctx.plateEdgeX
2816
+ );
2518
2817
  return {
2519
- nose: solved.noseShift,
2520
- mouth: solved.mouthShift,
2521
- clamped
2818
+ nose: solve(ctx.noseShift, ctx.landmarks.nose, NOSE_SHIFT_SHARE),
2819
+ mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2522
2820
  };
2523
2821
  }
2524
- function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
2822
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
2525
2823
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2526
2824
  throw new TurnTargetError(
2527
2825
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2528
2826
  );
2529
2827
  }
2530
- 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;
2531
- const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
2532
- 0,
2533
- Math.min(
2534
- 1,
2535
- (hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
2536
- )
2537
- );
2828
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
2538
2829
  const ctx = {
2539
2830
  landmarks,
2540
- faceGrid,
2831
+ lattice,
2541
2832
  faceCenterX,
2833
+ faceCenterY,
2834
+ nodRadius,
2835
+ eyeRowY,
2542
2836
  faceHalfWidth,
2837
+ // The slide's hard stop is the CROP's edge, profile or not: the far eye
2838
+ // may sit on the plate's transparent margin, as it does on the hero.
2543
2839
  plateEdgeX: faceCenterX - faceHalfWidth,
2840
+ carriers,
2841
+ profile,
2842
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
2544
2843
  // What this plate asks the turn for; each candidate caps it to its own
2545
2844
  // shell — see TurnSolveContext.travel.
2546
2845
  travel: headTurnTravel(faceHalfWidth),
@@ -2553,9 +2852,10 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2553
2852
  // every downstream use already has the magnitude.
2554
2853
  eyeShift: Math.abs(targets.eyeShift),
2555
2854
  clampEyeShift: targets.defaulted.has("eyeShift"),
2855
+ noseShift: targets.defaulted.has("noseShift") ? void 0 : Math.abs(targets.noseShift),
2856
+ mouthShift: targets.defaulted.has("mouthShift") ? void 0 : Math.abs(targets.mouthShift),
2556
2857
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2557
- hairFrontLeadFraction,
2558
- hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
2858
+ hairFrontSilhouette: hairFront,
2559
2859
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2560
2860
  headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
2561
2861
  };
@@ -2563,6 +2863,12 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2563
2863
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2564
2864
  const pass = sweepTurnRadii(ctx);
2565
2865
  if (pass.candidates.length === 0) {
2866
+ if (pass.unsettled > 0) {
2867
+ const refused = TURN_SWEEP_SAMPLES - pass.unsettled;
2868
+ throw new TurnTargetError(
2869
+ `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}` : ""}`
2870
+ );
2871
+ }
2566
2872
  return pass.offeredShift ? {
2567
2873
  unreachable: true,
2568
2874
  field: "eyeShift",
@@ -2624,14 +2930,29 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2624
2930
  if (!best.eye.reached) clamped.push("eyeShift");
2625
2931
  if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2626
2932
  const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2627
- const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2628
- if ("blocked" in features) {
2629
- return {
2630
- unreachable: true,
2631
- field: features.blocked,
2632
- value: targets[features.blocked],
2633
- attainable: features.attainable
2634
- };
2933
+ for (const [field, solution] of [
2934
+ ["noseShift", best.nose],
2935
+ ["mouthShift", best.mouth]
2936
+ ]) {
2937
+ if (solution.reached) continue;
2938
+ if (!targets.defaulted.has(field)) {
2939
+ return {
2940
+ unreachable: true,
2941
+ field,
2942
+ value: targets[field],
2943
+ // noseShift/mouthShift are MAGNITUDES too (see solveFeatureDepths),
2944
+ // so a negative lower bound here would advertise a value the field's
2945
+ // own contract already rules out; 0 is the true floor.
2946
+ attainable: [
2947
+ Math.max(
2948
+ 0,
2949
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2950
+ ),
2951
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2952
+ ]
2953
+ };
2954
+ }
2955
+ clamped.push(field);
2635
2956
  }
2636
2957
  return {
2637
2958
  unreachable: false,
@@ -2639,13 +2960,18 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2639
2960
  holdBase: best.holdBase,
2640
2961
  holdEdgeAt: best.holdEdgeAt,
2641
2962
  travel: best.travel,
2642
- depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2963
+ surface: best.surface,
2964
+ depths: {
2965
+ eye: best.eye.depth,
2966
+ nose: best.nose.depth,
2967
+ mouth: best.mouth.depth
2968
+ },
2643
2969
  achieved: {
2644
2970
  eyeShift,
2645
2971
  farEyeRatio: best.ratio,
2646
2972
  silhouetteRatio: best.renderedSilhouetteRatio
2647
2973
  },
2648
- clamped: [...clamped, ...features.clamped]
2974
+ clamped
2649
2975
  };
2650
2976
  }
2651
2977
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
@@ -2682,27 +3008,16 @@ function featureParallaxBindings(role, options) {
2682
3008
  if (!options.hasNose) return [];
2683
3009
  const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2684
3010
  if (nodDepth === void 0) return [];
2685
- const bindings = [];
2686
- const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2687
- const shiftX = turnDepth * (options.parallaxUnit ?? 0);
2688
- if (shiftX !== 0) {
2689
- bindings.push({
2690
- parameter: import_format3.StandardParameter.AngleX,
2691
- channel: "translateX",
2692
- from: -shiftX,
2693
- to: shiftX
2694
- });
2695
- }
2696
3011
  const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2697
- if (shiftY !== 0) {
2698
- bindings.push({
3012
+ if (shiftY === 0) return [];
3013
+ return [
3014
+ {
2699
3015
  parameter: import_format3.StandardParameter.AngleY,
2700
3016
  channel: "translateY",
2701
3017
  from: -shiftY,
2702
3018
  to: shiftY
2703
- });
2704
- }
2705
- return bindings;
3019
+ }
3020
+ ];
2706
3021
  }
2707
3022
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2708
3023
  return [
@@ -2810,20 +3125,17 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
2810
3125
  ];
2811
3126
  }
2812
3127
  }
2813
- if (role === "hair_front" || role === "hair_back") {
2814
- const isFront = role === "hair_front";
2815
- const parallax = [];
2816
- const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2817
- const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2818
- if (shiftY !== 0) {
2819
- parallax.push({
3128
+ if (role === "hair_back") {
3129
+ const shiftY = HAIR_BACK_NOD_DEPTH * (options.parallaxUnitY ?? 0);
3130
+ if (shiftY === 0) return [];
3131
+ return [
3132
+ {
2820
3133
  parameter: import_format3.StandardParameter.AngleY,
2821
3134
  channel: "translateY",
2822
3135
  from: -shiftY,
2823
3136
  to: shiftY
2824
- });
2825
- }
2826
- return parallax;
3137
+ }
3138
+ ];
2827
3139
  }
2828
3140
  return [];
2829
3141
  }
@@ -2846,64 +3158,98 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2846
3158
  ]
2847
3159
  };
2848
3160
  }
2849
- function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
3161
+ function bakeNodWarp(mesh, partY, surface, nodTravel) {
3162
+ const keyforms = HEAD_TURN_STOPS.map((deg) => {
3163
+ const t = nodTravel * deg / HEAD_TURN_MAX_DEG;
3164
+ const offsets = [];
3165
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
3166
+ const y = partY + mesh.vertices[i + 1];
3167
+ offsets.push(0, deg === 0 ? 0 : t + surface.nodBendAt(y + t, deg));
3168
+ }
3169
+ return { value: deg, offsets };
3170
+ });
3171
+ return { parameter: import_format3.StandardParameter.AngleY, keyforms };
3172
+ }
3173
+ function plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) {
2850
3174
  let reach = 0;
2851
3175
  for (const deg of HEAD_TURN_STOPS) {
2852
3176
  reach = Math.max(
2853
3177
  reach,
2854
- plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
3178
+ plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
2855
3179
  );
2856
3180
  }
2857
3181
  return reach;
2858
3182
  }
2859
- function plateReachAt(map, faceCenterX, faceHalfWidth) {
3183
+ function plateSideReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg, side) {
2860
3184
  let reach = 0;
2861
- for (const side of [-1, 1]) {
2862
- const dest = map.mapX(faceCenterX + side * faceHalfWidth);
3185
+ for (const y of plateGuardRows) {
3186
+ const dest = plateLandingAt(deg, faceCenterX + side * edgeAt(y), y);
2863
3187
  reach = Math.max(reach, Math.abs(dest - faceCenterX));
2864
3188
  }
2865
3189
  return reach;
2866
3190
  }
2867
- function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
3191
+ function plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg) {
3192
+ return Math.max(
3193
+ plateSideReachAt(
3194
+ plateLandingAt,
3195
+ faceCenterX,
3196
+ edgeAt,
3197
+ plateGuardRows,
3198
+ deg,
3199
+ -1
3200
+ ),
3201
+ plateSideReachAt(
3202
+ plateLandingAt,
3203
+ faceCenterX,
3204
+ edgeAt,
3205
+ plateGuardRows,
3206
+ deg,
3207
+ 1
3208
+ )
3209
+ );
3210
+ }
3211
+ function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows) {
2868
3212
  if (holdEdgeAt(0) !== holdBase) {
2869
3213
  throw new Error(
2870
3214
  `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`
2871
3215
  );
2872
3216
  }
2873
- const SIDES = [-1, 1];
2874
3217
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2875
- const map = columnMapAt(deg);
2876
- const requestedHoldEdge = holdEdgeAt(deg);
2877
- const plateDest = SIDES.map(
2878
- (side) => map.mapX(faceCenterX + side * faceHalfWidth)
3218
+ const holdEdge = holdEdgeAt(deg);
3219
+ const reach = [-1, 1].map(
3220
+ (side) => plateSideReachAt(
3221
+ plateLandingAt,
3222
+ faceCenterX,
3223
+ edgeAt,
3224
+ plateGuardRows,
3225
+ deg,
3226
+ side
3227
+ )
2879
3228
  );
2880
- const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
2881
- if (requestedHoldEdge <= reached) {
2882
- const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
3229
+ const reached = Math.max(reach[0], reach[1]);
3230
+ if (holdEdge <= reached) {
3231
+ const side = reach[1] > reach[0] ? 1 : -1;
2883
3232
  throw new Error(
2884
- `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`
3233
+ `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`
2885
3234
  );
2886
3235
  }
2887
- const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
2888
- (_side, idx) => Math.min(
2889
- requestedHoldEdge,
2890
- Math.abs(
2891
- map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
2892
- ) - HOLD_CLEARANCE
2893
- )
2894
- );
2895
3236
  const offsets = [];
2896
3237
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2897
3238
  const x = partX + mesh.vertices[i];
2898
- const target = hairFrontHoldTarget(
2899
- x,
2900
- faceCenterX,
2901
- faceHalfWidth,
2902
- holdBase,
2903
- holdEdge,
2904
- map
3239
+ const y = partY + mesh.vertices[i + 1];
3240
+ offsets.push(
3241
+ deg === 0 ? 0 : hairFrontHoldTarget(
3242
+ x,
3243
+ y,
3244
+ deg,
3245
+ faceCenterX,
3246
+ plateLandingAt,
3247
+ edgeAt,
3248
+ holdBase,
3249
+ holdEdge
3250
+ ) - x,
3251
+ 0
2905
3252
  );
2906
- offsets.push(map.invertX(target) - x, 0);
2907
3253
  }
2908
3254
  return { value: deg, offsets };
2909
3255
  });
@@ -2912,7 +3258,6 @@ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, ho
2912
3258
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2913
3259
  var HAIR_SWAY_CURL = 1.5;
2914
3260
  var HAIR_SWAY_RANGE = 20;
2915
- var HAIR_SWAY_PEAK_FRACTION = 0.56;
2916
3261
  function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2917
3262
  let top = -Infinity;
2918
3263
  let bottom = Infinity;
@@ -2942,112 +3287,378 @@ function headTurnTravel(faceHalfWidth) {
2942
3287
  var BODY_TURN_FOLLOW = 0.3;
2943
3288
  var HEAD_BREATH_BOB = -12;
2944
3289
  var BODY_BREATH_FOLLOW = 0.5;
3290
+ var STANDARD_PARAMETERS = [
3291
+ {
3292
+ id: import_format3.StandardParameter.MouthOpen,
3293
+ name: "Mouth Open",
3294
+ min: 0,
3295
+ max: 1,
3296
+ default: 0
3297
+ },
3298
+ {
3299
+ id: import_format3.StandardParameter.MouthForm,
3300
+ name: "Mouth Form",
3301
+ min: -1,
3302
+ max: 1,
3303
+ default: 0
3304
+ },
3305
+ {
3306
+ id: import_format3.StandardParameter.EyeOpenLeft,
3307
+ name: "Eye L",
3308
+ min: 0,
3309
+ max: 1,
3310
+ default: 1
3311
+ },
3312
+ {
3313
+ id: import_format3.StandardParameter.EyeOpenRight,
3314
+ name: "Eye R",
3315
+ min: 0,
3316
+ max: 1,
3317
+ default: 1
3318
+ },
3319
+ {
3320
+ id: import_format3.StandardParameter.EyeballX,
3321
+ name: "Gaze X",
3322
+ min: -1,
3323
+ max: 1,
3324
+ default: 0
3325
+ },
3326
+ {
3327
+ id: import_format3.StandardParameter.EyeballY,
3328
+ name: "Gaze Y",
3329
+ min: -1,
3330
+ max: 1,
3331
+ default: 0
3332
+ },
3333
+ {
3334
+ id: import_format3.StandardParameter.AngleX,
3335
+ name: "Head Angle",
3336
+ min: -30,
3337
+ max: 30,
3338
+ default: 0
3339
+ },
3340
+ {
3341
+ id: import_format3.StandardParameter.AngleY,
3342
+ name: "Head Angle Y",
3343
+ min: -30,
3344
+ max: 30,
3345
+ default: 0
3346
+ },
3347
+ {
3348
+ id: import_format3.StandardParameter.AngleZ,
3349
+ name: "Head Angle Z",
3350
+ min: -30,
3351
+ max: 30,
3352
+ default: 0
3353
+ },
3354
+ {
3355
+ id: import_format3.StandardParameter.Breath,
3356
+ name: "Breath",
3357
+ min: 0,
3358
+ max: 1,
3359
+ default: 0
3360
+ },
3361
+ {
3362
+ id: import_format3.StandardParameter.BrowLeftY,
3363
+ name: "Brow L Y",
3364
+ min: -1,
3365
+ max: 1,
3366
+ default: 0
3367
+ },
3368
+ {
3369
+ id: import_format3.StandardParameter.BrowRightY,
3370
+ name: "Brow R Y",
3371
+ min: -1,
3372
+ max: 1,
3373
+ default: 0
3374
+ },
3375
+ {
3376
+ id: import_format3.StandardParameter.BrowLeftAngle,
3377
+ name: "Brow L Angle",
3378
+ min: -1,
3379
+ max: 1,
3380
+ default: 0
3381
+ },
3382
+ {
3383
+ id: import_format3.StandardParameter.BrowRightAngle,
3384
+ name: "Brow R Angle",
3385
+ min: -1,
3386
+ max: 1,
3387
+ default: 0
3388
+ }
3389
+ ];
3390
+ function transformUnder(bindings, valueOf) {
3391
+ const trs = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 };
3392
+ bindings.forEach((binding, i) => {
3393
+ const value = valueOf(binding, i);
3394
+ switch (binding.channel) {
3395
+ case "translateX":
3396
+ trs.x += value;
3397
+ break;
3398
+ case "translateY":
3399
+ trs.y += value;
3400
+ break;
3401
+ case "rotate":
3402
+ trs.rotation += value;
3403
+ break;
3404
+ case "scaleX":
3405
+ trs.scaleX += value;
3406
+ break;
3407
+ case "scaleY":
3408
+ trs.scaleY += value;
3409
+ break;
3410
+ case "opacity":
3411
+ break;
3412
+ }
3413
+ });
3414
+ return trs;
3415
+ }
3416
+ function defaultPoseOf(bindings) {
3417
+ return transformUnder(bindings, (binding) => {
3418
+ const p = STANDARD_PARAMETERS.find((p2) => p2.id === binding.parameter);
3419
+ if (p === void 0) {
3420
+ throw new Error(
3421
+ `auto-rig: defaultPoseOf: "${binding.parameter}" is not a standard parameter`
3422
+ );
3423
+ }
3424
+ const t = (p.default - p.min) / (p.max - p.min);
3425
+ return binding.from + (binding.to - binding.from) * t;
3426
+ });
3427
+ }
3428
+ function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
3429
+ let minX = Infinity;
3430
+ let maxX = -Infinity;
3431
+ let minY = Infinity;
3432
+ let maxY = -Infinity;
3433
+ for (const member of members) {
3434
+ const rest = member.mesh.vertices;
3435
+ const shapes = [rest];
3436
+ for (const warp of member.warps) {
3437
+ for (const keyform of warp.keyforms) {
3438
+ shapes.push(rest.map((v, i) => v + keyform.offsets[i]));
3439
+ }
3440
+ }
3441
+ const geometric = member.bindings.filter((b) => b.channel !== "opacity");
3442
+ for (let corner = 0; corner < 1 << geometric.length; corner++) {
3443
+ const trs = transformUnder(
3444
+ geometric,
3445
+ (b, i) => (corner & 1 << i) === 0 ? b.from : b.to
3446
+ );
3447
+ const cos = Math.cos(trs.rotation * (Math.PI / 180));
3448
+ const sin = Math.sin(trs.rotation * (Math.PI / 180));
3449
+ for (const shape of shapes) {
3450
+ for (let i = 0; i < shape.length; i += 2) {
3451
+ const sx = shape[i] * trs.scaleX;
3452
+ const sy = shape[i + 1] * trs.scaleY;
3453
+ const x = member.transform.x + trs.x + cos * sx - sin * sy;
3454
+ const y = member.transform.y + trs.y + sin * sx + cos * sy;
3455
+ minX = Math.min(minX, x);
3456
+ maxX = Math.max(maxX, x);
3457
+ minY = Math.min(minY, y);
3458
+ maxY = Math.max(maxY, y);
3459
+ }
3460
+ }
3461
+ }
3462
+ }
3463
+ minX -= turnShiftPx;
3464
+ maxX += turnShiftPx;
3465
+ minY -= nodShiftPx;
3466
+ maxY += nodShiftPx;
3467
+ const marginX = (maxX - minX) * GRID_MARGIN + 1;
3468
+ const marginY = (maxY - minY) * GRID_MARGIN + 1;
3469
+ return {
3470
+ cols: cells,
3471
+ rows: cells,
3472
+ points: generateGridPoints(
3473
+ cells,
3474
+ cells,
3475
+ minX - marginX,
3476
+ maxX + marginX,
3477
+ minY - marginY,
3478
+ maxY + marginY
3479
+ )
3480
+ };
3481
+ }
3482
+ function eyelidFoldFor(layers, layer, mesh, t) {
3483
+ const side = ROLE_TABLE[layer.role].eyeSide;
3484
+ const isLash = layer.role.startsWith("lash_");
3485
+ if (side === void 0 || !(layer.role.startsWith("eye_") || isLash)) {
3486
+ return void 0;
3487
+ }
3488
+ const white = layers.find((l) => l.role === `eye_${side}`);
3489
+ const creaseWorldY = bboxToTransform(white.bbox, white.canvasW, white.canvasH, white.role).y - EYELID_FOLD_CREASE * white.cropH;
3490
+ const hasLash = layers.some((l) => l.role === `lash_${side}`);
3491
+ return bakeEyelidFoldWarp(
3492
+ mesh,
3493
+ side === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight,
3494
+ creaseWorldY - t.y,
3495
+ isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3496
+ );
3497
+ }
3498
+ function turnSetup(layers) {
3499
+ const faceLayer = layers.find((l) => l.role === "face");
3500
+ const faceTransform = bboxToTransform(
3501
+ faceLayer.bbox,
3502
+ faceLayer.canvasW,
3503
+ faceLayer.canvasH,
3504
+ "face"
3505
+ );
3506
+ const faceCenterX = faceTransform.x;
3507
+ const faceCenterY = faceTransform.y;
3508
+ const faceHalfWidth = faceLayer.cropW / 2;
3509
+ const plateEdgeX = faceCenterX - faceHalfWidth;
3510
+ const hasNose = layers.some((l) => l.role === "nose");
3511
+ const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
3512
+ const turnLayers = layers.filter(
3513
+ (l) => isTurnGroup(ROLE_TABLE[l.role].deformer) || l.role === "hair_front"
3514
+ );
3515
+ const turnYs = turnLayers.map((l) => ({
3516
+ y: bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role).y,
3517
+ h: l.cropH
3518
+ }));
3519
+ let unionMinY = Math.min(...turnYs.map(({ y, h }) => y - h / 2));
3520
+ let unionMaxY = Math.max(...turnYs.map(({ y, h }) => y + h / 2));
3521
+ const spanY = unionMaxY - unionMinY;
3522
+ unionMinY -= spanY * GRID_MARGIN;
3523
+ unionMaxY += spanY * GRID_MARGIN;
3524
+ const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3525
+ const nodRadius = halfH * HEAD_CYLINDER_RADIUS_FACTOR;
3526
+ const parallaxUnitY = headNodParallaxUnit(halfH);
3527
+ const members = [];
3528
+ for (const layer of layers) {
3529
+ const spec = ROLE_TABLE[layer.role];
3530
+ if (!isTurnGroup(spec.deformer)) continue;
3531
+ const { role, cropW, cropH } = layer;
3532
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
3533
+ const { cols, rows } = meshCellsFor(cropW, cropH);
3534
+ const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
3535
+ const bindings = bindingsForRole(spec, role, cropW, cropH, {
3536
+ hasMouthOpen
3537
+ });
3538
+ const fold = eyelidFoldFor(layers, layer, mesh, t);
3539
+ const pose = defaultPoseOf(bindings);
3540
+ members.push({
3541
+ role,
3542
+ group: spec.deformer,
3543
+ transform: t,
3544
+ part: {
3545
+ x: t.x + pose.x,
3546
+ y: t.y + pose.y,
3547
+ cropW,
3548
+ cropH,
3549
+ cols,
3550
+ rows,
3551
+ scaleX: pose.scaleX,
3552
+ scaleY: pose.scaleY,
3553
+ rotation: pose.rotation
3554
+ },
3555
+ mesh,
3556
+ bindings,
3557
+ warps: fold === void 0 ? [] : [fold]
3558
+ });
3559
+ }
3560
+ const landmarks = turnLandmarks(layers);
3561
+ const turnShiftOf = (marks) => hasNose && marks.length > 0 ? familyReachPx(marks, plateEdgeX) : 0;
3562
+ const turnShift = {
3563
+ eye: turnShiftOf(landmarks.eye),
3564
+ nose: turnShiftOf(landmarks.nose),
3565
+ mouth: turnShiftOf(landmarks.mouth)
3566
+ };
3567
+ const groupGrids = /* @__PURE__ */ new Map();
3568
+ for (const { group, role } of members) {
3569
+ if (groupGrids.has(group)) continue;
3570
+ const own = members.filter((m) => m.group === group);
3571
+ const isPlate = group === "faceWarp";
3572
+ const nodShiftPx = hasNose ? Math.max(
3573
+ ...own.map((m) => FEATURE_NOD_DEPTH[roleFamily(m.role)] ?? 0)
3574
+ ) * parallaxUnitY : 0;
3575
+ groupGrids.set(
3576
+ group,
3577
+ groupGridFor(
3578
+ own,
3579
+ isPlate ? FACE_PLATE_CELLS : FEATURE_GRID_CELLS,
3580
+ isPlate ? 0 : turnShift[turnFamily(role)],
3581
+ nodShiftPx
3582
+ )
3583
+ );
3584
+ }
3585
+ const carriers = new Map(
3586
+ members.map((m) => [
3587
+ m.role,
3588
+ { grid: groupGrids.get(m.group), part: m.part }
3589
+ ])
3590
+ );
3591
+ const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3592
+ let reach = 0;
3593
+ for (const grid of groupGrids.values()) {
3594
+ reach = Math.max(
3595
+ reach,
3596
+ faceCenterX - grid.points[0] + maxShift,
3597
+ grid.points[grid.cols * 2] - faceCenterX + maxShift
3598
+ );
3599
+ }
3600
+ const halfCells = Math.floor(reach / TURN_LATTICE_CELL_PX) + 1;
3601
+ const points = [];
3602
+ for (const y of [unionMaxY, unionMinY]) {
3603
+ for (let k = -halfCells; k <= halfCells; k++) {
3604
+ points.push(faceCenterX + k * TURN_LATTICE_CELL_PX, y);
3605
+ }
3606
+ }
3607
+ const lattice = { cols: 2 * halfCells, rows: 1, points };
3608
+ const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3609
+ const hairFront = hairFrontLayer && (() => {
3610
+ const t = bboxToTransform(
3611
+ hairFrontLayer.bbox,
3612
+ hairFrontLayer.canvasW,
3613
+ hairFrontLayer.canvasH,
3614
+ "hair_front"
3615
+ );
3616
+ return {
3617
+ x: t.x,
3618
+ centerY: t.y,
3619
+ cropW: hairFrontLayer.cropW,
3620
+ cropH: hairFrontLayer.cropH
3621
+ };
3622
+ })();
3623
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3624
+ const profile = faceRowProfile(faceLayer, faceCenterY);
3625
+ return {
3626
+ landmarks: turnLandmarks(layers, carriers),
3627
+ lattice,
3628
+ faceCenterX,
3629
+ faceHalfWidth,
3630
+ faceCenterY,
3631
+ nodRadius,
3632
+ carriers,
3633
+ profile,
3634
+ hairFront,
3635
+ eyeRowY,
3636
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
3637
+ members,
3638
+ groupGrids,
3639
+ parallaxUnitY,
3640
+ hasNose,
3641
+ hasMouthOpen
3642
+ };
3643
+ }
3644
+ function solveInputsOf(s) {
3645
+ return [
3646
+ s.landmarks,
3647
+ s.lattice,
3648
+ s.faceCenterX,
3649
+ s.faceHalfWidth,
3650
+ s.faceCenterY,
3651
+ s.nodRadius,
3652
+ s.carriers,
3653
+ s.profile,
3654
+ s.hairFront
3655
+ ];
3656
+ }
2945
3657
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
2946
3658
  validateLayerInputs(layers, canvas);
2947
3659
  const hasHair = layers.some((l) => l.role === "hair_front");
2948
- const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2949
- const hasNose = layers.some((l) => l.role === "nose");
2950
3660
  const bodyLayer = layers.find((l) => l.role === "body");
2951
- const parameters = [
2952
- {
2953
- id: import_format3.StandardParameter.MouthOpen,
2954
- name: "Mouth Open",
2955
- min: 0,
2956
- max: 1,
2957
- default: 0
2958
- },
2959
- {
2960
- id: import_format3.StandardParameter.MouthForm,
2961
- name: "Mouth Form",
2962
- min: -1,
2963
- max: 1,
2964
- default: 0
2965
- },
2966
- {
2967
- id: import_format3.StandardParameter.EyeOpenLeft,
2968
- name: "Eye L",
2969
- min: 0,
2970
- max: 1,
2971
- default: 1
2972
- },
2973
- {
2974
- id: import_format3.StandardParameter.EyeOpenRight,
2975
- name: "Eye R",
2976
- min: 0,
2977
- max: 1,
2978
- default: 1
2979
- },
2980
- {
2981
- id: import_format3.StandardParameter.EyeballX,
2982
- name: "Gaze X",
2983
- min: -1,
2984
- max: 1,
2985
- default: 0
2986
- },
2987
- {
2988
- id: import_format3.StandardParameter.EyeballY,
2989
- name: "Gaze Y",
2990
- min: -1,
2991
- max: 1,
2992
- default: 0
2993
- },
2994
- {
2995
- id: import_format3.StandardParameter.AngleX,
2996
- name: "Head Angle",
2997
- min: -30,
2998
- max: 30,
2999
- default: 0
3000
- },
3001
- {
3002
- id: import_format3.StandardParameter.AngleY,
3003
- name: "Head Angle Y",
3004
- min: -30,
3005
- max: 30,
3006
- default: 0
3007
- },
3008
- {
3009
- id: import_format3.StandardParameter.AngleZ,
3010
- name: "Head Angle Z",
3011
- min: -30,
3012
- max: 30,
3013
- default: 0
3014
- },
3015
- {
3016
- id: import_format3.StandardParameter.Breath,
3017
- name: "Breath",
3018
- min: 0,
3019
- max: 1,
3020
- default: 0
3021
- },
3022
- {
3023
- id: import_format3.StandardParameter.BrowLeftY,
3024
- name: "Brow L Y",
3025
- min: -1,
3026
- max: 1,
3027
- default: 0
3028
- },
3029
- {
3030
- id: import_format3.StandardParameter.BrowRightY,
3031
- name: "Brow R Y",
3032
- min: -1,
3033
- max: 1,
3034
- default: 0
3035
- },
3036
- {
3037
- id: import_format3.StandardParameter.BrowLeftAngle,
3038
- name: "Brow L Angle",
3039
- min: -1,
3040
- max: 1,
3041
- default: 0
3042
- },
3043
- {
3044
- id: import_format3.StandardParameter.BrowRightAngle,
3045
- name: "Brow R Angle",
3046
- min: -1,
3047
- max: 1,
3048
- default: 0
3049
- }
3050
- ];
3661
+ const parameters = [...STANDARD_PARAMETERS];
3051
3662
  if (hasHair) {
3052
3663
  parameters.push(
3053
3664
  {
@@ -3066,86 +3677,28 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3066
3677
  }
3067
3678
  );
3068
3679
  }
3069
- const faceLayers = layers.filter((l) => l.role === "face");
3070
- const faceLayer = faceLayers[0];
3071
- const faceTransform = bboxToTransform(
3072
- faceLayer.bbox,
3073
- faceLayer.canvasW,
3074
- faceLayer.canvasH,
3075
- "face"
3076
- );
3077
- const faceCenterX = faceTransform.x;
3078
- const faceCropH = faceLayer.cropH;
3079
- const faceWarpLayers = layers.filter(
3080
- (l) => ROLE_TABLE[l.role].deformer === "faceWarp"
3081
- );
3082
- let unionMinX = -canvas.width / 2;
3083
- let unionMaxX = canvas.width / 2;
3084
- let unionMinY = -canvas.height / 2;
3085
- let unionMaxY = canvas.height / 2;
3086
- if (faceWarpLayers.length > 0) {
3087
- const transforms = faceWarpLayers.map(
3088
- (l) => bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role)
3089
- );
3090
- unionMinX = Math.min(
3091
- ...transforms.map((t, i) => t.x - faceWarpLayers[i].cropW / 2)
3092
- );
3093
- unionMaxX = Math.max(
3094
- ...transforms.map((t, i) => t.x + faceWarpLayers[i].cropW / 2)
3095
- );
3096
- unionMinY = Math.min(
3097
- ...transforms.map((t, i) => t.y - faceWarpLayers[i].cropH / 2)
3098
- );
3099
- unionMaxY = Math.max(
3100
- ...transforms.map((t, i) => t.y + faceWarpLayers[i].cropH / 2)
3101
- );
3102
- const spanX = unionMaxX - unionMinX;
3103
- const spanY = unionMaxY - unionMinY;
3104
- const MARGIN = 0.12;
3105
- unionMinX -= spanX * MARGIN;
3106
- unionMaxX += spanX * MARGIN;
3107
- unionMinY -= spanY * MARGIN;
3108
- unionMaxY += spanY * MARGIN;
3109
- }
3110
- const halfW = Math.max(faceCenterX - unionMinX, unionMaxX - faceCenterX);
3111
- const faceGridMinX = faceCenterX - halfW;
3112
- const faceGridMaxX = faceCenterX + halfW;
3113
- const faceGrid = {
3114
- cols: FACE_GRID_CELLS,
3115
- rows: FACE_GRID_CELLS,
3116
- points: generateGridPoints(
3117
- FACE_GRID_CELLS,
3118
- FACE_GRID_CELLS,
3119
- faceGridMinX,
3120
- faceGridMaxX,
3121
- unionMinY,
3122
- unionMaxY
3123
- )
3124
- };
3125
- const faceCenterY = faceTransform.y;
3126
- const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3127
- const faceHalfWidth = faceLayer.cropW / 2;
3128
- const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3129
- const turn = hasNose ? solveTurnModel(
3130
- resolveTurnTargets(options.turnTargets),
3131
- turnLandmarks(layers),
3132
- faceGrid,
3680
+ const setup = turnSetup(layers);
3681
+ const {
3682
+ lattice,
3133
3683
  faceCenterX,
3134
3684
  faceHalfWidth,
3135
- hairFrontLayer && (() => {
3136
- const t = bboxToTransform(
3137
- hairFrontLayer.bbox,
3138
- hairFrontLayer.canvasW,
3139
- hairFrontLayer.canvasH,
3140
- "hair_front"
3141
- );
3142
- return {
3143
- x: t.x,
3144
- centerY: t.y,
3145
- cropW: hairFrontLayer.cropW,
3146
- cropH: hairFrontLayer.cropH
3147
- };
3148
- })(),
3685
+ faceCenterY,
3686
+ nodRadius,
3687
+ profile,
3688
+ eyeRowY,
3689
+ plate,
3690
+ edgeAt,
3691
+ plateGuardRows,
3692
+ members,
3693
+ groupGrids,
3694
+ parallaxUnitY,
3695
+ hasNose,
3696
+ hasMouthOpen
3697
+ } = setup;
3698
+ const faceCropH = plate.part.cropH;
3699
+ const turn = hasNose ? solveTurnModel(
3700
+ resolveTurnTargets(options.turnTargets),
3701
+ ...solveInputsOf(setup),
3149
3702
  options.headEdges
3150
3703
  ) : void 0;
3151
3704
  if (turn?.unreachable) {
@@ -3167,25 +3720,28 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3167
3720
  clamped: turn.clamped
3168
3721
  });
3169
3722
  }
3170
- const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
3171
- const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
3172
- const parallaxUnit = headTurnParallaxUnit(faceRadius);
3173
- const parallaxUnitY = headNodParallaxUnit(halfH);
3174
- const faceBottom = faceTransform.y - faceCropH / 2;
3723
+ const latticeReach = Math.max(
3724
+ faceCenterX - lattice.points[0],
3725
+ lattice.points[lattice.cols * 2] - faceCenterX
3726
+ );
3727
+ const surface = turn?.surface ?? turnSurface({
3728
+ faceCenterX,
3729
+ faceCenterY,
3730
+ radius: latticeReach * HEAD_CYLINDER_RADIUS_FACTOR,
3731
+ travel: headTurnTravel(faceHalfWidth),
3732
+ nodRadius,
3733
+ lattice,
3734
+ profile,
3735
+ eyeRowY
3736
+ });
3737
+ const parallaxUnit = headTurnParallaxUnit(surface.radius);
3738
+ const plateLandingAt = plateLandingOn(plate, rowMapsOf(surface));
3739
+ const faceBottom = faceCenterY - faceCropH / 2;
3175
3740
  const neckPivot = {
3176
3741
  x: faceCenterX,
3177
3742
  y: faceBottom - faceCropH * 0.15
3178
3743
  // 15% below face bottom = neck
3179
3744
  };
3180
- const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
3181
- faceGrid,
3182
- import_format3.StandardParameter.AngleX,
3183
- import_format3.StandardParameter.AngleY,
3184
- faceCenterX,
3185
- faceCenterY,
3186
- faceRadius,
3187
- headTravel
3188
- );
3189
3745
  const deformers = [
3190
3746
  // headDeformer: rigid matrix rotating/translating the whole head about the
3191
3747
  // neck pivot. Same bindings as sample-model.ts apart from the turn, which
@@ -3195,14 +3751,14 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3195
3751
  pivot: neckPivot,
3196
3752
  bindings: [
3197
3753
  // No AngleX binding: the turn moves NOTHING rigidly. Its sideways
3198
- // travel is baked into faceWarp's own grid (see
3199
- // bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
3200
- // a silhouette the bangs hold; a translate here would take the hair
3201
- // shell with it. Nor is the turn a roll — the sample model's ±6° "lean
3202
- // into the turn" was tried here and dropped, since rotating about the
3203
- // neck pivot swings the crown (~500px above it) far more than the chin,
3204
- // so the top of the head appeared to lunge ahead of the face on every
3205
- // turn. Roll is AngleZ's job, below.
3754
+ // travel is baked into every group grid (the surface's `travel`, see
3755
+ // bakeTurnGroupWarp2D) so the face slides inside a silhouette the
3756
+ // bangs hold; a translate here would take the hair shell with it. Nor
3757
+ // is the turn a roll — the sample model's ±6° "lean into the turn" was
3758
+ // tried here and dropped, since rotating about the neck pivot swings
3759
+ // the crown (~500px above it) far more than the chin, so the top of
3760
+ // the head appeared to lunge ahead of the face on every turn. Roll is
3761
+ // AngleZ's job, below.
3206
3762
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
3207
3763
  // rigid rotation would sum with the AngleZ roll below at diagonal
3208
3764
  // poses, collapsing pitch into roll.
@@ -3231,19 +3787,25 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3231
3787
  to: HEAD_BREATH_BOB
3232
3788
  }
3233
3789
  ]
3234
- },
3235
- // faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
3236
- // about faceCenterX so the bake's cylinder axis aligns with the face center.
3237
- // One 2D warp carries both the turn and the nod (a deformer holds either
3238
- // `warps` or `warp2d`, never both).
3239
- {
3240
- kind: "warp",
3241
- id: "faceWarp",
3242
- parent: "headDeformer",
3243
- grid: faceGrid,
3244
- warp2d: faceWarp2d
3245
3790
  }
3246
3791
  ];
3792
+ for (const [group, grid] of groupGrids) {
3793
+ const family = turnFamily(members.find((m) => m.group === group).role);
3794
+ const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
3795
+ deformers.push({
3796
+ kind: "warp",
3797
+ id: group,
3798
+ parent: "headDeformer",
3799
+ grid,
3800
+ warp2d: bakeTurnGroupWarp2D(
3801
+ grid,
3802
+ import_format3.StandardParameter.AngleX,
3803
+ import_format3.StandardParameter.AngleY,
3804
+ surface,
3805
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
3806
+ )
3807
+ });
3808
+ }
3247
3809
  if (bodyLayer) {
3248
3810
  const bt = bboxToTransform(
3249
3811
  bodyLayer.bbox,
@@ -3260,8 +3822,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3260
3822
  {
3261
3823
  parameter: import_format3.StandardParameter.AngleX,
3262
3824
  channel: "translateX",
3263
- from: -BODY_TURN_FOLLOW * headTravel,
3264
- to: BODY_TURN_FOLLOW * headTravel
3825
+ from: -BODY_TURN_FOLLOW * surface.travel,
3826
+ to: BODY_TURN_FOLLOW * surface.travel
3265
3827
  },
3266
3828
  // Breath: follow the head's bob, same direction, half amplitude.
3267
3829
  {
@@ -3273,34 +3835,21 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3273
3835
  ]
3274
3836
  });
3275
3837
  }
3276
- const eyeCreaseBySide = {};
3277
- for (const layer of layers) {
3278
- const side = ROLE_TABLE[layer.role].eyeSide;
3279
- if ((layer.role === "eye_L" || layer.role === "eye_R") && side) {
3280
- const ey = bboxToTransform(
3281
- layer.bbox,
3282
- layer.canvasW,
3283
- layer.canvasH,
3284
- layer.role
3285
- ).y;
3286
- eyeCreaseBySide[side] = ey - EYELID_FOLD_CREASE * layer.cropH;
3287
- }
3288
- }
3838
+ const memberByRole = new Map(members.map((m) => [m.role, m]));
3289
3839
  const parts = layers.map((layer) => {
3290
3840
  const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
3291
3841
  const spec = ROLE_TABLE[role];
3292
3842
  const t = bboxToTransform(bbox, canvasW, canvasH, role);
3293
3843
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
3294
3844
  hasMouthOpen,
3295
- parallaxUnit,
3296
3845
  parallaxUnitY,
3297
- hasNose,
3298
- turnDepths: turn?.depths
3846
+ hasNose
3299
3847
  });
3300
3848
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
3301
3849
  if (spec.mesh) {
3850
+ const member = memberByRole.get(role);
3302
3851
  const { cols, rows } = meshCellsFor(cropW, cropH);
3303
- const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
3852
+ const mesh = member?.mesh ?? createPixelGridMesh(cols, rows, cropW, cropH);
3304
3853
  const part = {
3305
3854
  id: role,
3306
3855
  color: [1, 1, 1, 1],
@@ -3315,35 +3864,38 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3315
3864
  part.bindings = roleBindings;
3316
3865
  }
3317
3866
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
3318
- let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3319
- let leadWarp;
3320
- let silhouetteWarp;
3867
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3868
+ const ownWarps = [];
3321
3869
  if (role === "hair_front") {
3322
- const shift = HAIR_FRONT_DEPTH * parallaxUnit;
3323
- const headroom = Math.min(
3324
- faceGridMaxX - (t.x + cropW / 2),
3325
- t.x - cropW / 2 - faceGridMinX
3326
- ) - shift;
3327
- tipShift = Math.min(
3328
- tipShift,
3329
- Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
3870
+ ownWarps.push(
3871
+ bakeHairSwayWarp(
3872
+ mesh,
3873
+ import_format3.StandardParameter.AngleX,
3874
+ HAIR_FRONT_DEPTH * parallaxUnit,
3875
+ HEAD_TURN_MAX_DEG
3876
+ )
3330
3877
  );
3331
- leadWarp = bakeHairSwayWarp(
3332
- mesh,
3333
- import_format3.StandardParameter.AngleX,
3334
- shift,
3335
- HEAD_TURN_MAX_DEG
3878
+ const holdBase = turn?.holdBase ?? plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) + HOLD_CLEARANCE;
3879
+ ownWarps.push(
3880
+ bakeHairFrontSilhouetteWarp(
3881
+ mesh,
3882
+ t.x,
3883
+ t.y,
3884
+ faceCenterX,
3885
+ plateLandingAt,
3886
+ edgeAt,
3887
+ holdBase,
3888
+ turn?.holdEdgeAt ?? (() => holdBase),
3889
+ plateGuardRows
3890
+ )
3336
3891
  );
3337
- const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
3338
- const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
3339
- silhouetteWarp = bakeHairFrontSilhouetteWarp(
3340
- mesh,
3341
- t.x,
3342
- faceCenterX,
3343
- faceHalfWidth,
3344
- holdBase,
3345
- turn?.holdEdgeAt ?? (() => holdBase),
3346
- columnMapAt
3892
+ ownWarps.push(
3893
+ bakeNodWarp(
3894
+ mesh,
3895
+ t.y,
3896
+ surface,
3897
+ hasNose ? HAIR_FRONT_NOD_DEPTH * parallaxUnitY : 0
3898
+ )
3347
3899
  );
3348
3900
  }
3349
3901
  part.warps = [
@@ -3359,24 +3911,12 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3359
3911
  tipShift,
3360
3912
  HAIR_SWAY_RANGE
3361
3913
  ),
3362
- ...leadWarp ? [leadWarp] : [],
3363
- ...silhouetteWarp ? [silhouetteWarp] : []
3914
+ ...ownWarps
3364
3915
  ];
3365
3916
  }
3366
3917
  if (spec.eyeSide !== void 0) {
3367
- const isLash = role.startsWith("lash_");
3368
- if (role.startsWith("eye_") || isLash) {
3369
- const openParam = spec.eyeSide === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight;
3370
- const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
3371
- const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
3372
- part.warps = [
3373
- bakeEyelidFoldWarp(
3374
- mesh,
3375
- openParam,
3376
- creaseWorldY - t.y,
3377
- isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3378
- )
3379
- ];
3918
+ if (role.startsWith("eye_") || role.startsWith("lash_")) {
3919
+ part.warps = [...member.warps];
3380
3920
  } else {
3381
3921
  part.clip = { masks: [`eye_${spec.eyeSide}`] };
3382
3922
  }