@ikijs/editor 0.5.0 → 0.7.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,146 @@ 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
+ }
1779
+ }
1780
+ }
1781
+ function validateStrandEdges(layers, strandEdges) {
1782
+ if (strandEdges === void 0) return;
1783
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1784
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1785
+ if (!isPlainObject(strandEdges)) {
1786
+ throw new Error(
1787
+ `${where} must be a plain object with optional left / right sides`
1788
+ );
1789
+ }
1790
+ for (const key of ["left", "right"]) {
1791
+ const raw = strandEdges[key];
1792
+ if (raw === void 0) continue;
1793
+ const path = `${where}.${key}`;
1794
+ if (!isPlainObject(raw)) {
1795
+ throw new Error(`${path} must be a plain object`);
1796
+ }
1797
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1798
+ const v = raw[field];
1799
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1800
+ throw new Error(
1801
+ `${path}.${field} is ${String(v)}, not a finite number`
1802
+ );
1803
+ }
1804
+ }
1805
+ const rawFace = raw.runFace;
1806
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1807
+ throw new Error(
1808
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1809
+ );
1810
+ }
1811
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1812
+ const cropOf = (role) => {
1813
+ const layer = layers.find((l) => l.role === role);
1814
+ if (layer === void 0) {
1815
+ throw new Error(
1816
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1817
+ );
1818
+ }
1819
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1820
+ return {
1821
+ role,
1822
+ x: t.x,
1823
+ left: t.x - layer.cropW / 2,
1824
+ right: t.x + layer.cropW / 2,
1825
+ bottom: t.y - layer.cropH / 2,
1826
+ top: t.y + layer.cropH / 2
1827
+ };
1828
+ };
1829
+ const hair = cropOf("hair_front");
1830
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1831
+ (a, b) => a.x - b.x
1832
+ );
1833
+ const sign = key === "left" ? -1 : 1;
1834
+ const iris = key === "left" ? lowIris : highIris;
1835
+ const other = key === "left" ? highIris : lowIris;
1836
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1837
+ for (const crop of [iris, hair]) {
1838
+ if (y < crop.bottom || y > crop.top) {
1839
+ throw fail(
1840
+ "y",
1841
+ y,
1842
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1843
+ );
1844
+ }
1845
+ }
1846
+ for (const [field, x] of [
1847
+ ["irisOuter", irisOuter],
1848
+ ["irisInner", irisInner]
1849
+ ]) {
1850
+ if (x < iris.left || x > iris.right) {
1851
+ throw fail(
1852
+ field,
1853
+ x,
1854
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1855
+ );
1856
+ }
1857
+ }
1858
+ if (sign * (irisOuter - irisInner) <= 0) {
1859
+ throw fail(
1860
+ "irisOuter",
1861
+ irisOuter,
1862
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1863
+ );
1864
+ }
1865
+ for (const [field, x] of [
1866
+ ["runOuter", runOuter],
1867
+ ["runFace", runFace]
1868
+ ]) {
1869
+ if (x !== null && (x < hair.left || x > hair.right)) {
1870
+ throw fail(
1871
+ field,
1872
+ x,
1873
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1874
+ );
1875
+ }
1876
+ }
1877
+ if (sign * (runOuter - iris.x) <= 0) {
1878
+ throw fail(
1879
+ "runOuter",
1880
+ runOuter,
1881
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1882
+ );
1883
+ }
1884
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1885
+ throw fail(
1886
+ "runOuter",
1887
+ runOuter,
1888
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1889
+ );
1890
+ }
1891
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1892
+ throw fail(
1893
+ "runFace",
1894
+ runFace,
1895
+ `must lie strictly on this side of the "${other.role}" crop's centre (${other.x}); a run that reaches it has no face-side end here, which is runFace null`
1896
+ );
1897
+ }
1734
1898
  }
1735
1899
  }
1736
1900
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1786,20 +1950,16 @@ function meshCellsFor(w, h) {
1786
1950
  );
1787
1951
  return { cols: cells(w), rows: cells(h) };
1788
1952
  }
1789
- var FACE_GRID_CELLS = 6;
1953
+ var FACE_PLATE_CELLS = 10;
1954
+ var FEATURE_GRID_CELLS = 4;
1955
+ var TURN_LATTICE_CELL_PX = 16;
1956
+ var GRID_MARGIN = 0.12;
1790
1957
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1791
1958
  var HEAD_TURN_MAX_DEG = 30;
1792
1959
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1793
1960
  function headTurnParallaxUnit(radius) {
1794
1961
  return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1795
1962
  }
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
1963
  function pinnedCylinderBend(local, radius, theta) {
1804
1964
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1805
1965
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
@@ -1812,27 +1972,107 @@ function boundedCylinderBend(local, radius, theta) {
1812
1972
  function turnSlide(travel, angleX) {
1813
1973
  return travel * angleX / HEAD_TURN_MAX_DEG;
1814
1974
  }
1815
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
1975
+ var FACE_ROW_MIN_FRACTION = 0.15;
1976
+ var FACE_ROW_SMOOTHING = 0.05;
1977
+ var CHIN_SWING = 0.08;
1978
+ function faceRowProfile(face, faceCenterY) {
1979
+ const raw = face.rowHalfWidths;
1980
+ if (raw === void 0) return void 0;
1981
+ const n = raw.length;
1982
+ let aMax = 0;
1983
+ let widest = -1;
1984
+ for (let i = 0; i < n; i++) {
1985
+ if (raw[i] > 0 && raw[i] >= aMax) {
1986
+ aMax = raw[i];
1987
+ widest = i;
1988
+ }
1989
+ }
1990
+ if (widest < 0) return void 0;
1991
+ const filled = [];
1992
+ for (let i = 0; i < n; i++) {
1993
+ filled.push(i < widest ? aMax : raw[i] > 0 ? raw[i] : filled[i - 1]);
1994
+ }
1995
+ const half = Math.max(1, Math.round(FACE_ROW_SMOOTHING * n / 2));
1996
+ const floor = FACE_ROW_MIN_FRACTION * aMax;
1997
+ const rows = [];
1998
+ for (let i = 0; i < n; i++) {
1999
+ if (i < widest) {
2000
+ rows.push(aMax);
2001
+ continue;
2002
+ }
2003
+ let sum = 0;
2004
+ for (let k = i - half; k <= i + half; k++) {
2005
+ sum += filled[Math.max(0, Math.min(n - 1, k))];
2006
+ }
2007
+ rows.push(Math.max(floor, sum / (2 * half + 1)));
2008
+ }
2009
+ const top = faceCenterY + face.cropH / 2;
2010
+ const rowAt = (y) => Math.max(0, Math.min(n - 1, top - y - 0.5));
2011
+ return {
2012
+ at: (y) => {
2013
+ const r = rowAt(y);
2014
+ const i = Math.floor(r);
2015
+ if (i + 1 >= n) return rows[n - 1];
2016
+ return rows[i] + (rows[i + 1] - rows[i]) * (r - i);
2017
+ },
2018
+ aMax,
2019
+ widestY: top - widest - 0.5,
2020
+ faceHalfWidth: face.cropW / 2
2021
+ };
2022
+ }
2023
+ function turnSurface(spec) {
2024
+ const { faceCenterX, faceCenterY, radius, travel, nodRadius, lattice } = spec;
2025
+ const { profile } = spec;
2026
+ let radiusAt = (_y) => radius;
2027
+ let swingAt = (_y) => 0;
2028
+ if (profile !== void 0) {
2029
+ const aEye = profile.at(spec.eyeRowY ?? faceCenterY);
2030
+ radiusAt = (y) => radius * (profile.at(y) / aEye);
2031
+ swingAt = (y) => y < profile.widestY ? -CHIN_SWING * profile.faceHalfWidth * Math.max(0, 1 - profile.at(y) / profile.aMax) : 0;
2032
+ }
2033
+ return {
2034
+ faceCenterX,
2035
+ faceCenterY,
2036
+ radius,
2037
+ travel,
2038
+ nodRadius,
2039
+ lattice,
2040
+ radiusAt,
2041
+ swingAt,
2042
+ mapAt: (deg, y) => turnColumnMap(
2043
+ lattice,
2044
+ faceCenterX,
2045
+ radiusAt(y),
2046
+ deg,
2047
+ travel + swingAt(y)
2048
+ ),
2049
+ nodBendAt: (y, angleY) => pinnedCylinderBend(
2050
+ y - faceCenterY,
2051
+ nodRadius,
2052
+ angleY * NOD_BEND * (Math.PI / 180)
2053
+ )
2054
+ };
2055
+ }
2056
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1816
2057
  const STOPS = [...HEAD_TURN_STOPS];
1817
- const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1818
2058
  const pointCount = grid.points.length / 2;
1819
- const DEG_TO_RAD = Math.PI / 180;
1820
2059
  const keyforms2d = [];
1821
2060
  for (const angleY of STOPS) {
1822
- const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1823
2061
  for (const angleX of STOPS) {
1824
- const thetaX = angleX * DEG_TO_RAD;
1825
- const slide = turnSlide(travel, angleX);
2062
+ const shift = shiftAt(angleX);
2063
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
1826
2064
  const offsets = [];
1827
2065
  for (let i = 0; i < pointCount; i++) {
1828
- 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
- )
1835
- );
2066
+ const x = grid.points[i * 2];
2067
+ const y = grid.points[i * 2 + 1];
2068
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2069
+ if (angleX === 0) {
2070
+ offsets.push(0, nodDy);
2071
+ continue;
2072
+ }
2073
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2074
+ const turnDy = landed.y - y;
2075
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
1836
2076
  }
1837
2077
  keyforms2d.push({ offsets });
1838
2078
  }
@@ -1863,30 +2103,163 @@ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1863
2103
  );
1864
2104
  }
1865
2105
  const cellFor = (v, edges) => {
1866
- for (let c = 0; c < grid.cols; c++) {
1867
- if (v < edges[c + 1]) return c;
2106
+ let lo = 0;
2107
+ let hi = grid.cols - 1;
2108
+ while (lo < hi) {
2109
+ const mid = lo + hi >> 1;
2110
+ if (v < edges[mid + 1]) hi = mid;
2111
+ else lo = mid + 1;
1868
2112
  }
1869
- return grid.cols - 1;
2113
+ return lo;
2114
+ };
2115
+ const sample = (edges, values, v) => {
2116
+ const c = cellFor(v, edges);
2117
+ const s = Math.max(
2118
+ 0,
2119
+ Math.min(1, (v - edges[c]) / (edges[c + 1] - edges[c]))
2120
+ );
2121
+ if (s === 0) return values[c];
2122
+ if (s === 1) return values[c + 1];
2123
+ return values[c] + (values[c + 1] - values[c]) * s;
1870
2124
  };
1871
2125
  return {
1872
2126
  restX,
1873
2127
  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;
2128
+ mapX: (x) => sample(restX, warpedX, x),
2129
+ invertX: (X) => sample(warpedX, restX, X)
2130
+ };
2131
+ }
2132
+ function renderedLandingX(part, vertexLanding, px, py) {
2133
+ const { cols, rows } = part;
2134
+ const u = (px - part.x + part.cropW / 2) / part.cropW * cols;
2135
+ const v = (part.y + part.cropH / 2 - py) / part.cropH * rows;
2136
+ const col = Math.max(0, Math.min(cols - 1, Math.floor(u)));
2137
+ const row = Math.max(0, Math.min(rows - 1, Math.floor(v)));
2138
+ const fx = Math.max(0, Math.min(1, u - col));
2139
+ const fy = Math.max(0, Math.min(1, v - row));
2140
+ const cos = Math.cos(part.rotation * (Math.PI / 180));
2141
+ const sin = Math.sin(part.rotation * (Math.PI / 180));
2142
+ const landingAt = (c, r) => {
2143
+ const lx = (-part.cropW / 2 + c / cols * part.cropW) * part.scaleX;
2144
+ const ly = (part.cropH / 2 - r / rows * part.cropH) * part.scaleY;
2145
+ return vertexLanding(
2146
+ part.x + cos * lx - sin * ly,
2147
+ part.y + sin * lx + cos * ly
2148
+ );
2149
+ };
2150
+ const tl = landingAt(col, row);
2151
+ const br = landingAt(col + 1, row + 1);
2152
+ if (fx <= fy) {
2153
+ const bl = landingAt(col, row + 1);
2154
+ return tl + fy * (bl - tl) + fx * (br - bl);
2155
+ }
2156
+ const tr = landingAt(col + 1, row);
2157
+ return tl + fx * (tr - tl) + fy * (br - tr);
2158
+ }
2159
+ function gridChildLandingX(grid, nodeLanding, x, y) {
2160
+ const { cols, rows, points } = grid;
2161
+ const stride = cols + 1;
2162
+ let col = cols - 1;
2163
+ for (let c = 0; c < cols; c++) {
2164
+ if (x < points[(c + 1) * 2]) {
2165
+ col = c;
2166
+ break;
2167
+ }
2168
+ }
2169
+ let row = rows - 1;
2170
+ for (let r = 0; r < rows; r++) {
2171
+ if (y > points[(r + 1) * stride * 2 + 1]) {
2172
+ row = r;
2173
+ break;
1889
2174
  }
2175
+ }
2176
+ const xLeft = points[col * 2];
2177
+ const xRight = points[(col + 1) * 2];
2178
+ const yTop = points[row * stride * 2 + 1];
2179
+ const yBottom = points[(row + 1) * stride * 2 + 1];
2180
+ const s = Math.max(0, Math.min(1, (x - xLeft) / (xRight - xLeft)));
2181
+ const t = Math.max(0, Math.min(1, (yTop - y) / (yTop - yBottom)));
2182
+ const at = (c, r) => nodeLanding(points[(r * stride + c) * 2], points[(r * stride + c) * 2 + 1]);
2183
+ const top = at(col, row) + (at(col + 1, row) - at(col, row)) * s;
2184
+ const bot = at(col, row + 1) + (at(col + 1, row + 1) - at(col, row + 1)) * s;
2185
+ return top + (bot - top) * t;
2186
+ }
2187
+ function carrierLandingX(carrier, nodeLanding, px, py) {
2188
+ return renderedLandingX(
2189
+ carrier.part,
2190
+ (vx, vy) => gridChildLandingX(carrier.grid, nodeLanding, vx, vy),
2191
+ px,
2192
+ py
2193
+ );
2194
+ }
2195
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2196
+ if (anchor === void 0) {
2197
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2198
+ }
2199
+ const anchorRow = mapAt(anchor.y);
2200
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2201
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2202
+ const dy = nodeY - anchor.y;
2203
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2204
+ const cos = Math.cos(phi);
2205
+ const sin = Math.sin(phi);
2206
+ return {
2207
+ x: pivotX + cos * dx - sin * dy,
2208
+ y: anchor.y + sin * dx + cos * dy
2209
+ };
2210
+ }
2211
+ function rowMapsOf(surface) {
2212
+ const maps = /* @__PURE__ */ new Map();
2213
+ return (deg, nodeY) => {
2214
+ let byRow = maps.get(deg);
2215
+ if (byRow === void 0) {
2216
+ byRow = /* @__PURE__ */ new Map();
2217
+ maps.set(deg, byRow);
2218
+ }
2219
+ let map = byRow.get(nodeY);
2220
+ if (map === void 0) {
2221
+ map = surface.mapAt(deg, nodeY);
2222
+ byRow.set(nodeY, map);
2223
+ }
2224
+ return map;
2225
+ };
2226
+ }
2227
+ function plateLandingOn(plate, mapAt) {
2228
+ return (deg, x, y) => carrierLandingX(
2229
+ plate,
2230
+ (nodeX, nodeY) => mapAt(deg, nodeY).mapX(nodeX),
2231
+ x,
2232
+ y
2233
+ );
2234
+ }
2235
+ function plateGuardRowsFor(face, hairFront) {
2236
+ const rowsOf = (part, y) => {
2237
+ const { rows } = meshCellsFor(part.cropW, part.cropH);
2238
+ return Array.from(
2239
+ { length: rows + 1 },
2240
+ (_, r) => y + (part.cropH / 2 - r / rows * part.cropH)
2241
+ );
2242
+ };
2243
+ const faceRows = rowsOf(face, face.y);
2244
+ if (hairFront === void 0) return faceRows;
2245
+ const top = face.y + face.cropH / 2;
2246
+ const bottom = face.y - face.cropH / 2;
2247
+ return [
2248
+ ...faceRows,
2249
+ ...rowsOf(hairFront, hairFront.centerY).map(
2250
+ (y) => Math.max(bottom, Math.min(top, y))
2251
+ )
2252
+ ];
2253
+ }
2254
+ function plateGuardsOf(carriers, faceHalfWidth, profile, hairFront) {
2255
+ const plate = carriers.get("face");
2256
+ if (plate === void 0) {
2257
+ throw new Error("auto-rig: plateGuardsOf: the carriers have no face");
2258
+ }
2259
+ return {
2260
+ plate,
2261
+ edgeAt: profile === void 0 ? () => faceHalfWidth : profile.at,
2262
+ plateGuardRows: plateGuardRowsFor(plate.part, hairFront)
1890
2263
  };
1891
2264
  }
1892
2265
  var TurnTargetError = class extends Error {
@@ -1957,12 +2330,15 @@ function resolveTurnTargets(targets = {}) {
1957
2330
  defaulted
1958
2331
  };
1959
2332
  }
1960
- function turnLandmarks(layers) {
2333
+ function turnLandmarks(layers, carriers) {
1961
2334
  const markOf = (role) => {
1962
2335
  const layer = layers.find((l) => l.role === role);
1963
2336
  if (!layer) return void 0;
1964
2337
  const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1965
- return { x: t.x, y: t.y, w: layer.cropW };
2338
+ const mark = { x: t.x, y: t.y, w: layer.cropW };
2339
+ const carrier = carriers?.get(role);
2340
+ if (carrier !== void 0) mark.carrier = carrier;
2341
+ return mark;
1966
2342
  };
1967
2343
  const pairOf = (left, right) => {
1968
2344
  const l = markOf(left);
@@ -1979,23 +2355,67 @@ function turnLandmarks(layers) {
1979
2355
  mouth: one("mouth")
1980
2356
  };
1981
2357
  }
2358
+ function eyeRowOf(landmarks, fallback) {
2359
+ const [l, r] = landmarks.eye;
2360
+ return landmarks.eye.length === 2 && l.y !== void 0 && r.y !== void 0 ? (l.y + r.y) / 2 : fallback;
2361
+ }
1982
2362
  var TURN_BISECT_STEPS = 40;
1983
2363
  var TURN_DEPTH_EPS = 1e-6;
1984
- function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
2364
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2365
+ const { carrier } = landmark;
2366
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2367
+ return carrierLandingX(
2368
+ carrier,
2369
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2370
+ px,
2371
+ py
2372
+ );
2373
+ }
2374
+ function familyReachPx(landmarks, plateEdgeX) {
2375
+ const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2376
+ return Math.max(0, farEdge - plateEdgeX);
2377
+ }
2378
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2379
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2380
+ }
2381
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
1985
2382
  if (landmarks.length === 0) {
1986
2383
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
1987
2384
  }
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;
2385
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2386
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2387
+ const achieved = (d) => landmarks.reduce(
2388
+ (sum, l) => sum + (landmarkLandingX(
2389
+ l,
2390
+ mapAt,
2391
+ -HEAD_TURN_MAX_DEG,
2392
+ -d * unit,
2393
+ l.x,
2394
+ l.y ?? 0
2395
+ ) - l.x),
2396
+ 0
2397
+ ) / landmarks.length;
1992
2398
  const atCap = achieved(cap);
1993
2399
  const atRest = achieved(0);
1994
2400
  const attainable = [atCap, atRest];
1995
- if (target > atRest + TURN_DEPTH_EPS)
1996
- return { reached: false, depth: 0, achieved: atRest, attainable };
1997
- if (target < atCap - TURN_DEPTH_EPS)
1998
- return { reached: false, depth: cap, achieved: atCap, attainable };
2401
+ if (target > atRest + TURN_DEPTH_EPS) {
2402
+ return {
2403
+ reached: false,
2404
+ depth: 0,
2405
+ achieved: atRest,
2406
+ attainable,
2407
+ limit: "floor"
2408
+ };
2409
+ }
2410
+ if (target < atCap - TURN_DEPTH_EPS) {
2411
+ return {
2412
+ reached: false,
2413
+ depth: cap,
2414
+ achieved: atCap,
2415
+ attainable,
2416
+ limit: "cap"
2417
+ };
2418
+ }
1999
2419
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2000
2420
  let lo = 0;
2001
2421
  let hi = cap;
@@ -2011,68 +2431,52 @@ var TURN_SWEEP_MAX_FACTOR = 8;
2011
2431
  var TURN_SWEEP_SAMPLES = 32;
2012
2432
  var HOLD_CLEARANCE = 1;
2013
2433
  var TURN_HOLD_SAMPLES = 16;
2014
- var TURN_HOLD_SATURATION_PASSES = 4;
2015
- function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2434
+ var TURN_SETTLE_PASSES = 24;
2435
+ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
2016
2436
  const local = x - faceCenterX;
2017
2437
  const dist = Math.abs(local);
2018
2438
  const side = Math.sign(local);
2019
- const sideIdx = side < 0 ? 0 : 1;
2020
- if (dist <= faceHalfWidth) return map.mapX(x);
2439
+ const painted = edgeAt(y);
2440
+ if (dist <= painted) return plateLandingAt(deg, x, y);
2021
2441
  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;
2442
+ const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
2443
+ const u = (dist - painted) / (holdBase - painted);
2444
+ return inner + (faceCenterX + side * holdEdge - inner) * u;
2025
2445
  }
2026
- return x + side * (holdEdge[sideIdx] - holdBase);
2446
+ return x + side * (holdEdge - holdBase);
2027
2447
  }
2028
- function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2448
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2029
2449
  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
2450
+ const top = hairFront.centerY + hairFront.cropH / 2;
2451
+ return renderedLandingX(
2452
+ {
2453
+ x: hairFront.x,
2454
+ y: hairFront.centerY,
2455
+ cropW: hairFront.cropW,
2456
+ cropH: hairFront.cropH,
2457
+ cols,
2458
+ rows,
2459
+ scaleX: 1,
2460
+ scaleY: 1,
2461
+ rotation: 0
2462
+ },
2463
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2464
+ x,
2465
+ y
2054
2466
  );
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) {
2467
+ }
2468
+ function shellTravelCap(faceCenterX, edgeAt, plateGuardRows, askedTravel, bendOnlyLandingAt, holdEdgeAt) {
2067
2469
  let cap = Infinity;
2068
2470
  for (const deg of HEAD_TURN_STOPS) {
2069
2471
  if (deg === 0) continue;
2070
- const map = bendOnlyMapAt(deg);
2071
2472
  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
- )
2473
+ const towardReach = plateSideReachAt(
2474
+ bendOnlyLandingAt,
2475
+ faceCenterX,
2476
+ edgeAt,
2477
+ plateGuardRows,
2478
+ deg,
2479
+ slideSign
2076
2480
  );
2077
2481
  cap = Math.min(
2078
2482
  cap,
@@ -2081,8 +2485,39 @@ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt,
2081
2485
  }
2082
2486
  return cap;
2083
2487
  }
2488
+ function aitken(x0, x1, x2, cap) {
2489
+ const d1 = x1 - x0;
2490
+ const d2 = x2 - x1;
2491
+ const denominator = d2 - d1;
2492
+ if (denominator === 0) return x2;
2493
+ const x = x2 - d2 * d2 / denominator;
2494
+ return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2495
+ }
2496
+ function strandDepthCap(margins, plateCap) {
2497
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2498
+ if (holds(plateCap)) return plateCap;
2499
+ if (!holds(0)) return void 0;
2500
+ let lo = 0;
2501
+ let hi = plateCap;
2502
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2503
+ const mid = (lo + hi) / 2;
2504
+ if (holds(mid)) lo = mid;
2505
+ else hi = mid;
2506
+ }
2507
+ return lo;
2508
+ }
2084
2509
  function evaluateTurnCandidate(ctx, radius) {
2085
- const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
2510
+ const surfaceAt = (travel) => turnSurface({
2511
+ faceCenterX: ctx.faceCenterX,
2512
+ faceCenterY: ctx.faceCenterY,
2513
+ radius,
2514
+ travel,
2515
+ nodRadius: ctx.nodRadius,
2516
+ lattice: ctx.lattice,
2517
+ profile: ctx.profile,
2518
+ eyeRowY: ctx.eyeRowY
2519
+ });
2520
+ const bendOnlyLandingAt = plateLandingOn(ctx.plate, rowMapsOf(surfaceAt(0)));
2086
2521
  const measuredEdgeAt = (side) => {
2087
2522
  const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2088
2523
  if (candidates === void 0 || candidates.length === 0) return void 0;
@@ -2094,66 +2529,160 @@ function evaluateTurnCandidate(ctx, radius) {
2094
2529
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2095
2530
  };
2096
2531
  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) => {
2532
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2533
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2534
+ const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2535
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2536
+ x,
2537
+ y,
2538
+ ctx.hairFrontSilhouette,
2539
+ (vx, vy) => hairFrontHoldTarget(
2540
+ vx,
2541
+ vy,
2542
+ deg,
2543
+ ctx.faceCenterX,
2544
+ plateLandingAt,
2545
+ ctx.edgeAt,
2546
+ hold.holdBase,
2547
+ hold.holdEdgeAt(deg)
2548
+ ),
2549
+ leadShift
2550
+ );
2551
+ const strandStopsOn = (hold) => {
2552
+ const rowMaps = rowMapsOf(hold.surface);
2553
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2554
+ return (ctx.strands ?? []).map((strand) => {
2555
+ const iris = {
2556
+ x: strand.carrier.part.x,
2557
+ w: strand.carrier.part.cropW,
2558
+ carrier: strand.carrier
2559
+ };
2560
+ const stops = HEAD_TURN_STOPS.filter(
2561
+ (deg) => Math.sign(deg) === strand.side
2562
+ ).map((deg) => {
2563
+ const runAt = (x) => bangsLandingAt(
2564
+ hold,
2565
+ plateLandingAt,
2566
+ deg,
2567
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2568
+ x,
2569
+ strand.y
2570
+ );
2571
+ return {
2572
+ deg,
2573
+ runOuter: runAt(strand.runOuter),
2574
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2575
+ irisAt: (depth, x) => landmarkLandingX(
2576
+ iris,
2577
+ (y) => rowMaps(deg, y),
2578
+ deg,
2579
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2580
+ x,
2581
+ strand.y
2582
+ )
2583
+ };
2584
+ });
2585
+ const { runFace } = strand;
2586
+ if (runFace === null) return { strand, stops, margins: void 0 };
2587
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2588
+ const margins = stops.map(
2589
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2590
+ );
2591
+ return { strand, stops, margins };
2592
+ });
2593
+ };
2594
+ const strandOverlapOf = (sides, depth) => {
2595
+ const overlap = {};
2596
+ for (const { strand, stops, margins } of sides) {
2597
+ const s = strand.side;
2598
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2599
+ 0,
2600
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2601
+ );
2602
+ let worst;
2603
+ for (const stop of stops) {
2604
+ const px = covered(
2605
+ stop.irisAt(depth, strand.irisOuter),
2606
+ stop.irisAt(depth, strand.irisInner),
2607
+ stop.runOuter,
2608
+ stop.runFace
2609
+ );
2610
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2611
+ worst = { deg: stop.deg, px };
2612
+ }
2613
+ }
2614
+ if (worst === void 0) continue;
2615
+ overlap[s < 0 ? "left" : "right"] = {
2616
+ deg: worst.deg,
2617
+ px: worst.px,
2618
+ hh: worst.px / ctx.hh,
2619
+ restPx: covered(
2620
+ strand.irisOuter,
2621
+ strand.irisInner,
2622
+ strand.runOuter,
2623
+ strand.runFace
2624
+ ),
2625
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2626
+ };
2627
+ }
2628
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2629
+ };
2630
+ const evaluateHold = (ratio, depths2) => {
2100
2631
  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(
2632
+ const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2102
2633
  ctx.travel,
2103
2634
  shellTravelCap(
2104
2635
  ctx.faceCenterX,
2105
- ctx.faceHalfWidth,
2636
+ ctx.edgeAt,
2637
+ ctx.plateGuardRows,
2106
2638
  ctx.travel,
2107
- bendOnlyMapAt,
2639
+ bendOnlyLandingAt,
2108
2640
  (deg, side) => {
2109
2641
  const rest = shellHalfWidthAt(side);
2110
2642
  return Math.min(rest, holdEdgeFrom(rest)(deg));
2111
2643
  }
2112
2644
  )
2113
2645
  );
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);
2646
+ const surface = surfaceAt(travel);
2647
+ const rowMaps = rowMapsOf(surface);
2648
+ const mapAt = (y) => rowMaps(-HEAD_TURN_MAX_DEG, y);
2649
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2650
+ const holdBase = ctx.headHalfWidth ?? plateReach(
2651
+ plateLandingAt,
2652
+ ctx.faceCenterX,
2653
+ ctx.edgeAt,
2654
+ ctx.plateGuardRows
2655
+ ) + HOLD_CLEARANCE;
2656
+ const holdEdgeAt = holdEdgeFrom(holdBase);
2117
2657
  let foldLowerBound = -Infinity;
2118
2658
  let folds = false;
2119
2659
  for (const deg of HEAD_TURN_STOPS) {
2120
2660
  if (deg === 0) continue;
2121
2661
  const reach = plateReachAt(
2122
- columnMapAt(deg),
2662
+ plateLandingAt,
2123
2663
  ctx.faceCenterX,
2124
- ctx.faceHalfWidth
2664
+ ctx.edgeAt,
2665
+ ctx.plateGuardRows,
2666
+ deg
2125
2667
  );
2126
2668
  foldLowerBound = Math.max(
2127
2669
  foldLowerBound,
2128
- 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2670
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2129
2671
  );
2130
- if (holdEdgeAt2(deg) <= reach) folds = true;
2672
+ if (holdEdgeAt(deg) <= reach) folds = true;
2131
2673
  }
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
2674
  const restAt = (side) => {
2143
2675
  const edge = measuredEdgeAt(side);
2144
2676
  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;
2677
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2146
2678
  };
2147
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2679
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2680
+ { holdBase, holdEdgeAt },
2681
+ plateLandingAt,
2682
+ -HEAD_TURN_MAX_DEG,
2683
+ -leadTipShift,
2148
2684
  x,
2149
- ctx.hairFrontSilhouette,
2150
- ctx.faceCenterX,
2151
- ctx.faceHalfWidth,
2152
- holdBase2,
2153
- cappedHoldEdge,
2154
- map2,
2155
- leadTipShift,
2156
- leadRow
2685
+ ctx.eyeRowY
2157
2686
  );
2158
2687
  const landingOfRole = (role, x) => {
2159
2688
  if (role === "hair_front") return hairFrontAt(x);
@@ -2161,10 +2690,29 @@ function evaluateTurnCandidate(ctx, radius) {
2161
2690
  return x;
2162
2691
  }
2163
2692
  if (role === "body") {
2164
- return x - BODY_TURN_FOLLOW * travel2;
2693
+ return x - BODY_TURN_FOLLOW * travel;
2165
2694
  }
2166
2695
  if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2167
- return map2.mapX(x);
2696
+ const carrier = ctx.carriers.get(role);
2697
+ if (carrier === void 0) {
2698
+ throw new Error(
2699
+ `auto-rig: evaluateTurnCandidate: headEdges names "${role}", which this layer set has no layer for`
2700
+ );
2701
+ }
2702
+ const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2703
+ return carrierLandingX(
2704
+ carrier,
2705
+ (nodeX, nodeY) => groupNodeLanding(
2706
+ carrier.anchor,
2707
+ mapAt,
2708
+ -HEAD_TURN_MAX_DEG,
2709
+ shift,
2710
+ nodeX,
2711
+ nodeY
2712
+ ).x,
2713
+ x,
2714
+ ctx.eyeRowY
2715
+ );
2168
2716
  }
2169
2717
  throw new Error(
2170
2718
  `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
@@ -2180,13 +2728,13 @@ function evaluateTurnCandidate(ctx, radius) {
2180
2728
  };
2181
2729
  return {
2182
2730
  ratio,
2183
- travel: travel2,
2184
- map: map2,
2185
- holdBase: holdBase2,
2186
- holdEdgeAt: holdEdgeAt2,
2731
+ travel,
2732
+ mapAt,
2733
+ surface,
2734
+ holdBase,
2735
+ holdEdgeAt,
2187
2736
  folds,
2188
2737
  foldLowerBound,
2189
- gridReach: gridReach2,
2190
2738
  // How far the silhouette's own centre drifts off the face centre at full
2191
2739
  // turn, against how far it already sat off centre at rest (zero unless
2192
2740
  // hair_front's crop is itself off-centre) — the held shell keeps its own
@@ -2203,24 +2751,18 @@ function evaluateTurnCandidate(ctx, radius) {
2203
2751
  restSpan: restAt(1) - restAt(-1)
2204
2752
  };
2205
2753
  };
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();
2754
+ const holdRange = (depths2) => {
2755
+ const ceiling = evaluateHold(TURN_RATIO_MAX, depths2);
2217
2756
  if (ceiling.folds) return void 0;
2218
- let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2757
+ let floor = evaluateHold(
2758
+ Math.min(ceiling.foldLowerBound, ceiling.ratio),
2759
+ depths2
2760
+ );
2219
2761
  if (floor.folds) {
2220
2762
  let lo = floor;
2221
2763
  let hi = ceiling;
2222
2764
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2223
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2765
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2224
2766
  if (mid.folds) lo = mid;
2225
2767
  else hi = mid;
2226
2768
  }
@@ -2229,7 +2771,8 @@ function evaluateTurnCandidate(ctx, radius) {
2229
2771
  const samples = [floor];
2230
2772
  for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2231
2773
  const sample = evaluateHold(
2232
- floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2774
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES,
2775
+ depths2
2233
2776
  );
2234
2777
  if (!sample.folds) samples.push(sample);
2235
2778
  }
@@ -2242,15 +2785,14 @@ function evaluateTurnCandidate(ctx, radius) {
2242
2785
  return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2243
2786
  };
2244
2787
  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();
2788
+ const fitHold = (depths2) => {
2789
+ const natural = evaluateHold(asked, depths2);
2790
+ const eps = TURN_DEPTH_EPS / natural.restSpan;
2791
+ if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2792
+ return { hold: natural, silhouetteClamped: false };
2793
+ }
2794
+ const samples = holdRange(depths2);
2795
+ if (samples === void 0) return void 0;
2254
2796
  const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2255
2797
  const span = [
2256
2798
  Math.min(...rendered),
@@ -2265,91 +2807,169 @@ function evaluateTurnCandidate(ctx, radius) {
2265
2807
  return ea <= eb ? a : b;
2266
2808
  };
2267
2809
  if (asked < span[0] - eps || asked > span[1] + eps) {
2268
- if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2269
2810
  const wanted = asked < span[0] ? span[0] : span[1];
2270
- hold = samples.reduce(
2811
+ const hold = samples.reduce(
2271
2812
  (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2272
2813
  );
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
- }
2814
+ return { hold, silhouetteClamped: true, span };
2815
+ }
2816
+ let best = samples.reduce(nearer);
2817
+ let lo;
2818
+ let hi;
2819
+ for (let i = 0; i + 1 < samples.length; i++) {
2820
+ const a = samples[i].renderedSilhouetteRatio - asked;
2821
+ const b = samples[i + 1].renderedSilhouetteRatio - asked;
2822
+ if (a * b <= 0) {
2823
+ lo = samples[i];
2824
+ hi = samples[i + 1];
2825
+ break;
2286
2826
  }
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
- }
2827
+ }
2828
+ for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2829
+ if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2830
+ break;
2831
+ }
2832
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2833
+ if (!mid.folds) best = nearer(best, mid);
2834
+ if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2835
+ lo = mid;
2836
+ } else {
2837
+ hi = mid;
2298
2838
  }
2299
- hold = best;
2300
2839
  }
2301
- }
2302
- if (hold.folds) {
2303
- throw new Error(
2304
- "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2840
+ return { hold: best, silhouetteClamped: false };
2841
+ };
2842
+ let depths = { eye: 0, nose: 0, mouth: 0 };
2843
+ let seed;
2844
+ for (let pass = 0; ; pass++) {
2845
+ const fitted = fitHold(depths);
2846
+ if (fitted === void 0) return silhouetteMiss();
2847
+ const { hold, silhouetteClamped, span } = fitted;
2848
+ if (hold.folds) {
2849
+ throw new Error(
2850
+ "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2851
+ );
2852
+ }
2853
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2854
+ let strandFeasible = true;
2855
+ let maxDepth;
2856
+ if (strandSides !== void 0) {
2857
+ const margins = strandSides.map((s) => s.margins);
2858
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2859
+ margins.flatMap((m) => m),
2860
+ eyePlateCap
2861
+ ) : void 0;
2862
+ strandFeasible = cap !== void 0;
2863
+ maxDepth = cap ?? 0;
2864
+ }
2865
+ const eye = solveTurnDepthSigned(
2866
+ hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2867
+ ctx.landmarks.eye,
2868
+ unit,
2869
+ hold.mapAt,
2870
+ ctx.plateEdgeX,
2871
+ maxDepth
2305
2872
  );
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) {
2873
+ const features = solveFeatureDepths(
2874
+ ctx,
2875
+ (-eye.achieved + hold.silhouetteCenterShift) / ctx.hh,
2876
+ unit,
2877
+ hold.mapAt,
2878
+ hold.silhouetteCenterShift
2879
+ );
2880
+ const solved = {
2881
+ eye: eye.depth,
2882
+ nose: features.nose.depth,
2883
+ mouth: features.mouth.depth
2884
+ };
2885
+ const settled = evaluateHold(hold.ratio, solved);
2886
+ if (Math.abs(settled.silhouetteCenterShift - hold.silhouetteCenterShift) > TURN_DEPTH_EPS || Math.abs(settled.renderedSilhouetteRatio - hold.renderedSilhouetteRatio) * hold.restSpan > TURN_DEPTH_EPS) {
2887
+ if (pass + 1 >= TURN_SETTLE_PASSES) return { blocked: "settle" };
2888
+ if (seed === void 0) {
2889
+ seed = depths;
2890
+ depths = solved;
2891
+ } else {
2892
+ depths = {
2893
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2894
+ nose: aitken(
2895
+ seed.nose,
2896
+ depths.nose,
2897
+ solved.nose,
2898
+ plateCapOf(ctx.landmarks.nose)
2899
+ ),
2900
+ mouth: aitken(
2901
+ seed.mouth,
2902
+ depths.mouth,
2903
+ solved.mouth,
2904
+ plateCapOf(ctx.landmarks.mouth)
2905
+ )
2906
+ };
2907
+ seed = void 0;
2908
+ }
2909
+ continue;
2910
+ }
2911
+ const {
2912
+ travel,
2913
+ mapAt,
2914
+ surface,
2915
+ holdBase,
2916
+ holdEdgeAt,
2917
+ silhouetteCenterShift,
2918
+ renderedSilhouetteRatio
2919
+ } = settled;
2920
+ if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2921
+ return silhouetteMiss(span);
2922
+ }
2923
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2924
+ return {
2925
+ blocked: "eyeShift",
2926
+ // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
2927
+ // a negative lower bound here would advertise a value the field's own
2928
+ // contract already rules out; 0 is the true floor.
2929
+ offeredShift: [
2930
+ Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2931
+ (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2932
+ ]
2933
+ };
2934
+ }
2935
+ const eyeShiftPx = -eye.depth * unit;
2936
+ const scaleOf = (l) => (landmarkLandingX(
2937
+ l,
2938
+ mapAt,
2939
+ -HEAD_TURN_MAX_DEG,
2940
+ eyeShiftPx,
2941
+ l.x + l.w / 2,
2942
+ l.y ?? 0
2943
+ ) - landmarkLandingX(
2944
+ l,
2945
+ mapAt,
2946
+ -HEAD_TURN_MAX_DEG,
2947
+ eyeShiftPx,
2948
+ l.x - l.w / 2,
2949
+ l.y ?? 0
2950
+ )) / l.w;
2951
+ const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2952
+ const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2953
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2325
2954
  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
- ]
2955
+ radius,
2956
+ ratio: scaleOf(far) / scaleOf(near),
2957
+ unit,
2958
+ travel,
2959
+ mapAt,
2960
+ surface,
2961
+ holdBase,
2962
+ holdEdgeAt,
2963
+ eye,
2964
+ nose: features.nose,
2965
+ mouth: features.mouth,
2966
+ silhouetteClamped,
2967
+ silhouetteCenterShift,
2968
+ renderedSilhouetteRatio,
2969
+ strandFeasible,
2970
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2334
2971
  };
2335
2972
  }
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
2973
  }
2354
2974
  function sweepTurnRadii(ctx) {
2355
2975
  const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
@@ -2358,6 +2978,7 @@ function sweepTurnRadii(ctx) {
2358
2978
  let heldRatioLower = Infinity;
2359
2979
  let heldRatioUpper = -Infinity;
2360
2980
  let offeredShift;
2981
+ let unsettled = 0;
2361
2982
  for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2362
2983
  const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2363
2984
  const result = evaluateTurnCandidate(ctx, radius);
@@ -2366,6 +2987,8 @@ function sweepTurnRadii(ctx) {
2366
2987
  } else if (result.blocked === "silhouetteRatio") {
2367
2988
  heldRatioLower = Math.min(heldRatioLower, result.lower);
2368
2989
  heldRatioUpper = Math.max(heldRatioUpper, result.upper);
2990
+ } else if (result.blocked === "settle") {
2991
+ unsettled++;
2369
2992
  } else {
2370
2993
  offeredShift = offeredShift ? [
2371
2994
  Math.min(offeredShift[0], result.offeredShift[0]),
@@ -2373,7 +2996,16 @@ function sweepTurnRadii(ctx) {
2373
2996
  ] : result.offeredShift;
2374
2997
  }
2375
2998
  }
2376
- return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
2999
+ return {
3000
+ candidates,
3001
+ heldRatioLower,
3002
+ heldRatioUpper,
3003
+ offeredShift,
3004
+ unsettled
3005
+ };
3006
+ }
3007
+ function runHolds(run, ratio) {
3008
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
2377
3009
  }
2378
3010
  function ratioClusters(candidates) {
2379
3011
  const clusters = [];
@@ -2397,7 +3029,7 @@ function ratioClusters(candidates) {
2397
3029
  close();
2398
3030
  return clusters;
2399
3031
  }
2400
- function fitTurnRadius(ctx, candidates, target) {
3032
+ function fitTurnRadius(ctx, candidates, target, accept) {
2401
3033
  let lo = candidates[0];
2402
3034
  let hi = candidates[candidates.length - 1];
2403
3035
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2407,79 +3039,102 @@ function fitTurnRadius(ctx, candidates, target) {
2407
3039
  break;
2408
3040
  }
2409
3041
  }
3042
+ return bisectTurnRadius(
3043
+ lo,
3044
+ hi,
3045
+ target,
3046
+ (radius) => {
3047
+ const mid = evaluateTurnCandidate(ctx, radius);
3048
+ return "blocked" in mid ? void 0 : mid;
3049
+ },
3050
+ accept
3051
+ );
3052
+ }
3053
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3054
+ if (!accept(lo) || !accept(hi)) {
3055
+ throw new Error(
3056
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3057
+ );
3058
+ }
3059
+ const miss = (c) => Math.abs(c.ratio - target);
3060
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2410
3061
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2411
- const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2412
- if ("blocked" in mid) break;
3062
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3063
+ if (mid === void 0) break;
3064
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2413
3065
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2414
3066
  else lo = mid;
2415
3067
  }
2416
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3068
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3069
+ return nearest;
2417
3070
  }
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
- }
3071
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3072
+ const feasible = candidates.filter((c) => c.strandFeasible);
3073
+ if (feasible.length === candidates.length) return candidates;
3074
+ const holds = ratioClusters(feasible).some(
3075
+ (run) => runHolds(run, farEyeRatio)
3076
+ );
3077
+ return holds ? feasible : candidates;
3078
+ }
3079
+ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
3080
+ const solve = (measured, marks, share) => solveTurnDepthSigned(
3081
+ silhouetteCenterShift - (measured ?? Math.abs(share * eyeShift)) * ctx.hh,
3082
+ marks,
3083
+ unit,
3084
+ mapAt,
3085
+ ctx.plateEdgeX
3086
+ );
2457
3087
  return {
2458
- nose: solved.noseShift,
2459
- mouth: solved.mouthShift,
2460
- clamped
3088
+ nose: solve(ctx.noseShift, ctx.landmarks.nose, NOSE_SHIFT_SHARE),
3089
+ mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2461
3090
  };
2462
3091
  }
2463
- function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
3092
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2464
3093
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2465
3094
  throw new TurnTargetError(
2466
3095
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2467
3096
  );
2468
3097
  }
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
- )
3098
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3099
+ let strands;
3100
+ const strandSides = ["left", "right"].filter(
3101
+ (key) => strandEdges?.[key] !== void 0
2476
3102
  );
3103
+ if (strandSides.length > 0) {
3104
+ if (hairFront === void 0) {
3105
+ throw new Error(
3106
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3107
+ );
3108
+ }
3109
+ const irises = ["iris_L", "iris_R"].map((role) => carriers.get(role)).filter((c) => c !== void 0).sort((a, b) => a.part.x - b.part.x);
3110
+ strands = strandSides.map((key) => {
3111
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3112
+ if (carrier === void 0) {
3113
+ throw new Error(
3114
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3115
+ );
3116
+ }
3117
+ return {
3118
+ ...strandEdges[key],
3119
+ side: key === "left" ? -1 : 1,
3120
+ carrier
3121
+ };
3122
+ });
3123
+ }
2477
3124
  const ctx = {
2478
3125
  landmarks,
2479
- faceGrid,
3126
+ lattice,
2480
3127
  faceCenterX,
3128
+ faceCenterY,
3129
+ nodRadius,
3130
+ eyeRowY,
2481
3131
  faceHalfWidth,
3132
+ // The slide's hard stop is the CROP's edge, profile or not: the far eye
3133
+ // may sit on the plate's transparent margin, as it does on the hero.
2482
3134
  plateEdgeX: faceCenterX - faceHalfWidth,
3135
+ carriers,
3136
+ profile,
3137
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
2483
3138
  // What this plate asks the turn for; each candidate caps it to its own
2484
3139
  // shell — see TurnSolveContext.travel.
2485
3140
  travel: headTurnTravel(faceHalfWidth),
@@ -2492,16 +3147,23 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2492
3147
  // every downstream use already has the magnitude.
2493
3148
  eyeShift: Math.abs(targets.eyeShift),
2494
3149
  clampEyeShift: targets.defaulted.has("eyeShift"),
3150
+ noseShift: targets.defaulted.has("noseShift") ? void 0 : Math.abs(targets.noseShift),
3151
+ mouthShift: targets.defaulted.has("mouthShift") ? void 0 : Math.abs(targets.mouthShift),
2495
3152
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2496
- hairFrontLeadFraction,
2497
- hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
3153
+ hairFrontSilhouette: hairFront,
2498
3154
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2499
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3155
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3156
+ strands
2500
3157
  };
2501
- const clamped = [];
2502
3158
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2503
3159
  const pass = sweepTurnRadii(ctx);
2504
3160
  if (pass.candidates.length === 0) {
3161
+ if (pass.unsettled > 0) {
3162
+ const refused = TURN_SWEEP_SAMPLES - pass.unsettled;
3163
+ throw new TurnTargetError(
3164
+ `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}` : ""}`
3165
+ );
3166
+ }
2505
3167
  return pass.offeredShift ? {
2506
3168
  unreachable: true,
2507
3169
  field: "eyeShift",
@@ -2523,72 +3185,107 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2523
3185
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2524
3186
  };
2525
3187
  }
2526
- const clusters = ratioClusters(pass.candidates);
2527
- const distanceTo = (c) => Math.max(
2528
- c.ratios[0] - targets.farEyeRatio,
2529
- targets.farEyeRatio - c.ratios[1],
2530
- 0
2531
- );
2532
- let farEyeRatio = targets.farEyeRatio;
2533
- let cluster = clusters.find(
2534
- (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2535
- );
2536
- if (cluster === void 0) {
2537
- cluster = clusters.reduce(
2538
- (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3188
+ const fitFrom = (pool, accept) => {
3189
+ const clamped = [];
3190
+ const clusters = ratioClusters(pool);
3191
+ const distanceTo = (c) => Math.max(
3192
+ c.ratios[0] - targets.farEyeRatio,
3193
+ targets.farEyeRatio - c.ratios[1],
3194
+ 0
2539
3195
  );
2540
- if (!targets.defaulted.has("farEyeRatio")) {
2541
- return {
2542
- unreachable: true,
2543
- field: "farEyeRatio",
2544
- value: targets.farEyeRatio,
2545
- attainable: cluster.ratios
2546
- };
3196
+ let farEyeRatio = targets.farEyeRatio;
3197
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3198
+ if (cluster === void 0) {
3199
+ cluster = clusters.reduce(
3200
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3201
+ );
3202
+ if (!targets.defaulted.has("farEyeRatio")) {
3203
+ return {
3204
+ unreachable: true,
3205
+ field: "farEyeRatio",
3206
+ value: targets.farEyeRatio,
3207
+ attainable: cluster.ratios
3208
+ };
3209
+ }
3210
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3211
+ clamped.push("farEyeRatio");
2547
3212
  }
2548
- farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2549
- clamped.push("farEyeRatio");
2550
- }
2551
- const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2552
- if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2553
- if (!targets.defaulted.has("farEyeRatio")) {
2554
- return {
2555
- unreachable: true,
2556
- field: "farEyeRatio",
2557
- value: targets.farEyeRatio,
2558
- attainable: cluster.ratios
2559
- };
3213
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3214
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3215
+ if (!targets.defaulted.has("farEyeRatio")) {
3216
+ return {
3217
+ unreachable: true,
3218
+ field: "farEyeRatio",
3219
+ value: targets.farEyeRatio,
3220
+ attainable: cluster.ratios
3221
+ };
3222
+ }
3223
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3224
+ }
3225
+ if (!best.eye.reached) clamped.push("eyeShift");
3226
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3227
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3228
+ for (const [field, solution] of [
3229
+ ["noseShift", best.nose],
3230
+ ["mouthShift", best.mouth]
3231
+ ]) {
3232
+ if (solution.reached) continue;
3233
+ if (!targets.defaulted.has(field)) {
3234
+ return {
3235
+ unreachable: true,
3236
+ field,
3237
+ value: targets[field],
3238
+ // noseShift/mouthShift are MAGNITUDES too (see
3239
+ // solveFeatureDepths), so a negative lower bound here would
3240
+ // advertise a value the field's own contract already rules out; 0
3241
+ // is the true floor.
3242
+ attainable: [
3243
+ Math.max(
3244
+ 0,
3245
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3246
+ ),
3247
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3248
+ ]
3249
+ };
3250
+ }
3251
+ clamped.push(field);
2560
3252
  }
2561
- if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2562
- }
2563
- if (!best.eye.reached) clamped.push("eyeShift");
2564
- if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2565
- const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2566
- const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2567
- if ("blocked" in features) {
2568
3253
  return {
2569
- unreachable: true,
2570
- field: features.blocked,
2571
- value: targets[features.blocked],
2572
- attainable: features.attainable
3254
+ unreachable: false,
3255
+ radius: best.radius,
3256
+ holdBase: best.holdBase,
3257
+ holdEdgeAt: best.holdEdgeAt,
3258
+ travel: best.travel,
3259
+ surface: best.surface,
3260
+ depths: {
3261
+ eye: best.eye.depth,
3262
+ nose: best.nose.depth,
3263
+ mouth: best.mouth.depth
3264
+ },
3265
+ achieved: {
3266
+ eyeShift,
3267
+ farEyeRatio: best.ratio,
3268
+ silhouetteRatio: best.renderedSilhouetteRatio
3269
+ },
3270
+ clamped,
3271
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
2573
3272
  };
2574
- }
2575
- return {
2576
- unreachable: false,
2577
- radius: best.radius,
2578
- holdBase: best.holdBase,
2579
- holdEdgeAt: best.holdEdgeAt,
2580
- travel: best.travel,
2581
- depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2582
- achieved: {
2583
- eyeShift,
2584
- farEyeRatio: best.ratio,
2585
- silhouetteRatio: best.renderedSilhouetteRatio
2586
- },
2587
- clamped: [...clamped, ...features.clamped]
2588
3273
  };
3274
+ const preferred = strandPreferredCandidates(
3275
+ pass.candidates,
3276
+ targets.farEyeRatio
3277
+ );
3278
+ if (preferred !== pass.candidates) {
3279
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3280
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3281
+ return fitted;
3282
+ }
3283
+ }
3284
+ return fitFrom(pass.candidates, () => true);
2589
3285
  }
2590
3286
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2591
3287
  var HAIR_FRONT_DEPTH = 0.1;
3288
+ var MOUTH_TURN_TILT_DEG = 5;
2592
3289
  var NOD_TRAVEL = 30;
2593
3290
  var NOD_BEND = 0.5;
2594
3291
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -2621,27 +3318,16 @@ function featureParallaxBindings(role, options) {
2621
3318
  if (!options.hasNose) return [];
2622
3319
  const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2623
3320
  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
3321
  const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2636
- if (shiftY !== 0) {
2637
- bindings.push({
3322
+ if (shiftY === 0) return [];
3323
+ return [
3324
+ {
2638
3325
  parameter: StandardParameter.AngleY,
2639
3326
  channel: "translateY",
2640
3327
  from: -shiftY,
2641
3328
  to: shiftY
2642
- });
2643
- }
2644
- return bindings;
3329
+ }
3330
+ ];
2645
3331
  }
2646
3332
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2647
3333
  return [
@@ -2749,20 +3435,17 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
2749
3435
  ];
2750
3436
  }
2751
3437
  }
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({
3438
+ if (role === "hair_back") {
3439
+ const shiftY = HAIR_BACK_NOD_DEPTH * (options.parallaxUnitY ?? 0);
3440
+ if (shiftY === 0) return [];
3441
+ return [
3442
+ {
2759
3443
  parameter: StandardParameter.AngleY,
2760
3444
  channel: "translateY",
2761
3445
  from: -shiftY,
2762
3446
  to: shiftY
2763
- });
2764
- }
2765
- return parallax;
3447
+ }
3448
+ ];
2766
3449
  }
2767
3450
  return [];
2768
3451
  }
@@ -2785,64 +3468,98 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2785
3468
  ]
2786
3469
  };
2787
3470
  }
2788
- function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
3471
+ function bakeNodWarp(mesh, partY, surface, nodTravel) {
3472
+ const keyforms = HEAD_TURN_STOPS.map((deg) => {
3473
+ const t = nodTravel * deg / HEAD_TURN_MAX_DEG;
3474
+ const offsets = [];
3475
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
3476
+ const y = partY + mesh.vertices[i + 1];
3477
+ offsets.push(0, deg === 0 ? 0 : t + surface.nodBendAt(y + t, deg));
3478
+ }
3479
+ return { value: deg, offsets };
3480
+ });
3481
+ return { parameter: StandardParameter.AngleY, keyforms };
3482
+ }
3483
+ function plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) {
2789
3484
  let reach = 0;
2790
3485
  for (const deg of HEAD_TURN_STOPS) {
2791
3486
  reach = Math.max(
2792
3487
  reach,
2793
- plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
3488
+ plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
2794
3489
  );
2795
3490
  }
2796
3491
  return reach;
2797
3492
  }
2798
- function plateReachAt(map, faceCenterX, faceHalfWidth) {
3493
+ function plateSideReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg, side) {
2799
3494
  let reach = 0;
2800
- for (const side of [-1, 1]) {
2801
- const dest = map.mapX(faceCenterX + side * faceHalfWidth);
3495
+ for (const y of plateGuardRows) {
3496
+ const dest = plateLandingAt(deg, faceCenterX + side * edgeAt(y), y);
2802
3497
  reach = Math.max(reach, Math.abs(dest - faceCenterX));
2803
3498
  }
2804
3499
  return reach;
2805
3500
  }
2806
- function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
3501
+ function plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg) {
3502
+ return Math.max(
3503
+ plateSideReachAt(
3504
+ plateLandingAt,
3505
+ faceCenterX,
3506
+ edgeAt,
3507
+ plateGuardRows,
3508
+ deg,
3509
+ -1
3510
+ ),
3511
+ plateSideReachAt(
3512
+ plateLandingAt,
3513
+ faceCenterX,
3514
+ edgeAt,
3515
+ plateGuardRows,
3516
+ deg,
3517
+ 1
3518
+ )
3519
+ );
3520
+ }
3521
+ function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows) {
2807
3522
  if (holdEdgeAt(0) !== holdBase) {
2808
3523
  throw new Error(
2809
3524
  `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
3525
  );
2811
3526
  }
2812
- const SIDES = [-1, 1];
2813
3527
  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)
3528
+ const holdEdge = holdEdgeAt(deg);
3529
+ const reach = [-1, 1].map(
3530
+ (side) => plateSideReachAt(
3531
+ plateLandingAt,
3532
+ faceCenterX,
3533
+ edgeAt,
3534
+ plateGuardRows,
3535
+ deg,
3536
+ side
3537
+ )
2818
3538
  );
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;
3539
+ const reached = Math.max(reach[0], reach[1]);
3540
+ if (holdEdge <= reached) {
3541
+ const side = reach[1] > reach[0] ? 1 : -1;
2822
3542
  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`
3543
+ `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
3544
  );
2825
3545
  }
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
3546
  const offsets = [];
2835
3547
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2836
3548
  const x = partX + mesh.vertices[i];
2837
- const target = hairFrontHoldTarget(
2838
- x,
2839
- faceCenterX,
2840
- faceHalfWidth,
2841
- holdBase,
2842
- holdEdge,
2843
- map
3549
+ const y = partY + mesh.vertices[i + 1];
3550
+ offsets.push(
3551
+ deg === 0 ? 0 : hairFrontHoldTarget(
3552
+ x,
3553
+ y,
3554
+ deg,
3555
+ faceCenterX,
3556
+ plateLandingAt,
3557
+ edgeAt,
3558
+ holdBase,
3559
+ holdEdge
3560
+ ) - x,
3561
+ 0
2844
3562
  );
2845
- offsets.push(map.invertX(target) - x, 0);
2846
3563
  }
2847
3564
  return { value: deg, offsets };
2848
3565
  });
@@ -2851,7 +3568,6 @@ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, ho
2851
3568
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2852
3569
  var HAIR_SWAY_CURL = 1.5;
2853
3570
  var HAIR_SWAY_RANGE = 20;
2854
- var HAIR_SWAY_PEAK_FRACTION = 0.56;
2855
3571
  function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2856
3572
  let top = -Infinity;
2857
3573
  let bottom = Infinity;
@@ -2881,112 +3597,385 @@ function headTurnTravel(faceHalfWidth) {
2881
3597
  var BODY_TURN_FOLLOW = 0.3;
2882
3598
  var HEAD_BREATH_BOB = -12;
2883
3599
  var BODY_BREATH_FOLLOW = 0.5;
3600
+ var STANDARD_PARAMETERS = [
3601
+ {
3602
+ id: StandardParameter.MouthOpen,
3603
+ name: "Mouth Open",
3604
+ min: 0,
3605
+ max: 1,
3606
+ default: 0
3607
+ },
3608
+ {
3609
+ id: StandardParameter.MouthForm,
3610
+ name: "Mouth Form",
3611
+ min: -1,
3612
+ max: 1,
3613
+ default: 0
3614
+ },
3615
+ {
3616
+ id: StandardParameter.EyeOpenLeft,
3617
+ name: "Eye L",
3618
+ min: 0,
3619
+ max: 1,
3620
+ default: 1
3621
+ },
3622
+ {
3623
+ id: StandardParameter.EyeOpenRight,
3624
+ name: "Eye R",
3625
+ min: 0,
3626
+ max: 1,
3627
+ default: 1
3628
+ },
3629
+ {
3630
+ id: StandardParameter.EyeballX,
3631
+ name: "Gaze X",
3632
+ min: -1,
3633
+ max: 1,
3634
+ default: 0
3635
+ },
3636
+ {
3637
+ id: StandardParameter.EyeballY,
3638
+ name: "Gaze Y",
3639
+ min: -1,
3640
+ max: 1,
3641
+ default: 0
3642
+ },
3643
+ {
3644
+ id: StandardParameter.AngleX,
3645
+ name: "Head Angle",
3646
+ min: -30,
3647
+ max: 30,
3648
+ default: 0
3649
+ },
3650
+ {
3651
+ id: StandardParameter.AngleY,
3652
+ name: "Head Angle Y",
3653
+ min: -30,
3654
+ max: 30,
3655
+ default: 0
3656
+ },
3657
+ {
3658
+ id: StandardParameter.AngleZ,
3659
+ name: "Head Angle Z",
3660
+ min: -30,
3661
+ max: 30,
3662
+ default: 0
3663
+ },
3664
+ {
3665
+ id: StandardParameter.Breath,
3666
+ name: "Breath",
3667
+ min: 0,
3668
+ max: 1,
3669
+ default: 0
3670
+ },
3671
+ {
3672
+ id: StandardParameter.BrowLeftY,
3673
+ name: "Brow L Y",
3674
+ min: -1,
3675
+ max: 1,
3676
+ default: 0
3677
+ },
3678
+ {
3679
+ id: StandardParameter.BrowRightY,
3680
+ name: "Brow R Y",
3681
+ min: -1,
3682
+ max: 1,
3683
+ default: 0
3684
+ },
3685
+ {
3686
+ id: StandardParameter.BrowLeftAngle,
3687
+ name: "Brow L Angle",
3688
+ min: -1,
3689
+ max: 1,
3690
+ default: 0
3691
+ },
3692
+ {
3693
+ id: StandardParameter.BrowRightAngle,
3694
+ name: "Brow R Angle",
3695
+ min: -1,
3696
+ max: 1,
3697
+ default: 0
3698
+ }
3699
+ ];
3700
+ function transformUnder(bindings, valueOf) {
3701
+ const trs = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 };
3702
+ bindings.forEach((binding, i) => {
3703
+ const value = valueOf(binding, i);
3704
+ switch (binding.channel) {
3705
+ case "translateX":
3706
+ trs.x += value;
3707
+ break;
3708
+ case "translateY":
3709
+ trs.y += value;
3710
+ break;
3711
+ case "rotate":
3712
+ trs.rotation += value;
3713
+ break;
3714
+ case "scaleX":
3715
+ trs.scaleX += value;
3716
+ break;
3717
+ case "scaleY":
3718
+ trs.scaleY += value;
3719
+ break;
3720
+ case "opacity":
3721
+ break;
3722
+ }
3723
+ });
3724
+ return trs;
3725
+ }
3726
+ function defaultPoseOf(bindings) {
3727
+ return transformUnder(bindings, (binding) => {
3728
+ const p = STANDARD_PARAMETERS.find((p2) => p2.id === binding.parameter);
3729
+ if (p === void 0) {
3730
+ throw new Error(
3731
+ `auto-rig: defaultPoseOf: "${binding.parameter}" is not a standard parameter`
3732
+ );
3733
+ }
3734
+ const t = (p.default - p.min) / (p.max - p.min);
3735
+ return binding.from + (binding.to - binding.from) * t;
3736
+ });
3737
+ }
3738
+ function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
3739
+ let minX = Infinity;
3740
+ let maxX = -Infinity;
3741
+ let minY = Infinity;
3742
+ let maxY = -Infinity;
3743
+ for (const member of members) {
3744
+ const rest = member.mesh.vertices;
3745
+ const shapes = [rest];
3746
+ for (const warp of member.warps) {
3747
+ for (const keyform of warp.keyforms) {
3748
+ shapes.push(rest.map((v, i) => v + keyform.offsets[i]));
3749
+ }
3750
+ }
3751
+ const geometric = member.bindings.filter((b) => b.channel !== "opacity");
3752
+ for (let corner = 0; corner < 1 << geometric.length; corner++) {
3753
+ const trs = transformUnder(
3754
+ geometric,
3755
+ (b, i) => (corner & 1 << i) === 0 ? b.from : b.to
3756
+ );
3757
+ const cos = Math.cos(trs.rotation * (Math.PI / 180));
3758
+ const sin = Math.sin(trs.rotation * (Math.PI / 180));
3759
+ for (const shape of shapes) {
3760
+ for (let i = 0; i < shape.length; i += 2) {
3761
+ const sx = shape[i] * trs.scaleX;
3762
+ const sy = shape[i + 1] * trs.scaleY;
3763
+ const x = member.transform.x + trs.x + cos * sx - sin * sy;
3764
+ const y = member.transform.y + trs.y + sin * sx + cos * sy;
3765
+ minX = Math.min(minX, x);
3766
+ maxX = Math.max(maxX, x);
3767
+ minY = Math.min(minY, y);
3768
+ maxY = Math.max(maxY, y);
3769
+ }
3770
+ }
3771
+ }
3772
+ }
3773
+ minX -= turnShiftPx;
3774
+ maxX += turnShiftPx;
3775
+ minY -= nodShiftPx;
3776
+ maxY += nodShiftPx;
3777
+ const marginX = (maxX - minX) * GRID_MARGIN + 1;
3778
+ const marginY = (maxY - minY) * GRID_MARGIN + 1;
3779
+ return {
3780
+ cols: cells,
3781
+ rows: cells,
3782
+ points: generateGridPoints(
3783
+ cells,
3784
+ cells,
3785
+ minX - marginX,
3786
+ maxX + marginX,
3787
+ minY - marginY,
3788
+ maxY + marginY
3789
+ )
3790
+ };
3791
+ }
3792
+ function eyelidFoldFor(layers, layer, mesh, t) {
3793
+ const side = ROLE_TABLE[layer.role].eyeSide;
3794
+ const isLash = layer.role.startsWith("lash_");
3795
+ if (side === void 0 || !(layer.role.startsWith("eye_") || isLash)) {
3796
+ return void 0;
3797
+ }
3798
+ const white = layers.find((l) => l.role === `eye_${side}`);
3799
+ const creaseWorldY = bboxToTransform(white.bbox, white.canvasW, white.canvasH, white.role).y - EYELID_FOLD_CREASE * white.cropH;
3800
+ const hasLash = layers.some((l) => l.role === `lash_${side}`);
3801
+ return bakeEyelidFoldWarp(
3802
+ mesh,
3803
+ side === "L" ? StandardParameter.EyeOpenLeft : StandardParameter.EyeOpenRight,
3804
+ creaseWorldY - t.y,
3805
+ isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3806
+ );
3807
+ }
3808
+ function turnSetup(layers) {
3809
+ const faceLayer = layers.find((l) => l.role === "face");
3810
+ const faceTransform = bboxToTransform(
3811
+ faceLayer.bbox,
3812
+ faceLayer.canvasW,
3813
+ faceLayer.canvasH,
3814
+ "face"
3815
+ );
3816
+ const faceCenterX = faceTransform.x;
3817
+ const faceCenterY = faceTransform.y;
3818
+ const faceHalfWidth = faceLayer.cropW / 2;
3819
+ const plateEdgeX = faceCenterX - faceHalfWidth;
3820
+ const hasNose = layers.some((l) => l.role === "nose");
3821
+ const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
3822
+ const turnLayers = layers.filter(
3823
+ (l) => isTurnGroup(ROLE_TABLE[l.role].deformer) || l.role === "hair_front"
3824
+ );
3825
+ const turnYs = turnLayers.map((l) => ({
3826
+ y: bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role).y,
3827
+ h: l.cropH
3828
+ }));
3829
+ let unionMinY = Math.min(...turnYs.map(({ y, h }) => y - h / 2));
3830
+ let unionMaxY = Math.max(...turnYs.map(({ y, h }) => y + h / 2));
3831
+ const spanY = unionMaxY - unionMinY;
3832
+ unionMinY -= spanY * GRID_MARGIN;
3833
+ unionMaxY += spanY * GRID_MARGIN;
3834
+ const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3835
+ const nodRadius = halfH * HEAD_CYLINDER_RADIUS_FACTOR;
3836
+ const parallaxUnitY = headNodParallaxUnit(halfH);
3837
+ const members = [];
3838
+ for (const layer of layers) {
3839
+ const spec = ROLE_TABLE[layer.role];
3840
+ if (!isTurnGroup(spec.deformer)) continue;
3841
+ const { role, cropW, cropH } = layer;
3842
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
3843
+ const { cols, rows } = meshCellsFor(cropW, cropH);
3844
+ const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
3845
+ const bindings = bindingsForRole(spec, role, cropW, cropH, {
3846
+ hasMouthOpen
3847
+ });
3848
+ const fold = eyelidFoldFor(layers, layer, mesh, t);
3849
+ const pose = defaultPoseOf(bindings);
3850
+ members.push({
3851
+ role,
3852
+ group: spec.deformer,
3853
+ transform: t,
3854
+ part: {
3855
+ x: t.x + pose.x,
3856
+ y: t.y + pose.y,
3857
+ cropW,
3858
+ cropH,
3859
+ cols,
3860
+ rows,
3861
+ scaleX: pose.scaleX,
3862
+ scaleY: pose.scaleY,
3863
+ rotation: pose.rotation
3864
+ },
3865
+ mesh,
3866
+ bindings,
3867
+ warps: fold === void 0 ? [] : [fold]
3868
+ });
3869
+ }
3870
+ const landmarks = turnLandmarks(layers);
3871
+ const turnShiftOf = (marks) => hasNose && marks.length > 0 ? familyReachPx(marks, plateEdgeX) : 0;
3872
+ const turnShift = {
3873
+ eye: turnShiftOf(landmarks.eye),
3874
+ nose: turnShiftOf(landmarks.nose),
3875
+ mouth: turnShiftOf(landmarks.mouth)
3876
+ };
3877
+ const groupGrids = /* @__PURE__ */ new Map();
3878
+ for (const { group, role } of members) {
3879
+ if (groupGrids.has(group)) continue;
3880
+ const own = members.filter((m) => m.group === group);
3881
+ const isPlate = group === "faceWarp";
3882
+ const nodShiftPx = hasNose ? Math.max(
3883
+ ...own.map((m) => FEATURE_NOD_DEPTH[roleFamily(m.role)] ?? 0)
3884
+ ) * parallaxUnitY : 0;
3885
+ groupGrids.set(
3886
+ group,
3887
+ groupGridFor(
3888
+ own,
3889
+ isPlate ? FACE_PLATE_CELLS : FEATURE_GRID_CELLS,
3890
+ isPlate ? 0 : turnShift[turnFamily(role)],
3891
+ nodShiftPx
3892
+ )
3893
+ );
3894
+ }
3895
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
3896
+ const mouthAnchor = {
3897
+ x: mouthAt.x,
3898
+ y: mouthAt.y,
3899
+ tiltDeg: MOUTH_TURN_TILT_DEG
3900
+ };
3901
+ const carriers = new Map(
3902
+ members.map((m) => [
3903
+ m.role,
3904
+ m.group === "mouthWarp" ? { grid: groupGrids.get(m.group), part: m.part, anchor: mouthAnchor } : { grid: groupGrids.get(m.group), part: m.part }
3905
+ ])
3906
+ );
3907
+ const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3908
+ let reach = 0;
3909
+ for (const grid of groupGrids.values()) {
3910
+ reach = Math.max(
3911
+ reach,
3912
+ faceCenterX - grid.points[0] + maxShift,
3913
+ grid.points[grid.cols * 2] - faceCenterX + maxShift
3914
+ );
3915
+ }
3916
+ const halfCells = Math.floor(reach / TURN_LATTICE_CELL_PX) + 1;
3917
+ const points = [];
3918
+ for (const y of [unionMaxY, unionMinY]) {
3919
+ for (let k = -halfCells; k <= halfCells; k++) {
3920
+ points.push(faceCenterX + k * TURN_LATTICE_CELL_PX, y);
3921
+ }
3922
+ }
3923
+ const lattice = { cols: 2 * halfCells, rows: 1, points };
3924
+ const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3925
+ const hairFront = hairFrontLayer && (() => {
3926
+ const t = bboxToTransform(
3927
+ hairFrontLayer.bbox,
3928
+ hairFrontLayer.canvasW,
3929
+ hairFrontLayer.canvasH,
3930
+ "hair_front"
3931
+ );
3932
+ return {
3933
+ x: t.x,
3934
+ centerY: t.y,
3935
+ cropW: hairFrontLayer.cropW,
3936
+ cropH: hairFrontLayer.cropH
3937
+ };
3938
+ })();
3939
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3940
+ const profile = faceRowProfile(faceLayer, faceCenterY);
3941
+ return {
3942
+ landmarks: turnLandmarks(layers, carriers),
3943
+ lattice,
3944
+ faceCenterX,
3945
+ faceHalfWidth,
3946
+ faceCenterY,
3947
+ nodRadius,
3948
+ carriers,
3949
+ profile,
3950
+ hairFront,
3951
+ eyeRowY,
3952
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
3953
+ members,
3954
+ groupGrids,
3955
+ parallaxUnitY,
3956
+ hasNose,
3957
+ hasMouthOpen
3958
+ };
3959
+ }
3960
+ function solveInputsOf(s) {
3961
+ return [
3962
+ s.landmarks,
3963
+ s.lattice,
3964
+ s.faceCenterX,
3965
+ s.faceHalfWidth,
3966
+ s.faceCenterY,
3967
+ s.nodRadius,
3968
+ s.carriers,
3969
+ s.profile,
3970
+ s.hairFront
3971
+ ];
3972
+ }
2884
3973
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
2885
3974
  validateLayerInputs(layers, canvas);
3975
+ validateStrandEdges(layers, options.strandEdges);
2886
3976
  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
3977
  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
- ];
3978
+ const parameters = [...STANDARD_PARAMETERS];
2990
3979
  if (hasHair) {
2991
3980
  parameters.push(
2992
3981
  {
@@ -3005,87 +3994,31 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3005
3994
  }
3006
3995
  );
3007
3996
  }
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,
3997
+ const setup = turnSetup(layers);
3998
+ const {
3999
+ lattice,
3072
4000
  faceCenterX,
3073
4001
  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
- })(),
3088
- options.headEdges
4002
+ faceCenterY,
4003
+ nodRadius,
4004
+ profile,
4005
+ eyeRowY,
4006
+ plate,
4007
+ edgeAt,
4008
+ plateGuardRows,
4009
+ members,
4010
+ groupGrids,
4011
+ carriers,
4012
+ parallaxUnitY,
4013
+ hasNose,
4014
+ hasMouthOpen
4015
+ } = setup;
4016
+ const faceCropH = plate.part.cropH;
4017
+ const turn = hasNose ? solveTurnModel(
4018
+ resolveTurnTargets(options.turnTargets),
4019
+ ...solveInputsOf(setup),
4020
+ options.headEdges,
4021
+ options.strandEdges
3089
4022
  ) : void 0;
3090
4023
  if (turn?.unreachable) {
3091
4024
  throw new TurnTargetError(
@@ -3103,28 +4036,32 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3103
4036
  holdBase: turn.holdBase,
3104
4037
  depths: turn.depths,
3105
4038
  achieved: turn.achieved,
3106
- clamped: turn.clamped
4039
+ clamped: turn.clamped,
4040
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3107
4041
  });
3108
4042
  }
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;
4043
+ const latticeReach = Math.max(
4044
+ faceCenterX - lattice.points[0],
4045
+ lattice.points[lattice.cols * 2] - faceCenterX
4046
+ );
4047
+ const surface = turn?.surface ?? turnSurface({
4048
+ faceCenterX,
4049
+ faceCenterY,
4050
+ radius: latticeReach * HEAD_CYLINDER_RADIUS_FACTOR,
4051
+ travel: headTurnTravel(faceHalfWidth),
4052
+ nodRadius,
4053
+ lattice,
4054
+ profile,
4055
+ eyeRowY
4056
+ });
4057
+ const parallaxUnit = headTurnParallaxUnit(surface.radius);
4058
+ const plateLandingAt = plateLandingOn(plate, rowMapsOf(surface));
4059
+ const faceBottom = faceCenterY - faceCropH / 2;
3114
4060
  const neckPivot = {
3115
4061
  x: faceCenterX,
3116
4062
  y: faceBottom - faceCropH * 0.15
3117
4063
  // 15% below face bottom = neck
3118
4064
  };
3119
- const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
3120
- faceGrid,
3121
- StandardParameter.AngleX,
3122
- StandardParameter.AngleY,
3123
- faceCenterX,
3124
- faceCenterY,
3125
- faceRadius,
3126
- headTravel
3127
- );
3128
4065
  const deformers = [
3129
4066
  // headDeformer: rigid matrix rotating/translating the whole head about the
3130
4067
  // neck pivot. Same bindings as sample-model.ts apart from the turn, which
@@ -3134,14 +4071,14 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3134
4071
  pivot: neckPivot,
3135
4072
  bindings: [
3136
4073
  // 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.
4074
+ // travel is baked into every group grid (the surface's `travel`, see
4075
+ // bakeTurnGroupWarp2D) so the face slides inside a silhouette the
4076
+ // bangs hold; a translate here would take the hair shell with it. Nor
4077
+ // is the turn a roll — the sample model's ±6° "lean into the turn" was
4078
+ // tried here and dropped, since rotating about the neck pivot swings
4079
+ // the crown (~500px above it) far more than the chin, so the top of
4080
+ // the head appeared to lunge ahead of the face on every turn. Roll is
4081
+ // AngleZ's job, below.
3145
4082
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
3146
4083
  // rigid rotation would sum with the AngleZ roll below at diagonal
3147
4084
  // poses, collapsing pitch into roll.
@@ -3170,19 +4107,27 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3170
4107
  to: HEAD_BREATH_BOB
3171
4108
  }
3172
4109
  ]
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
4110
  }
3185
4111
  ];
4112
+ for (const [group, grid] of groupGrids) {
4113
+ const { role } = members.find((m) => m.group === group);
4114
+ const family = turnFamily(role);
4115
+ const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
4116
+ deformers.push({
4117
+ kind: "warp",
4118
+ id: group,
4119
+ parent: "headDeformer",
4120
+ grid,
4121
+ warp2d: bakeTurnGroupWarp2D(
4122
+ grid,
4123
+ StandardParameter.AngleX,
4124
+ StandardParameter.AngleY,
4125
+ surface,
4126
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4127
+ carriers.get(role).anchor
4128
+ )
4129
+ });
4130
+ }
3186
4131
  if (bodyLayer) {
3187
4132
  const bt = bboxToTransform(
3188
4133
  bodyLayer.bbox,
@@ -3199,8 +4144,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3199
4144
  {
3200
4145
  parameter: StandardParameter.AngleX,
3201
4146
  channel: "translateX",
3202
- from: -BODY_TURN_FOLLOW * headTravel,
3203
- to: BODY_TURN_FOLLOW * headTravel
4147
+ from: -BODY_TURN_FOLLOW * surface.travel,
4148
+ to: BODY_TURN_FOLLOW * surface.travel
3204
4149
  },
3205
4150
  // Breath: follow the head's bob, same direction, half amplitude.
3206
4151
  {
@@ -3212,34 +4157,21 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3212
4157
  ]
3213
4158
  });
3214
4159
  }
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
- }
4160
+ const memberByRole = new Map(members.map((m) => [m.role, m]));
3228
4161
  const parts = layers.map((layer) => {
3229
4162
  const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
3230
4163
  const spec = ROLE_TABLE[role];
3231
4164
  const t = bboxToTransform(bbox, canvasW, canvasH, role);
3232
4165
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
3233
4166
  hasMouthOpen,
3234
- parallaxUnit,
3235
4167
  parallaxUnitY,
3236
- hasNose,
3237
- turnDepths: turn?.depths
4168
+ hasNose
3238
4169
  });
3239
4170
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
3240
4171
  if (spec.mesh) {
4172
+ const member = memberByRole.get(role);
3241
4173
  const { cols, rows } = meshCellsFor(cropW, cropH);
3242
- const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
4174
+ const mesh = member?.mesh ?? createPixelGridMesh(cols, rows, cropW, cropH);
3243
4175
  const part = {
3244
4176
  id: role,
3245
4177
  color: [1, 1, 1, 1],
@@ -3254,35 +4186,38 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3254
4186
  part.bindings = roleBindings;
3255
4187
  }
3256
4188
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
3257
- let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3258
- let leadWarp;
3259
- let silhouetteWarp;
4189
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
4190
+ const ownWarps = [];
3260
4191
  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
4192
+ ownWarps.push(
4193
+ bakeHairSwayWarp(
4194
+ mesh,
4195
+ StandardParameter.AngleX,
4196
+ HAIR_FRONT_DEPTH * parallaxUnit,
4197
+ HEAD_TURN_MAX_DEG
4198
+ )
3269
4199
  );
3270
- leadWarp = bakeHairSwayWarp(
3271
- mesh,
3272
- StandardParameter.AngleX,
3273
- shift,
3274
- HEAD_TURN_MAX_DEG
4200
+ const holdBase = turn?.holdBase ?? plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) + HOLD_CLEARANCE;
4201
+ ownWarps.push(
4202
+ bakeHairFrontSilhouetteWarp(
4203
+ mesh,
4204
+ t.x,
4205
+ t.y,
4206
+ faceCenterX,
4207
+ plateLandingAt,
4208
+ edgeAt,
4209
+ holdBase,
4210
+ turn?.holdEdgeAt ?? (() => holdBase),
4211
+ plateGuardRows
4212
+ )
3275
4213
  );
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
4214
+ ownWarps.push(
4215
+ bakeNodWarp(
4216
+ mesh,
4217
+ t.y,
4218
+ surface,
4219
+ hasNose ? HAIR_FRONT_NOD_DEPTH * parallaxUnitY : 0
4220
+ )
3286
4221
  );
3287
4222
  }
3288
4223
  part.warps = [
@@ -3298,24 +4233,12 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3298
4233
  tipShift,
3299
4234
  HAIR_SWAY_RANGE
3300
4235
  ),
3301
- ...leadWarp ? [leadWarp] : [],
3302
- ...silhouetteWarp ? [silhouetteWarp] : []
4236
+ ...ownWarps
3303
4237
  ];
3304
4238
  }
3305
4239
  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
- ];
4240
+ if (role.startsWith("eye_") || role.startsWith("lash_")) {
4241
+ part.warps = [...member.warps];
3319
4242
  } else {
3320
4243
  part.clip = { masks: [`eye_${spec.eyeSide}`] };
3321
4244
  }