@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.js CHANGED
@@ -1664,6 +1664,21 @@ function createDefaultWarpDeformer(model) {
1664
1664
 
1665
1665
  // src/auto-rig.ts
1666
1666
  var import_format3 = require("@ikijs/format");
1667
+ var TURN_GROUP_IDS = [
1668
+ "faceWarp",
1669
+ "eyeWarp_L",
1670
+ "eyeWarp_R",
1671
+ "mouthWarp",
1672
+ "noseWarp",
1673
+ "browWarp_L",
1674
+ "browWarp_R",
1675
+ "blushWarp_L",
1676
+ "blushWarp_R"
1677
+ ];
1678
+ var TURN_GROUP_ID_SET = new Set(TURN_GROUP_IDS);
1679
+ function isTurnGroup(id) {
1680
+ return TURN_GROUP_ID_SET.has(id);
1681
+ }
1667
1682
  var ROLE_TABLE = {
1668
1683
  // The silhouette behind the face. It rides the rigid head (not faceWarp,
1669
1684
  // whose grid it would stretch across the shoulders) as a MESH, so its ends
@@ -1677,34 +1692,43 @@ var ROLE_TABLE = {
1677
1692
  // reading as a floating head. Drawn over the back hair so long hair falls
1678
1693
  // behind the shoulders.
1679
1694
  body: { deformer: "bodyDeformer", order: 5, mesh: false },
1695
+ // The face plate and everything painted on it ride per-group turn warps
1696
+ // under the head (TurnGroupId): the plate its own, each eye stack its
1697
+ // side's, both mouth drawings one, the nose, each brow and each blush their
1698
+ // own. A group's grid is sized to its members alone and baked from the one
1699
+ // solved surface with the family's turn depth in its keyforms, so a feature
1700
+ // slides across the plate on its own grid instead of a translate binding
1701
+ // (see generateIkiFromLayerSet).
1680
1702
  face: { deformer: "faceWarp", order: 10, mesh: true },
1681
- nose: { deformer: "faceWarp", order: 15, mesh: true },
1682
- blush_L: { deformer: "faceWarp", order: 20, mesh: true },
1683
- blush_R: { deformer: "faceWarp", order: 20, mesh: true },
1684
- mouth: { deformer: "faceWarp", order: 25, mesh: true },
1703
+ nose: { deformer: "noseWarp", order: 15, mesh: true },
1704
+ blush_L: { deformer: "blushWarp_L", order: 20, mesh: true },
1705
+ blush_R: { deformer: "blushWarp_R", order: 20, mesh: true },
1706
+ mouth: { deformer: "mouthWarp", order: 25, mesh: true },
1685
1707
  // An OPTIONAL second mouth drawing, open. When present the two cross-fade on
1686
1708
  // MouthOpen instead of the closed one being stretched, which is the
1687
1709
  // difference between a portrait rig and one that can lip-sync.
1688
- mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
1689
- eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
1690
- eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
1691
- iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
1692
- iris_R: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "R" },
1693
- pupil_L: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "L" },
1694
- pupil_R: { deformer: "faceWarp", order: 32, mesh: true, eyeSide: "R" },
1695
- highlight_L: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "L" },
1696
- highlight_R: { deformer: "faceWarp", order: 33, mesh: true, eyeSide: "R" },
1710
+ mouth_open: { deformer: "mouthWarp", order: 26, mesh: true },
1711
+ eye_L: { deformer: "eyeWarp_L", order: 30, mesh: true, eyeSide: "L" },
1712
+ eye_R: { deformer: "eyeWarp_R", order: 30, mesh: true, eyeSide: "R" },
1713
+ iris_L: { deformer: "eyeWarp_L", order: 31, mesh: true, eyeSide: "L" },
1714
+ iris_R: { deformer: "eyeWarp_R", order: 31, mesh: true, eyeSide: "R" },
1715
+ pupil_L: { deformer: "eyeWarp_L", order: 32, mesh: true, eyeSide: "L" },
1716
+ pupil_R: { deformer: "eyeWarp_R", order: 32, mesh: true, eyeSide: "R" },
1717
+ highlight_L: { deformer: "eyeWarp_L", order: 33, mesh: true, eyeSide: "L" },
1718
+ highlight_R: { deformer: "eyeWarp_R", order: 33, mesh: true, eyeSide: "R" },
1697
1719
  // Upper lashes: an OPTIONAL separate layer ABOVE the iris that folds down to
1698
1720
  // the closed-eye seam (the same crease the white folds to), covering the cut
1699
1721
  // eyeball cleanly. When absent, the white's own fold is the only closed line.
1700
- lash_L: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "L" },
1701
- lash_R: { deformer: "faceWarp", order: 34, mesh: true, eyeSide: "R" },
1702
- brow_L: { deformer: "faceWarp", order: 40, mesh: true },
1703
- brow_R: { deformer: "faceWarp", order: 40, mesh: true },
1704
- // Front hair rides faceWarp (mesh) so it follows the head-turn curvature with
1705
- // the face instead of detaching as a rigid blob; its bbox joins the faceWarp
1706
- // grid union so the grid covers it.
1707
- hair_front: { deformer: "faceWarp", order: 50, mesh: true }
1722
+ lash_L: { deformer: "eyeWarp_L", order: 34, mesh: true, eyeSide: "L" },
1723
+ lash_R: { deformer: "eyeWarp_R", order: 34, mesh: true, eyeSide: "R" },
1724
+ brow_L: { deformer: "browWarp_L", order: 40, mesh: true },
1725
+ brow_R: { deformer: "browWarp_R", order: 40, mesh: true },
1726
+ // The bangs ride the rigid head, like the back hair, and bind to no grid:
1727
+ // every motion of their own — the silhouette hold, the turn lead, the nod
1728
+ // and the sway — is a per-vertex warp attached in generateIkiFromLayerSet,
1729
+ // so they draw the head's outline where the plate under them bends and
1730
+ // slides (see bakeHairFrontSilhouetteWarp).
1731
+ hair_front: { deformer: "headDeformer", order: 50, mesh: true }
1708
1732
  };
1709
1733
  var REQUIRED_ROLES = ["face", "eye_L", "eye_R", "mouth"];
1710
1734
  var ALIAS_MAP = {
@@ -1792,6 +1816,146 @@ function validateLayerInputs(layers, canvas) {
1792
1816
  `auto-rig: validateLayerInputs: role "${role}" canvas size (${canvasW}\xD7${canvasH}) does not match canvas arg (${canvas.width}\xD7${canvas.height})`
1793
1817
  );
1794
1818
  }
1819
+ if (layer.rowHalfWidths !== void 0) {
1820
+ const rows = layer.rowHalfWidths;
1821
+ if (!Array.isArray(rows)) {
1822
+ throw new Error(
1823
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths must be an array with one entry per crop row`
1824
+ );
1825
+ }
1826
+ if (rows.length !== cropH) {
1827
+ throw new Error(
1828
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths has ${rows.length} entries, not one per crop row (cropH ${cropH})`
1829
+ );
1830
+ }
1831
+ for (let i = 0; i < rows.length; i++) {
1832
+ const a = rows[i];
1833
+ if (typeof a !== "number" || !Number.isFinite(a) || a < 0 || a > cropW / 2) {
1834
+ throw new Error(
1835
+ `auto-rig: validateLayerInputs: role "${role}" rowHalfWidths[${i}] is ${String(a)}, not a finite number in [0, ${cropW / 2}] (half the crop's width)`
1836
+ );
1837
+ }
1838
+ }
1839
+ }
1840
+ }
1841
+ }
1842
+ function validateStrandEdges(layers, strandEdges) {
1843
+ if (strandEdges === void 0) return;
1844
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1845
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1846
+ if (!isPlainObject(strandEdges)) {
1847
+ throw new Error(
1848
+ `${where} must be a plain object with optional left / right sides`
1849
+ );
1850
+ }
1851
+ for (const key of ["left", "right"]) {
1852
+ const raw = strandEdges[key];
1853
+ if (raw === void 0) continue;
1854
+ const path = `${where}.${key}`;
1855
+ if (!isPlainObject(raw)) {
1856
+ throw new Error(`${path} must be a plain object`);
1857
+ }
1858
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1859
+ const v = raw[field];
1860
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1861
+ throw new Error(
1862
+ `${path}.${field} is ${String(v)}, not a finite number`
1863
+ );
1864
+ }
1865
+ }
1866
+ const rawFace = raw.runFace;
1867
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1868
+ throw new Error(
1869
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1870
+ );
1871
+ }
1872
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1873
+ const cropOf = (role) => {
1874
+ const layer = layers.find((l) => l.role === role);
1875
+ if (layer === void 0) {
1876
+ throw new Error(
1877
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1878
+ );
1879
+ }
1880
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1881
+ return {
1882
+ role,
1883
+ x: t.x,
1884
+ left: t.x - layer.cropW / 2,
1885
+ right: t.x + layer.cropW / 2,
1886
+ bottom: t.y - layer.cropH / 2,
1887
+ top: t.y + layer.cropH / 2
1888
+ };
1889
+ };
1890
+ const hair = cropOf("hair_front");
1891
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1892
+ (a, b) => a.x - b.x
1893
+ );
1894
+ const sign = key === "left" ? -1 : 1;
1895
+ const iris = key === "left" ? lowIris : highIris;
1896
+ const other = key === "left" ? highIris : lowIris;
1897
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1898
+ for (const crop of [iris, hair]) {
1899
+ if (y < crop.bottom || y > crop.top) {
1900
+ throw fail(
1901
+ "y",
1902
+ y,
1903
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1904
+ );
1905
+ }
1906
+ }
1907
+ for (const [field, x] of [
1908
+ ["irisOuter", irisOuter],
1909
+ ["irisInner", irisInner]
1910
+ ]) {
1911
+ if (x < iris.left || x > iris.right) {
1912
+ throw fail(
1913
+ field,
1914
+ x,
1915
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1916
+ );
1917
+ }
1918
+ }
1919
+ if (sign * (irisOuter - irisInner) <= 0) {
1920
+ throw fail(
1921
+ "irisOuter",
1922
+ irisOuter,
1923
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1924
+ );
1925
+ }
1926
+ for (const [field, x] of [
1927
+ ["runOuter", runOuter],
1928
+ ["runFace", runFace]
1929
+ ]) {
1930
+ if (x !== null && (x < hair.left || x > hair.right)) {
1931
+ throw fail(
1932
+ field,
1933
+ x,
1934
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1935
+ );
1936
+ }
1937
+ }
1938
+ if (sign * (runOuter - iris.x) <= 0) {
1939
+ throw fail(
1940
+ "runOuter",
1941
+ runOuter,
1942
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1943
+ );
1944
+ }
1945
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1946
+ throw fail(
1947
+ "runOuter",
1948
+ runOuter,
1949
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1950
+ );
1951
+ }
1952
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1953
+ throw fail(
1954
+ "runFace",
1955
+ runFace,
1956
+ `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`
1957
+ );
1958
+ }
1795
1959
  }
1796
1960
  }
1797
1961
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1847,20 +2011,16 @@ function meshCellsFor(w, h) {
1847
2011
  );
1848
2012
  return { cols: cells(w), rows: cells(h) };
1849
2013
  }
1850
- var FACE_GRID_CELLS = 6;
2014
+ var FACE_PLATE_CELLS = 10;
2015
+ var FEATURE_GRID_CELLS = 4;
2016
+ var TURN_LATTICE_CELL_PX = 16;
2017
+ var GRID_MARGIN = 0.12;
1851
2018
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1852
2019
  var HEAD_TURN_MAX_DEG = 30;
1853
2020
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1854
2021
  function headTurnParallaxUnit(radius) {
1855
2022
  return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1856
2023
  }
1857
- function gridReach(grid, axis, center) {
1858
- let reach = 0;
1859
- for (let i = axis; i < grid.points.length; i += 2) {
1860
- reach = Math.max(reach, Math.abs(grid.points[i] - center));
1861
- }
1862
- return reach;
1863
- }
1864
2024
  function pinnedCylinderBend(local, radius, theta) {
1865
2025
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1866
2026
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
@@ -1873,27 +2033,107 @@ function boundedCylinderBend(local, radius, theta) {
1873
2033
  function turnSlide(travel, angleX) {
1874
2034
  return travel * angleX / HEAD_TURN_MAX_DEG;
1875
2035
  }
1876
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
2036
+ var FACE_ROW_MIN_FRACTION = 0.15;
2037
+ var FACE_ROW_SMOOTHING = 0.05;
2038
+ var CHIN_SWING = 0.08;
2039
+ function faceRowProfile(face, faceCenterY) {
2040
+ const raw = face.rowHalfWidths;
2041
+ if (raw === void 0) return void 0;
2042
+ const n = raw.length;
2043
+ let aMax = 0;
2044
+ let widest = -1;
2045
+ for (let i = 0; i < n; i++) {
2046
+ if (raw[i] > 0 && raw[i] >= aMax) {
2047
+ aMax = raw[i];
2048
+ widest = i;
2049
+ }
2050
+ }
2051
+ if (widest < 0) return void 0;
2052
+ const filled = [];
2053
+ for (let i = 0; i < n; i++) {
2054
+ filled.push(i < widest ? aMax : raw[i] > 0 ? raw[i] : filled[i - 1]);
2055
+ }
2056
+ const half = Math.max(1, Math.round(FACE_ROW_SMOOTHING * n / 2));
2057
+ const floor = FACE_ROW_MIN_FRACTION * aMax;
2058
+ const rows = [];
2059
+ for (let i = 0; i < n; i++) {
2060
+ if (i < widest) {
2061
+ rows.push(aMax);
2062
+ continue;
2063
+ }
2064
+ let sum = 0;
2065
+ for (let k = i - half; k <= i + half; k++) {
2066
+ sum += filled[Math.max(0, Math.min(n - 1, k))];
2067
+ }
2068
+ rows.push(Math.max(floor, sum / (2 * half + 1)));
2069
+ }
2070
+ const top = faceCenterY + face.cropH / 2;
2071
+ const rowAt = (y) => Math.max(0, Math.min(n - 1, top - y - 0.5));
2072
+ return {
2073
+ at: (y) => {
2074
+ const r = rowAt(y);
2075
+ const i = Math.floor(r);
2076
+ if (i + 1 >= n) return rows[n - 1];
2077
+ return rows[i] + (rows[i + 1] - rows[i]) * (r - i);
2078
+ },
2079
+ aMax,
2080
+ widestY: top - widest - 0.5,
2081
+ faceHalfWidth: face.cropW / 2
2082
+ };
2083
+ }
2084
+ function turnSurface(spec) {
2085
+ const { faceCenterX, faceCenterY, radius, travel, nodRadius, lattice } = spec;
2086
+ const { profile } = spec;
2087
+ let radiusAt = (_y) => radius;
2088
+ let swingAt = (_y) => 0;
2089
+ if (profile !== void 0) {
2090
+ const aEye = profile.at(spec.eyeRowY ?? faceCenterY);
2091
+ radiusAt = (y) => radius * (profile.at(y) / aEye);
2092
+ swingAt = (y) => y < profile.widestY ? -CHIN_SWING * profile.faceHalfWidth * Math.max(0, 1 - profile.at(y) / profile.aMax) : 0;
2093
+ }
2094
+ return {
2095
+ faceCenterX,
2096
+ faceCenterY,
2097
+ radius,
2098
+ travel,
2099
+ nodRadius,
2100
+ lattice,
2101
+ radiusAt,
2102
+ swingAt,
2103
+ mapAt: (deg, y) => turnColumnMap(
2104
+ lattice,
2105
+ faceCenterX,
2106
+ radiusAt(y),
2107
+ deg,
2108
+ travel + swingAt(y)
2109
+ ),
2110
+ nodBendAt: (y, angleY) => pinnedCylinderBend(
2111
+ y - faceCenterY,
2112
+ nodRadius,
2113
+ angleY * NOD_BEND * (Math.PI / 180)
2114
+ )
2115
+ };
2116
+ }
2117
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1877
2118
  const STOPS = [...HEAD_TURN_STOPS];
1878
- const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1879
2119
  const pointCount = grid.points.length / 2;
1880
- const DEG_TO_RAD = Math.PI / 180;
1881
2120
  const keyforms2d = [];
1882
2121
  for (const angleY of STOPS) {
1883
- const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1884
2122
  for (const angleX of STOPS) {
1885
- const thetaX = angleX * DEG_TO_RAD;
1886
- const slide = turnSlide(travel, angleX);
2123
+ const shift = shiftAt(angleX);
2124
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
1887
2125
  const offsets = [];
1888
2126
  for (let i = 0; i < pointCount; i++) {
1889
- offsets.push(
1890
- boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
1891
- pinnedCylinderBend(
1892
- grid.points[i * 2 + 1] - centerY,
1893
- RADIUS_Y,
1894
- thetaY
1895
- )
1896
- );
2127
+ const x = grid.points[i * 2];
2128
+ const y = grid.points[i * 2 + 1];
2129
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2130
+ if (angleX === 0) {
2131
+ offsets.push(0, nodDy);
2132
+ continue;
2133
+ }
2134
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2135
+ const turnDy = landed.y - y;
2136
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
1897
2137
  }
1898
2138
  keyforms2d.push({ offsets });
1899
2139
  }
@@ -1924,30 +2164,163 @@ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1924
2164
  );
1925
2165
  }
1926
2166
  const cellFor = (v, edges) => {
1927
- for (let c = 0; c < grid.cols; c++) {
1928
- if (v < edges[c + 1]) return c;
2167
+ let lo = 0;
2168
+ let hi = grid.cols - 1;
2169
+ while (lo < hi) {
2170
+ const mid = lo + hi >> 1;
2171
+ if (v < edges[mid + 1]) hi = mid;
2172
+ else lo = mid + 1;
1929
2173
  }
1930
- return grid.cols - 1;
2174
+ return lo;
2175
+ };
2176
+ const sample = (edges, values, v) => {
2177
+ const c = cellFor(v, edges);
2178
+ const s = Math.max(
2179
+ 0,
2180
+ Math.min(1, (v - edges[c]) / (edges[c + 1] - edges[c]))
2181
+ );
2182
+ if (s === 0) return values[c];
2183
+ if (s === 1) return values[c + 1];
2184
+ return values[c] + (values[c + 1] - values[c]) * s;
1931
2185
  };
1932
2186
  return {
1933
2187
  restX,
1934
2188
  warpedX,
1935
- mapX(x) {
1936
- const c = cellFor(x, restX);
1937
- const s = Math.max(
1938
- 0,
1939
- Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
1940
- );
1941
- return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
1942
- },
1943
- invertX(X) {
1944
- const c = cellFor(X, warpedX);
1945
- const s = Math.max(
1946
- 0,
1947
- Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
1948
- );
1949
- return restX[c] + (restX[c + 1] - restX[c]) * s;
2189
+ mapX: (x) => sample(restX, warpedX, x),
2190
+ invertX: (X) => sample(warpedX, restX, X)
2191
+ };
2192
+ }
2193
+ function renderedLandingX(part, vertexLanding, px, py) {
2194
+ const { cols, rows } = part;
2195
+ const u = (px - part.x + part.cropW / 2) / part.cropW * cols;
2196
+ const v = (part.y + part.cropH / 2 - py) / part.cropH * rows;
2197
+ const col = Math.max(0, Math.min(cols - 1, Math.floor(u)));
2198
+ const row = Math.max(0, Math.min(rows - 1, Math.floor(v)));
2199
+ const fx = Math.max(0, Math.min(1, u - col));
2200
+ const fy = Math.max(0, Math.min(1, v - row));
2201
+ const cos = Math.cos(part.rotation * (Math.PI / 180));
2202
+ const sin = Math.sin(part.rotation * (Math.PI / 180));
2203
+ const landingAt = (c, r) => {
2204
+ const lx = (-part.cropW / 2 + c / cols * part.cropW) * part.scaleX;
2205
+ const ly = (part.cropH / 2 - r / rows * part.cropH) * part.scaleY;
2206
+ return vertexLanding(
2207
+ part.x + cos * lx - sin * ly,
2208
+ part.y + sin * lx + cos * ly
2209
+ );
2210
+ };
2211
+ const tl = landingAt(col, row);
2212
+ const br = landingAt(col + 1, row + 1);
2213
+ if (fx <= fy) {
2214
+ const bl = landingAt(col, row + 1);
2215
+ return tl + fy * (bl - tl) + fx * (br - bl);
2216
+ }
2217
+ const tr = landingAt(col + 1, row);
2218
+ return tl + fx * (tr - tl) + fy * (br - tr);
2219
+ }
2220
+ function gridChildLandingX(grid, nodeLanding, x, y) {
2221
+ const { cols, rows, points } = grid;
2222
+ const stride = cols + 1;
2223
+ let col = cols - 1;
2224
+ for (let c = 0; c < cols; c++) {
2225
+ if (x < points[(c + 1) * 2]) {
2226
+ col = c;
2227
+ break;
2228
+ }
2229
+ }
2230
+ let row = rows - 1;
2231
+ for (let r = 0; r < rows; r++) {
2232
+ if (y > points[(r + 1) * stride * 2 + 1]) {
2233
+ row = r;
2234
+ break;
1950
2235
  }
2236
+ }
2237
+ const xLeft = points[col * 2];
2238
+ const xRight = points[(col + 1) * 2];
2239
+ const yTop = points[row * stride * 2 + 1];
2240
+ const yBottom = points[(row + 1) * stride * 2 + 1];
2241
+ const s = Math.max(0, Math.min(1, (x - xLeft) / (xRight - xLeft)));
2242
+ const t = Math.max(0, Math.min(1, (yTop - y) / (yTop - yBottom)));
2243
+ const at = (c, r) => nodeLanding(points[(r * stride + c) * 2], points[(r * stride + c) * 2 + 1]);
2244
+ const top = at(col, row) + (at(col + 1, row) - at(col, row)) * s;
2245
+ const bot = at(col, row + 1) + (at(col + 1, row + 1) - at(col, row + 1)) * s;
2246
+ return top + (bot - top) * t;
2247
+ }
2248
+ function carrierLandingX(carrier, nodeLanding, px, py) {
2249
+ return renderedLandingX(
2250
+ carrier.part,
2251
+ (vx, vy) => gridChildLandingX(carrier.grid, nodeLanding, vx, vy),
2252
+ px,
2253
+ py
2254
+ );
2255
+ }
2256
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2257
+ if (anchor === void 0) {
2258
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2259
+ }
2260
+ const anchorRow = mapAt(anchor.y);
2261
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2262
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2263
+ const dy = nodeY - anchor.y;
2264
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2265
+ const cos = Math.cos(phi);
2266
+ const sin = Math.sin(phi);
2267
+ return {
2268
+ x: pivotX + cos * dx - sin * dy,
2269
+ y: anchor.y + sin * dx + cos * dy
2270
+ };
2271
+ }
2272
+ function rowMapsOf(surface) {
2273
+ const maps = /* @__PURE__ */ new Map();
2274
+ return (deg, nodeY) => {
2275
+ let byRow = maps.get(deg);
2276
+ if (byRow === void 0) {
2277
+ byRow = /* @__PURE__ */ new Map();
2278
+ maps.set(deg, byRow);
2279
+ }
2280
+ let map = byRow.get(nodeY);
2281
+ if (map === void 0) {
2282
+ map = surface.mapAt(deg, nodeY);
2283
+ byRow.set(nodeY, map);
2284
+ }
2285
+ return map;
2286
+ };
2287
+ }
2288
+ function plateLandingOn(plate, mapAt) {
2289
+ return (deg, x, y) => carrierLandingX(
2290
+ plate,
2291
+ (nodeX, nodeY) => mapAt(deg, nodeY).mapX(nodeX),
2292
+ x,
2293
+ y
2294
+ );
2295
+ }
2296
+ function plateGuardRowsFor(face, hairFront) {
2297
+ const rowsOf = (part, y) => {
2298
+ const { rows } = meshCellsFor(part.cropW, part.cropH);
2299
+ return Array.from(
2300
+ { length: rows + 1 },
2301
+ (_, r) => y + (part.cropH / 2 - r / rows * part.cropH)
2302
+ );
2303
+ };
2304
+ const faceRows = rowsOf(face, face.y);
2305
+ if (hairFront === void 0) return faceRows;
2306
+ const top = face.y + face.cropH / 2;
2307
+ const bottom = face.y - face.cropH / 2;
2308
+ return [
2309
+ ...faceRows,
2310
+ ...rowsOf(hairFront, hairFront.centerY).map(
2311
+ (y) => Math.max(bottom, Math.min(top, y))
2312
+ )
2313
+ ];
2314
+ }
2315
+ function plateGuardsOf(carriers, faceHalfWidth, profile, hairFront) {
2316
+ const plate = carriers.get("face");
2317
+ if (plate === void 0) {
2318
+ throw new Error("auto-rig: plateGuardsOf: the carriers have no face");
2319
+ }
2320
+ return {
2321
+ plate,
2322
+ edgeAt: profile === void 0 ? () => faceHalfWidth : profile.at,
2323
+ plateGuardRows: plateGuardRowsFor(plate.part, hairFront)
1951
2324
  };
1952
2325
  }
1953
2326
  var TurnTargetError = class extends Error {
@@ -2018,12 +2391,15 @@ function resolveTurnTargets(targets = {}) {
2018
2391
  defaulted
2019
2392
  };
2020
2393
  }
2021
- function turnLandmarks(layers) {
2394
+ function turnLandmarks(layers, carriers) {
2022
2395
  const markOf = (role) => {
2023
2396
  const layer = layers.find((l) => l.role === role);
2024
2397
  if (!layer) return void 0;
2025
2398
  const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2026
- return { x: t.x, y: t.y, w: layer.cropW };
2399
+ const mark = { x: t.x, y: t.y, w: layer.cropW };
2400
+ const carrier = carriers?.get(role);
2401
+ if (carrier !== void 0) mark.carrier = carrier;
2402
+ return mark;
2027
2403
  };
2028
2404
  const pairOf = (left, right) => {
2029
2405
  const l = markOf(left);
@@ -2040,23 +2416,67 @@ function turnLandmarks(layers) {
2040
2416
  mouth: one("mouth")
2041
2417
  };
2042
2418
  }
2419
+ function eyeRowOf(landmarks, fallback) {
2420
+ const [l, r] = landmarks.eye;
2421
+ return landmarks.eye.length === 2 && l.y !== void 0 && r.y !== void 0 ? (l.y + r.y) / 2 : fallback;
2422
+ }
2043
2423
  var TURN_BISECT_STEPS = 40;
2044
2424
  var TURN_DEPTH_EPS = 1e-6;
2045
- function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
2425
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2426
+ const { carrier } = landmark;
2427
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2428
+ return carrierLandingX(
2429
+ carrier,
2430
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2431
+ px,
2432
+ py
2433
+ );
2434
+ }
2435
+ function familyReachPx(landmarks, plateEdgeX) {
2436
+ const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2437
+ return Math.max(0, farEdge - plateEdgeX);
2438
+ }
2439
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2440
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2441
+ }
2442
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
2046
2443
  if (landmarks.length === 0) {
2047
2444
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2048
2445
  }
2049
- const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2050
- const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
2051
- const cap = Math.max(0, (farEdge - floor) / unit);
2052
- const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
2446
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2447
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2448
+ const achieved = (d) => landmarks.reduce(
2449
+ (sum, l) => sum + (landmarkLandingX(
2450
+ l,
2451
+ mapAt,
2452
+ -HEAD_TURN_MAX_DEG,
2453
+ -d * unit,
2454
+ l.x,
2455
+ l.y ?? 0
2456
+ ) - l.x),
2457
+ 0
2458
+ ) / landmarks.length;
2053
2459
  const atCap = achieved(cap);
2054
2460
  const atRest = achieved(0);
2055
2461
  const attainable = [atCap, atRest];
2056
- if (target > atRest + TURN_DEPTH_EPS)
2057
- return { reached: false, depth: 0, achieved: atRest, attainable };
2058
- if (target < atCap - TURN_DEPTH_EPS)
2059
- return { reached: false, depth: cap, achieved: atCap, attainable };
2462
+ if (target > atRest + TURN_DEPTH_EPS) {
2463
+ return {
2464
+ reached: false,
2465
+ depth: 0,
2466
+ achieved: atRest,
2467
+ attainable,
2468
+ limit: "floor"
2469
+ };
2470
+ }
2471
+ if (target < atCap - TURN_DEPTH_EPS) {
2472
+ return {
2473
+ reached: false,
2474
+ depth: cap,
2475
+ achieved: atCap,
2476
+ attainable,
2477
+ limit: "cap"
2478
+ };
2479
+ }
2060
2480
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2061
2481
  let lo = 0;
2062
2482
  let hi = cap;
@@ -2072,68 +2492,52 @@ var TURN_SWEEP_MAX_FACTOR = 8;
2072
2492
  var TURN_SWEEP_SAMPLES = 32;
2073
2493
  var HOLD_CLEARANCE = 1;
2074
2494
  var TURN_HOLD_SAMPLES = 16;
2075
- var TURN_HOLD_SATURATION_PASSES = 4;
2076
- function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2495
+ var TURN_SETTLE_PASSES = 24;
2496
+ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
2077
2497
  const local = x - faceCenterX;
2078
2498
  const dist = Math.abs(local);
2079
2499
  const side = Math.sign(local);
2080
- const sideIdx = side < 0 ? 0 : 1;
2081
- if (dist <= faceHalfWidth) return map.mapX(x);
2500
+ const painted = edgeAt(y);
2501
+ if (dist <= painted) return plateLandingAt(deg, x, y);
2082
2502
  if (dist <= holdBase) {
2083
- const inner = map.mapX(faceCenterX + side * faceHalfWidth);
2084
- const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
2085
- return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
2503
+ const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
2504
+ const u = (dist - painted) / (holdBase - painted);
2505
+ return inner + (faceCenterX + side * holdEdge - inner) * u;
2086
2506
  }
2087
- return x + side * (holdEdge[sideIdx] - holdBase);
2507
+ return x + side * (holdEdge - holdBase);
2088
2508
  }
2089
- function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2509
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2090
2510
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2091
- const preBindAt = (col2) => {
2092
- const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
2093
- const target = hairFrontHoldTarget(
2094
- colX,
2095
- faceCenterX,
2096
- faceHalfWidth,
2097
- holdBase,
2098
- cappedHoldEdge,
2099
- map
2100
- );
2101
- return map.invertX(target);
2102
- };
2103
- const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
2104
- const bracket = (v, cells) => {
2105
- const lo = Math.max(0, Math.min(cells, Math.floor(v)));
2106
- return {
2107
- lo,
2108
- hi: Math.max(0, Math.min(cells, lo + 1)),
2109
- t: Math.max(0, Math.min(1, v - lo))
2110
- };
2111
- };
2112
- const col = bracket(
2113
- (x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
2114
- cols
2511
+ const top = hairFront.centerY + hairFront.cropH / 2;
2512
+ return renderedLandingX(
2513
+ {
2514
+ x: hairFront.x,
2515
+ y: hairFront.centerY,
2516
+ cropW: hairFront.cropW,
2517
+ cropH: hairFront.cropH,
2518
+ cols,
2519
+ rows,
2520
+ scaleX: 1,
2521
+ scaleY: 1,
2522
+ rotation: 0
2523
+ },
2524
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2525
+ x,
2526
+ y
2115
2527
  );
2116
- const row = bracket(leadRow * rows, rows);
2117
- const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
2118
- const tl = landingAt(col.lo, row.lo);
2119
- const br = landingAt(col.hi, row.hi);
2120
- if (col.t <= row.t) {
2121
- const bl = landingAt(col.lo, row.hi);
2122
- return tl + row.t * (bl - tl) + col.t * (br - bl);
2123
- }
2124
- const tr = landingAt(col.hi, row.lo);
2125
- return tl + col.t * (tr - tl) + row.t * (br - tr);
2126
- }
2127
- function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
2528
+ }
2529
+ function shellTravelCap(faceCenterX, edgeAt, plateGuardRows, askedTravel, bendOnlyLandingAt, holdEdgeAt) {
2128
2530
  let cap = Infinity;
2129
2531
  for (const deg of HEAD_TURN_STOPS) {
2130
2532
  if (deg === 0) continue;
2131
- const map = bendOnlyMapAt(deg);
2132
2533
  const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
2133
- const towardReach = Math.max(
2134
- ...[-1, 1].map(
2135
- (side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
2136
- )
2534
+ const towardReach = plateSideReachAt(
2535
+ bendOnlyLandingAt,
2536
+ faceCenterX,
2537
+ edgeAt,
2538
+ plateGuardRows,
2539
+ deg,
2540
+ slideSign
2137
2541
  );
2138
2542
  cap = Math.min(
2139
2543
  cap,
@@ -2142,8 +2546,39 @@ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt,
2142
2546
  }
2143
2547
  return cap;
2144
2548
  }
2549
+ function aitken(x0, x1, x2, cap) {
2550
+ const d1 = x1 - x0;
2551
+ const d2 = x2 - x1;
2552
+ const denominator = d2 - d1;
2553
+ if (denominator === 0) return x2;
2554
+ const x = x2 - d2 * d2 / denominator;
2555
+ return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2556
+ }
2557
+ function strandDepthCap(margins, plateCap) {
2558
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2559
+ if (holds(plateCap)) return plateCap;
2560
+ if (!holds(0)) return void 0;
2561
+ let lo = 0;
2562
+ let hi = plateCap;
2563
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2564
+ const mid = (lo + hi) / 2;
2565
+ if (holds(mid)) lo = mid;
2566
+ else hi = mid;
2567
+ }
2568
+ return lo;
2569
+ }
2145
2570
  function evaluateTurnCandidate(ctx, radius) {
2146
- const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
2571
+ const surfaceAt = (travel) => turnSurface({
2572
+ faceCenterX: ctx.faceCenterX,
2573
+ faceCenterY: ctx.faceCenterY,
2574
+ radius,
2575
+ travel,
2576
+ nodRadius: ctx.nodRadius,
2577
+ lattice: ctx.lattice,
2578
+ profile: ctx.profile,
2579
+ eyeRowY: ctx.eyeRowY
2580
+ });
2581
+ const bendOnlyLandingAt = plateLandingOn(ctx.plate, rowMapsOf(surfaceAt(0)));
2147
2582
  const measuredEdgeAt = (side) => {
2148
2583
  const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2149
2584
  if (candidates === void 0 || candidates.length === 0) return void 0;
@@ -2155,66 +2590,160 @@ function evaluateTurnCandidate(ctx, radius) {
2155
2590
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2156
2591
  };
2157
2592
  const unit = headTurnParallaxUnit(radius);
2158
- const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2159
- const leadRow = ctx.hairFrontLeadFraction ?? 0;
2160
- const evaluateHold = (ratio) => {
2593
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2594
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2595
+ const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2596
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2597
+ x,
2598
+ y,
2599
+ ctx.hairFrontSilhouette,
2600
+ (vx, vy) => hairFrontHoldTarget(
2601
+ vx,
2602
+ vy,
2603
+ deg,
2604
+ ctx.faceCenterX,
2605
+ plateLandingAt,
2606
+ ctx.edgeAt,
2607
+ hold.holdBase,
2608
+ hold.holdEdgeAt(deg)
2609
+ ),
2610
+ leadShift
2611
+ );
2612
+ const strandStopsOn = (hold) => {
2613
+ const rowMaps = rowMapsOf(hold.surface);
2614
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2615
+ return (ctx.strands ?? []).map((strand) => {
2616
+ const iris = {
2617
+ x: strand.carrier.part.x,
2618
+ w: strand.carrier.part.cropW,
2619
+ carrier: strand.carrier
2620
+ };
2621
+ const stops = HEAD_TURN_STOPS.filter(
2622
+ (deg) => Math.sign(deg) === strand.side
2623
+ ).map((deg) => {
2624
+ const runAt = (x) => bangsLandingAt(
2625
+ hold,
2626
+ plateLandingAt,
2627
+ deg,
2628
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2629
+ x,
2630
+ strand.y
2631
+ );
2632
+ return {
2633
+ deg,
2634
+ runOuter: runAt(strand.runOuter),
2635
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2636
+ irisAt: (depth, x) => landmarkLandingX(
2637
+ iris,
2638
+ (y) => rowMaps(deg, y),
2639
+ deg,
2640
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2641
+ x,
2642
+ strand.y
2643
+ )
2644
+ };
2645
+ });
2646
+ const { runFace } = strand;
2647
+ if (runFace === null) return { strand, stops, margins: void 0 };
2648
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2649
+ const margins = stops.map(
2650
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2651
+ );
2652
+ return { strand, stops, margins };
2653
+ });
2654
+ };
2655
+ const strandOverlapOf = (sides, depth) => {
2656
+ const overlap = {};
2657
+ for (const { strand, stops, margins } of sides) {
2658
+ const s = strand.side;
2659
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2660
+ 0,
2661
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2662
+ );
2663
+ let worst;
2664
+ for (const stop of stops) {
2665
+ const px = covered(
2666
+ stop.irisAt(depth, strand.irisOuter),
2667
+ stop.irisAt(depth, strand.irisInner),
2668
+ stop.runOuter,
2669
+ stop.runFace
2670
+ );
2671
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2672
+ worst = { deg: stop.deg, px };
2673
+ }
2674
+ }
2675
+ if (worst === void 0) continue;
2676
+ overlap[s < 0 ? "left" : "right"] = {
2677
+ deg: worst.deg,
2678
+ px: worst.px,
2679
+ hh: worst.px / ctx.hh,
2680
+ restPx: covered(
2681
+ strand.irisOuter,
2682
+ strand.irisInner,
2683
+ strand.runOuter,
2684
+ strand.runFace
2685
+ ),
2686
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2687
+ };
2688
+ }
2689
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2690
+ };
2691
+ const evaluateHold = (ratio, depths2) => {
2161
2692
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2162
- const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2693
+ const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2163
2694
  ctx.travel,
2164
2695
  shellTravelCap(
2165
2696
  ctx.faceCenterX,
2166
- ctx.faceHalfWidth,
2697
+ ctx.edgeAt,
2698
+ ctx.plateGuardRows,
2167
2699
  ctx.travel,
2168
- bendOnlyMapAt,
2700
+ bendOnlyLandingAt,
2169
2701
  (deg, side) => {
2170
2702
  const rest = shellHalfWidthAt(side);
2171
2703
  return Math.min(rest, holdEdgeFrom(rest)(deg));
2172
2704
  }
2173
2705
  )
2174
2706
  );
2175
- const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
2176
- const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
2177
- const holdEdgeAt2 = holdEdgeFrom(holdBase2);
2707
+ const surface = surfaceAt(travel);
2708
+ const rowMaps = rowMapsOf(surface);
2709
+ const mapAt = (y) => rowMaps(-HEAD_TURN_MAX_DEG, y);
2710
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2711
+ const holdBase = ctx.headHalfWidth ?? plateReach(
2712
+ plateLandingAt,
2713
+ ctx.faceCenterX,
2714
+ ctx.edgeAt,
2715
+ ctx.plateGuardRows
2716
+ ) + HOLD_CLEARANCE;
2717
+ const holdEdgeAt = holdEdgeFrom(holdBase);
2178
2718
  let foldLowerBound = -Infinity;
2179
2719
  let folds = false;
2180
2720
  for (const deg of HEAD_TURN_STOPS) {
2181
2721
  if (deg === 0) continue;
2182
2722
  const reach = plateReachAt(
2183
- columnMapAt(deg),
2723
+ plateLandingAt,
2184
2724
  ctx.faceCenterX,
2185
- ctx.faceHalfWidth
2725
+ ctx.edgeAt,
2726
+ ctx.plateGuardRows,
2727
+ deg
2186
2728
  );
2187
2729
  foldLowerBound = Math.max(
2188
2730
  foldLowerBound,
2189
- 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2731
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2190
2732
  );
2191
- if (holdEdgeAt2(deg) <= reach) folds = true;
2733
+ if (holdEdgeAt(deg) <= reach) folds = true;
2192
2734
  }
2193
- const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
2194
- const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
2195
- const gridReach2 = [
2196
- Math.abs(map2.warpedX[0] - ctx.faceCenterX),
2197
- Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
2198
- ];
2199
- const cappedHoldEdge = [
2200
- Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
2201
- Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
2202
- ];
2203
2735
  const restAt = (side) => {
2204
2736
  const edge = measuredEdgeAt(side);
2205
2737
  if (edge !== void 0) return edge;
2206
- return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2738
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2207
2739
  };
2208
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2740
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2741
+ { holdBase, holdEdgeAt },
2742
+ plateLandingAt,
2743
+ -HEAD_TURN_MAX_DEG,
2744
+ -leadTipShift,
2209
2745
  x,
2210
- ctx.hairFrontSilhouette,
2211
- ctx.faceCenterX,
2212
- ctx.faceHalfWidth,
2213
- holdBase2,
2214
- cappedHoldEdge,
2215
- map2,
2216
- leadTipShift,
2217
- leadRow
2746
+ ctx.eyeRowY
2218
2747
  );
2219
2748
  const landingOfRole = (role, x) => {
2220
2749
  if (role === "hair_front") return hairFrontAt(x);
@@ -2222,10 +2751,29 @@ function evaluateTurnCandidate(ctx, radius) {
2222
2751
  return x;
2223
2752
  }
2224
2753
  if (role === "body") {
2225
- return x - BODY_TURN_FOLLOW * travel2;
2754
+ return x - BODY_TURN_FOLLOW * travel;
2226
2755
  }
2227
2756
  if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2228
- return map2.mapX(x);
2757
+ const carrier = ctx.carriers.get(role);
2758
+ if (carrier === void 0) {
2759
+ throw new Error(
2760
+ `auto-rig: evaluateTurnCandidate: headEdges names "${role}", which this layer set has no layer for`
2761
+ );
2762
+ }
2763
+ const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2764
+ return carrierLandingX(
2765
+ carrier,
2766
+ (nodeX, nodeY) => groupNodeLanding(
2767
+ carrier.anchor,
2768
+ mapAt,
2769
+ -HEAD_TURN_MAX_DEG,
2770
+ shift,
2771
+ nodeX,
2772
+ nodeY
2773
+ ).x,
2774
+ x,
2775
+ ctx.eyeRowY
2776
+ );
2229
2777
  }
2230
2778
  throw new Error(
2231
2779
  `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
@@ -2241,13 +2789,13 @@ function evaluateTurnCandidate(ctx, radius) {
2241
2789
  };
2242
2790
  return {
2243
2791
  ratio,
2244
- travel: travel2,
2245
- map: map2,
2246
- holdBase: holdBase2,
2247
- holdEdgeAt: holdEdgeAt2,
2792
+ travel,
2793
+ mapAt,
2794
+ surface,
2795
+ holdBase,
2796
+ holdEdgeAt,
2248
2797
  folds,
2249
2798
  foldLowerBound,
2250
- gridReach: gridReach2,
2251
2799
  // How far the silhouette's own centre drifts off the face centre at full
2252
2800
  // turn, against how far it already sat off centre at rest (zero unless
2253
2801
  // hair_front's crop is itself off-centre) — the held shell keeps its own
@@ -2264,24 +2812,18 @@ function evaluateTurnCandidate(ctx, radius) {
2264
2812
  restSpan: restAt(1) - restAt(-1)
2265
2813
  };
2266
2814
  };
2267
- const saturatedHold = () => {
2268
- let hold2 = evaluateHold(1);
2269
- for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
2270
- const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
2271
- if (needed <= hold2.ratio) break;
2272
- hold2 = evaluateHold(needed);
2273
- }
2274
- return hold2;
2275
- };
2276
- const holdRange = () => {
2277
- const ceiling = saturatedHold();
2815
+ const holdRange = (depths2) => {
2816
+ const ceiling = evaluateHold(TURN_RATIO_MAX, depths2);
2278
2817
  if (ceiling.folds) return void 0;
2279
- let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2818
+ let floor = evaluateHold(
2819
+ Math.min(ceiling.foldLowerBound, ceiling.ratio),
2820
+ depths2
2821
+ );
2280
2822
  if (floor.folds) {
2281
2823
  let lo = floor;
2282
2824
  let hi = ceiling;
2283
2825
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2284
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2826
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2285
2827
  if (mid.folds) lo = mid;
2286
2828
  else hi = mid;
2287
2829
  }
@@ -2290,7 +2832,8 @@ function evaluateTurnCandidate(ctx, radius) {
2290
2832
  const samples = [floor];
2291
2833
  for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2292
2834
  const sample = evaluateHold(
2293
- floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2835
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES,
2836
+ depths2
2294
2837
  );
2295
2838
  if (!sample.folds) samples.push(sample);
2296
2839
  }
@@ -2303,15 +2846,14 @@ function evaluateTurnCandidate(ctx, radius) {
2303
2846
  return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2304
2847
  };
2305
2848
  const asked = ctx.silhouetteRatio;
2306
- const natural = evaluateHold(asked);
2307
- const eps = TURN_DEPTH_EPS / natural.restSpan;
2308
- let hold;
2309
- let silhouetteClamped = false;
2310
- if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2311
- hold = natural;
2312
- } else {
2313
- const samples = holdRange();
2314
- if (samples === void 0) return silhouetteMiss();
2849
+ const fitHold = (depths2) => {
2850
+ const natural = evaluateHold(asked, depths2);
2851
+ const eps = TURN_DEPTH_EPS / natural.restSpan;
2852
+ if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2853
+ return { hold: natural, silhouetteClamped: false };
2854
+ }
2855
+ const samples = holdRange(depths2);
2856
+ if (samples === void 0) return void 0;
2315
2857
  const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2316
2858
  const span = [
2317
2859
  Math.min(...rendered),
@@ -2326,91 +2868,169 @@ function evaluateTurnCandidate(ctx, radius) {
2326
2868
  return ea <= eb ? a : b;
2327
2869
  };
2328
2870
  if (asked < span[0] - eps || asked > span[1] + eps) {
2329
- if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2330
2871
  const wanted = asked < span[0] ? span[0] : span[1];
2331
- hold = samples.reduce(
2872
+ const hold = samples.reduce(
2332
2873
  (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2333
2874
  );
2334
- silhouetteClamped = true;
2335
- } else {
2336
- let best = samples.reduce(nearer);
2337
- let lo;
2338
- let hi;
2339
- for (let i = 0; i + 1 < samples.length; i++) {
2340
- const a = samples[i].renderedSilhouetteRatio - asked;
2341
- const b = samples[i + 1].renderedSilhouetteRatio - asked;
2342
- if (a * b <= 0) {
2343
- lo = samples[i];
2344
- hi = samples[i + 1];
2345
- break;
2346
- }
2875
+ return { hold, silhouetteClamped: true, span };
2876
+ }
2877
+ let best = samples.reduce(nearer);
2878
+ let lo;
2879
+ let hi;
2880
+ for (let i = 0; i + 1 < samples.length; i++) {
2881
+ const a = samples[i].renderedSilhouetteRatio - asked;
2882
+ const b = samples[i + 1].renderedSilhouetteRatio - asked;
2883
+ if (a * b <= 0) {
2884
+ lo = samples[i];
2885
+ hi = samples[i + 1];
2886
+ break;
2347
2887
  }
2348
- for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2349
- if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2350
- break;
2351
- }
2352
- const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2353
- if (!mid.folds) best = nearer(best, mid);
2354
- if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2355
- lo = mid;
2356
- } else {
2357
- hi = mid;
2358
- }
2888
+ }
2889
+ for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2890
+ if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2891
+ break;
2892
+ }
2893
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2, depths2);
2894
+ if (!mid.folds) best = nearer(best, mid);
2895
+ if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2896
+ lo = mid;
2897
+ } else {
2898
+ hi = mid;
2359
2899
  }
2360
- hold = best;
2361
2900
  }
2362
- }
2363
- if (hold.folds) {
2364
- throw new Error(
2365
- "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2901
+ return { hold: best, silhouetteClamped: false };
2902
+ };
2903
+ let depths = { eye: 0, nose: 0, mouth: 0 };
2904
+ let seed;
2905
+ for (let pass = 0; ; pass++) {
2906
+ const fitted = fitHold(depths);
2907
+ if (fitted === void 0) return silhouetteMiss();
2908
+ const { hold, silhouetteClamped, span } = fitted;
2909
+ if (hold.folds) {
2910
+ throw new Error(
2911
+ "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2912
+ );
2913
+ }
2914
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2915
+ let strandFeasible = true;
2916
+ let maxDepth;
2917
+ if (strandSides !== void 0) {
2918
+ const margins = strandSides.map((s) => s.margins);
2919
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2920
+ margins.flatMap((m) => m),
2921
+ eyePlateCap
2922
+ ) : void 0;
2923
+ strandFeasible = cap !== void 0;
2924
+ maxDepth = cap ?? 0;
2925
+ }
2926
+ const eye = solveTurnDepthSigned(
2927
+ hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2928
+ ctx.landmarks.eye,
2929
+ unit,
2930
+ hold.mapAt,
2931
+ ctx.plateEdgeX,
2932
+ maxDepth
2366
2933
  );
2367
- }
2368
- const {
2369
- travel,
2370
- map,
2371
- holdBase,
2372
- holdEdgeAt,
2373
- silhouetteCenterShift,
2374
- renderedSilhouetteRatio
2375
- } = hold;
2376
- const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
2377
- const eye = solveTurnDepthSigned(
2378
- silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2379
- ctx.landmarks.eye,
2380
- unit,
2381
- map,
2382
- holdEdgeX,
2383
- ctx.plateEdgeX
2384
- );
2385
- if (!eye.reached && !ctx.clampEyeShift) {
2934
+ const features = solveFeatureDepths(
2935
+ ctx,
2936
+ (-eye.achieved + hold.silhouetteCenterShift) / ctx.hh,
2937
+ unit,
2938
+ hold.mapAt,
2939
+ hold.silhouetteCenterShift
2940
+ );
2941
+ const solved = {
2942
+ eye: eye.depth,
2943
+ nose: features.nose.depth,
2944
+ mouth: features.mouth.depth
2945
+ };
2946
+ const settled = evaluateHold(hold.ratio, solved);
2947
+ if (Math.abs(settled.silhouetteCenterShift - hold.silhouetteCenterShift) > TURN_DEPTH_EPS || Math.abs(settled.renderedSilhouetteRatio - hold.renderedSilhouetteRatio) * hold.restSpan > TURN_DEPTH_EPS) {
2948
+ if (pass + 1 >= TURN_SETTLE_PASSES) return { blocked: "settle" };
2949
+ if (seed === void 0) {
2950
+ seed = depths;
2951
+ depths = solved;
2952
+ } else {
2953
+ depths = {
2954
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2955
+ nose: aitken(
2956
+ seed.nose,
2957
+ depths.nose,
2958
+ solved.nose,
2959
+ plateCapOf(ctx.landmarks.nose)
2960
+ ),
2961
+ mouth: aitken(
2962
+ seed.mouth,
2963
+ depths.mouth,
2964
+ solved.mouth,
2965
+ plateCapOf(ctx.landmarks.mouth)
2966
+ )
2967
+ };
2968
+ seed = void 0;
2969
+ }
2970
+ continue;
2971
+ }
2972
+ const {
2973
+ travel,
2974
+ mapAt,
2975
+ surface,
2976
+ holdBase,
2977
+ holdEdgeAt,
2978
+ silhouetteCenterShift,
2979
+ renderedSilhouetteRatio
2980
+ } = settled;
2981
+ if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2982
+ return silhouetteMiss(span);
2983
+ }
2984
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2985
+ return {
2986
+ blocked: "eyeShift",
2987
+ // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
2988
+ // a negative lower bound here would advertise a value the field's own
2989
+ // contract already rules out; 0 is the true floor.
2990
+ offeredShift: [
2991
+ Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2992
+ (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2993
+ ]
2994
+ };
2995
+ }
2996
+ const eyeShiftPx = -eye.depth * unit;
2997
+ const scaleOf = (l) => (landmarkLandingX(
2998
+ l,
2999
+ mapAt,
3000
+ -HEAD_TURN_MAX_DEG,
3001
+ eyeShiftPx,
3002
+ l.x + l.w / 2,
3003
+ l.y ?? 0
3004
+ ) - landmarkLandingX(
3005
+ l,
3006
+ mapAt,
3007
+ -HEAD_TURN_MAX_DEG,
3008
+ eyeShiftPx,
3009
+ l.x - l.w / 2,
3010
+ l.y ?? 0
3011
+ )) / l.w;
3012
+ const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
3013
+ const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
3014
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2386
3015
  return {
2387
- blocked: "eyeShift",
2388
- // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
2389
- // negative lower bound here would advertise a value the field's own
2390
- // contract already rules out; 0 is the true floor.
2391
- offeredShift: [
2392
- Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2393
- (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2394
- ]
3016
+ radius,
3017
+ ratio: scaleOf(far) / scaleOf(near),
3018
+ unit,
3019
+ travel,
3020
+ mapAt,
3021
+ surface,
3022
+ holdBase,
3023
+ holdEdgeAt,
3024
+ eye,
3025
+ nose: features.nose,
3026
+ mouth: features.mouth,
3027
+ silhouetteClamped,
3028
+ silhouetteCenterShift,
3029
+ renderedSilhouetteRatio,
3030
+ strandFeasible,
3031
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2395
3032
  };
2396
3033
  }
2397
- const scaleOf = (l) => (map.mapX(l.x + l.w / 2 - eye.depth * unit) - map.mapX(l.x - l.w / 2 - eye.depth * unit)) / l.w;
2398
- const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2399
- const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2400
- return {
2401
- radius,
2402
- ratio: scaleOf(far) / scaleOf(near),
2403
- unit,
2404
- travel,
2405
- map,
2406
- holdBase,
2407
- holdEdgeAt,
2408
- holdEdgeX,
2409
- eye,
2410
- silhouetteClamped,
2411
- silhouetteCenterShift,
2412
- renderedSilhouetteRatio
2413
- };
2414
3034
  }
2415
3035
  function sweepTurnRadii(ctx) {
2416
3036
  const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
@@ -2419,6 +3039,7 @@ function sweepTurnRadii(ctx) {
2419
3039
  let heldRatioLower = Infinity;
2420
3040
  let heldRatioUpper = -Infinity;
2421
3041
  let offeredShift;
3042
+ let unsettled = 0;
2422
3043
  for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2423
3044
  const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2424
3045
  const result = evaluateTurnCandidate(ctx, radius);
@@ -2427,6 +3048,8 @@ function sweepTurnRadii(ctx) {
2427
3048
  } else if (result.blocked === "silhouetteRatio") {
2428
3049
  heldRatioLower = Math.min(heldRatioLower, result.lower);
2429
3050
  heldRatioUpper = Math.max(heldRatioUpper, result.upper);
3051
+ } else if (result.blocked === "settle") {
3052
+ unsettled++;
2430
3053
  } else {
2431
3054
  offeredShift = offeredShift ? [
2432
3055
  Math.min(offeredShift[0], result.offeredShift[0]),
@@ -2434,7 +3057,16 @@ function sweepTurnRadii(ctx) {
2434
3057
  ] : result.offeredShift;
2435
3058
  }
2436
3059
  }
2437
- return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
3060
+ return {
3061
+ candidates,
3062
+ heldRatioLower,
3063
+ heldRatioUpper,
3064
+ offeredShift,
3065
+ unsettled
3066
+ };
3067
+ }
3068
+ function runHolds(run, ratio) {
3069
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
2438
3070
  }
2439
3071
  function ratioClusters(candidates) {
2440
3072
  const clusters = [];
@@ -2458,7 +3090,7 @@ function ratioClusters(candidates) {
2458
3090
  close();
2459
3091
  return clusters;
2460
3092
  }
2461
- function fitTurnRadius(ctx, candidates, target) {
3093
+ function fitTurnRadius(ctx, candidates, target, accept) {
2462
3094
  let lo = candidates[0];
2463
3095
  let hi = candidates[candidates.length - 1];
2464
3096
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2468,79 +3100,102 @@ function fitTurnRadius(ctx, candidates, target) {
2468
3100
  break;
2469
3101
  }
2470
3102
  }
3103
+ return bisectTurnRadius(
3104
+ lo,
3105
+ hi,
3106
+ target,
3107
+ (radius) => {
3108
+ const mid = evaluateTurnCandidate(ctx, radius);
3109
+ return "blocked" in mid ? void 0 : mid;
3110
+ },
3111
+ accept
3112
+ );
3113
+ }
3114
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3115
+ if (!accept(lo) || !accept(hi)) {
3116
+ throw new Error(
3117
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3118
+ );
3119
+ }
3120
+ const miss = (c) => Math.abs(c.ratio - target);
3121
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2471
3122
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2472
- const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2473
- if ("blocked" in mid) break;
3123
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3124
+ if (mid === void 0) break;
3125
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2474
3126
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2475
3127
  else lo = mid;
2476
3128
  }
2477
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3129
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3130
+ return nearest;
2478
3131
  }
2479
- function solveFeatureDepths(ctx, targets, best, eyeShift) {
2480
- const clamped = [];
2481
- const solved = {};
2482
- const families = [
2483
- ["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
2484
- ["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
2485
- ];
2486
- for (const [field, marks, share] of families) {
2487
- const m = Math.abs(
2488
- targets.defaulted.has(field) ? share * eyeShift : targets[field]
2489
- );
2490
- const solution = solveTurnDepthSigned(
2491
- best.silhouetteCenterShift - m * ctx.hh,
2492
- marks,
2493
- best.unit,
2494
- best.map,
2495
- best.holdEdgeX,
2496
- ctx.plateEdgeX
2497
- );
2498
- if (!solution.reached) {
2499
- if (!targets.defaulted.has(field)) {
2500
- return {
2501
- blocked: field,
2502
- // noseShift/mouthShift are MAGNITUDES too (see the correction
2503
- // above), so a negative lower bound here would advertise a value
2504
- // the field's own contract already rules out; 0 is the true floor.
2505
- attainable: [
2506
- Math.max(
2507
- 0,
2508
- (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2509
- ),
2510
- (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2511
- ]
2512
- };
2513
- }
2514
- clamped.push(field);
2515
- }
2516
- solved[field] = solution.depth;
2517
- }
3132
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3133
+ const feasible = candidates.filter((c) => c.strandFeasible);
3134
+ if (feasible.length === candidates.length) return candidates;
3135
+ const holds = ratioClusters(feasible).some(
3136
+ (run) => runHolds(run, farEyeRatio)
3137
+ );
3138
+ return holds ? feasible : candidates;
3139
+ }
3140
+ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
3141
+ const solve = (measured, marks, share) => solveTurnDepthSigned(
3142
+ silhouetteCenterShift - (measured ?? Math.abs(share * eyeShift)) * ctx.hh,
3143
+ marks,
3144
+ unit,
3145
+ mapAt,
3146
+ ctx.plateEdgeX
3147
+ );
2518
3148
  return {
2519
- nose: solved.noseShift,
2520
- mouth: solved.mouthShift,
2521
- clamped
3149
+ nose: solve(ctx.noseShift, ctx.landmarks.nose, NOSE_SHIFT_SHARE),
3150
+ mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2522
3151
  };
2523
3152
  }
2524
- function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
3153
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2525
3154
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2526
3155
  throw new TurnTargetError(
2527
3156
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2528
3157
  );
2529
3158
  }
2530
- const eyeRowY = landmarks.eye.length === 2 && landmarks.eye[0].y !== void 0 && landmarks.eye[1].y !== void 0 ? (landmarks.eye[0].y + landmarks.eye[1].y) / 2 : void 0;
2531
- const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
2532
- 0,
2533
- Math.min(
2534
- 1,
2535
- (hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
2536
- )
3159
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3160
+ let strands;
3161
+ const strandSides = ["left", "right"].filter(
3162
+ (key) => strandEdges?.[key] !== void 0
2537
3163
  );
3164
+ if (strandSides.length > 0) {
3165
+ if (hairFront === void 0) {
3166
+ throw new Error(
3167
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3168
+ );
3169
+ }
3170
+ 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);
3171
+ strands = strandSides.map((key) => {
3172
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3173
+ if (carrier === void 0) {
3174
+ throw new Error(
3175
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3176
+ );
3177
+ }
3178
+ return {
3179
+ ...strandEdges[key],
3180
+ side: key === "left" ? -1 : 1,
3181
+ carrier
3182
+ };
3183
+ });
3184
+ }
2538
3185
  const ctx = {
2539
3186
  landmarks,
2540
- faceGrid,
3187
+ lattice,
2541
3188
  faceCenterX,
3189
+ faceCenterY,
3190
+ nodRadius,
3191
+ eyeRowY,
2542
3192
  faceHalfWidth,
3193
+ // The slide's hard stop is the CROP's edge, profile or not: the far eye
3194
+ // may sit on the plate's transparent margin, as it does on the hero.
2543
3195
  plateEdgeX: faceCenterX - faceHalfWidth,
3196
+ carriers,
3197
+ profile,
3198
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
2544
3199
  // What this plate asks the turn for; each candidate caps it to its own
2545
3200
  // shell — see TurnSolveContext.travel.
2546
3201
  travel: headTurnTravel(faceHalfWidth),
@@ -2553,16 +3208,23 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2553
3208
  // every downstream use already has the magnitude.
2554
3209
  eyeShift: Math.abs(targets.eyeShift),
2555
3210
  clampEyeShift: targets.defaulted.has("eyeShift"),
3211
+ noseShift: targets.defaulted.has("noseShift") ? void 0 : Math.abs(targets.noseShift),
3212
+ mouthShift: targets.defaulted.has("mouthShift") ? void 0 : Math.abs(targets.mouthShift),
2556
3213
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2557
- hairFrontLeadFraction,
2558
- hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
3214
+ hairFrontSilhouette: hairFront,
2559
3215
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2560
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3216
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3217
+ strands
2561
3218
  };
2562
- const clamped = [];
2563
3219
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2564
3220
  const pass = sweepTurnRadii(ctx);
2565
3221
  if (pass.candidates.length === 0) {
3222
+ if (pass.unsettled > 0) {
3223
+ const refused = TURN_SWEEP_SAMPLES - pass.unsettled;
3224
+ throw new TurnTargetError(
3225
+ `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}` : ""}`
3226
+ );
3227
+ }
2566
3228
  return pass.offeredShift ? {
2567
3229
  unreachable: true,
2568
3230
  field: "eyeShift",
@@ -2584,72 +3246,107 @@ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth
2584
3246
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2585
3247
  };
2586
3248
  }
2587
- const clusters = ratioClusters(pass.candidates);
2588
- const distanceTo = (c) => Math.max(
2589
- c.ratios[0] - targets.farEyeRatio,
2590
- targets.farEyeRatio - c.ratios[1],
2591
- 0
2592
- );
2593
- let farEyeRatio = targets.farEyeRatio;
2594
- let cluster = clusters.find(
2595
- (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2596
- );
2597
- if (cluster === void 0) {
2598
- cluster = clusters.reduce(
2599
- (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3249
+ const fitFrom = (pool, accept) => {
3250
+ const clamped = [];
3251
+ const clusters = ratioClusters(pool);
3252
+ const distanceTo = (c) => Math.max(
3253
+ c.ratios[0] - targets.farEyeRatio,
3254
+ targets.farEyeRatio - c.ratios[1],
3255
+ 0
2600
3256
  );
2601
- if (!targets.defaulted.has("farEyeRatio")) {
2602
- return {
2603
- unreachable: true,
2604
- field: "farEyeRatio",
2605
- value: targets.farEyeRatio,
2606
- attainable: cluster.ratios
2607
- };
3257
+ let farEyeRatio = targets.farEyeRatio;
3258
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3259
+ if (cluster === void 0) {
3260
+ cluster = clusters.reduce(
3261
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3262
+ );
3263
+ if (!targets.defaulted.has("farEyeRatio")) {
3264
+ return {
3265
+ unreachable: true,
3266
+ field: "farEyeRatio",
3267
+ value: targets.farEyeRatio,
3268
+ attainable: cluster.ratios
3269
+ };
3270
+ }
3271
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3272
+ clamped.push("farEyeRatio");
2608
3273
  }
2609
- farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2610
- clamped.push("farEyeRatio");
2611
- }
2612
- const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2613
- if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2614
- if (!targets.defaulted.has("farEyeRatio")) {
2615
- return {
2616
- unreachable: true,
2617
- field: "farEyeRatio",
2618
- value: targets.farEyeRatio,
2619
- attainable: cluster.ratios
2620
- };
3274
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3275
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3276
+ if (!targets.defaulted.has("farEyeRatio")) {
3277
+ return {
3278
+ unreachable: true,
3279
+ field: "farEyeRatio",
3280
+ value: targets.farEyeRatio,
3281
+ attainable: cluster.ratios
3282
+ };
3283
+ }
3284
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3285
+ }
3286
+ if (!best.eye.reached) clamped.push("eyeShift");
3287
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3288
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3289
+ for (const [field, solution] of [
3290
+ ["noseShift", best.nose],
3291
+ ["mouthShift", best.mouth]
3292
+ ]) {
3293
+ if (solution.reached) continue;
3294
+ if (!targets.defaulted.has(field)) {
3295
+ return {
3296
+ unreachable: true,
3297
+ field,
3298
+ value: targets[field],
3299
+ // noseShift/mouthShift are MAGNITUDES too (see
3300
+ // solveFeatureDepths), so a negative lower bound here would
3301
+ // advertise a value the field's own contract already rules out; 0
3302
+ // is the true floor.
3303
+ attainable: [
3304
+ Math.max(
3305
+ 0,
3306
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3307
+ ),
3308
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3309
+ ]
3310
+ };
3311
+ }
3312
+ clamped.push(field);
2621
3313
  }
2622
- if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2623
- }
2624
- if (!best.eye.reached) clamped.push("eyeShift");
2625
- if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2626
- const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2627
- const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2628
- if ("blocked" in features) {
2629
3314
  return {
2630
- unreachable: true,
2631
- field: features.blocked,
2632
- value: targets[features.blocked],
2633
- attainable: features.attainable
3315
+ unreachable: false,
3316
+ radius: best.radius,
3317
+ holdBase: best.holdBase,
3318
+ holdEdgeAt: best.holdEdgeAt,
3319
+ travel: best.travel,
3320
+ surface: best.surface,
3321
+ depths: {
3322
+ eye: best.eye.depth,
3323
+ nose: best.nose.depth,
3324
+ mouth: best.mouth.depth
3325
+ },
3326
+ achieved: {
3327
+ eyeShift,
3328
+ farEyeRatio: best.ratio,
3329
+ silhouetteRatio: best.renderedSilhouetteRatio
3330
+ },
3331
+ clamped,
3332
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
2634
3333
  };
2635
- }
2636
- return {
2637
- unreachable: false,
2638
- radius: best.radius,
2639
- holdBase: best.holdBase,
2640
- holdEdgeAt: best.holdEdgeAt,
2641
- travel: best.travel,
2642
- depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2643
- achieved: {
2644
- eyeShift,
2645
- farEyeRatio: best.ratio,
2646
- silhouetteRatio: best.renderedSilhouetteRatio
2647
- },
2648
- clamped: [...clamped, ...features.clamped]
2649
3334
  };
3335
+ const preferred = strandPreferredCandidates(
3336
+ pass.candidates,
3337
+ targets.farEyeRatio
3338
+ );
3339
+ if (preferred !== pass.candidates) {
3340
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3341
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3342
+ return fitted;
3343
+ }
3344
+ }
3345
+ return fitFrom(pass.candidates, () => true);
2650
3346
  }
2651
3347
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2652
3348
  var HAIR_FRONT_DEPTH = 0.1;
3349
+ var MOUTH_TURN_TILT_DEG = 5;
2653
3350
  var NOD_TRAVEL = 30;
2654
3351
  var NOD_BEND = 0.5;
2655
3352
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -2682,27 +3379,16 @@ function featureParallaxBindings(role, options) {
2682
3379
  if (!options.hasNose) return [];
2683
3380
  const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2684
3381
  if (nodDepth === void 0) return [];
2685
- const bindings = [];
2686
- const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2687
- const shiftX = turnDepth * (options.parallaxUnit ?? 0);
2688
- if (shiftX !== 0) {
2689
- bindings.push({
2690
- parameter: import_format3.StandardParameter.AngleX,
2691
- channel: "translateX",
2692
- from: -shiftX,
2693
- to: shiftX
2694
- });
2695
- }
2696
3382
  const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2697
- if (shiftY !== 0) {
2698
- bindings.push({
3383
+ if (shiftY === 0) return [];
3384
+ return [
3385
+ {
2699
3386
  parameter: import_format3.StandardParameter.AngleY,
2700
3387
  channel: "translateY",
2701
3388
  from: -shiftY,
2702
3389
  to: shiftY
2703
- });
2704
- }
2705
- return bindings;
3390
+ }
3391
+ ];
2706
3392
  }
2707
3393
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2708
3394
  return [
@@ -2810,20 +3496,17 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
2810
3496
  ];
2811
3497
  }
2812
3498
  }
2813
- if (role === "hair_front" || role === "hair_back") {
2814
- const isFront = role === "hair_front";
2815
- const parallax = [];
2816
- const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2817
- const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2818
- if (shiftY !== 0) {
2819
- parallax.push({
3499
+ if (role === "hair_back") {
3500
+ const shiftY = HAIR_BACK_NOD_DEPTH * (options.parallaxUnitY ?? 0);
3501
+ if (shiftY === 0) return [];
3502
+ return [
3503
+ {
2820
3504
  parameter: import_format3.StandardParameter.AngleY,
2821
3505
  channel: "translateY",
2822
3506
  from: -shiftY,
2823
3507
  to: shiftY
2824
- });
2825
- }
2826
- return parallax;
3508
+ }
3509
+ ];
2827
3510
  }
2828
3511
  return [];
2829
3512
  }
@@ -2846,64 +3529,98 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2846
3529
  ]
2847
3530
  };
2848
3531
  }
2849
- function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
3532
+ function bakeNodWarp(mesh, partY, surface, nodTravel) {
3533
+ const keyforms = HEAD_TURN_STOPS.map((deg) => {
3534
+ const t = nodTravel * deg / HEAD_TURN_MAX_DEG;
3535
+ const offsets = [];
3536
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
3537
+ const y = partY + mesh.vertices[i + 1];
3538
+ offsets.push(0, deg === 0 ? 0 : t + surface.nodBendAt(y + t, deg));
3539
+ }
3540
+ return { value: deg, offsets };
3541
+ });
3542
+ return { parameter: import_format3.StandardParameter.AngleY, keyforms };
3543
+ }
3544
+ function plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) {
2850
3545
  let reach = 0;
2851
3546
  for (const deg of HEAD_TURN_STOPS) {
2852
3547
  reach = Math.max(
2853
3548
  reach,
2854
- plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
3549
+ plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
2855
3550
  );
2856
3551
  }
2857
3552
  return reach;
2858
3553
  }
2859
- function plateReachAt(map, faceCenterX, faceHalfWidth) {
3554
+ function plateSideReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg, side) {
2860
3555
  let reach = 0;
2861
- for (const side of [-1, 1]) {
2862
- const dest = map.mapX(faceCenterX + side * faceHalfWidth);
3556
+ for (const y of plateGuardRows) {
3557
+ const dest = plateLandingAt(deg, faceCenterX + side * edgeAt(y), y);
2863
3558
  reach = Math.max(reach, Math.abs(dest - faceCenterX));
2864
3559
  }
2865
3560
  return reach;
2866
3561
  }
2867
- function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
3562
+ function plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg) {
3563
+ return Math.max(
3564
+ plateSideReachAt(
3565
+ plateLandingAt,
3566
+ faceCenterX,
3567
+ edgeAt,
3568
+ plateGuardRows,
3569
+ deg,
3570
+ -1
3571
+ ),
3572
+ plateSideReachAt(
3573
+ plateLandingAt,
3574
+ faceCenterX,
3575
+ edgeAt,
3576
+ plateGuardRows,
3577
+ deg,
3578
+ 1
3579
+ )
3580
+ );
3581
+ }
3582
+ function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows) {
2868
3583
  if (holdEdgeAt(0) !== holdBase) {
2869
3584
  throw new Error(
2870
3585
  `auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
2871
3586
  );
2872
3587
  }
2873
- const SIDES = [-1, 1];
2874
3588
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2875
- const map = columnMapAt(deg);
2876
- const requestedHoldEdge = holdEdgeAt(deg);
2877
- const plateDest = SIDES.map(
2878
- (side) => map.mapX(faceCenterX + side * faceHalfWidth)
3589
+ const holdEdge = holdEdgeAt(deg);
3590
+ const reach = [-1, 1].map(
3591
+ (side) => plateSideReachAt(
3592
+ plateLandingAt,
3593
+ faceCenterX,
3594
+ edgeAt,
3595
+ plateGuardRows,
3596
+ deg,
3597
+ side
3598
+ )
2879
3599
  );
2880
- const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
2881
- if (requestedHoldEdge <= reached) {
2882
- const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
3600
+ const reached = Math.max(reach[0], reach[1]);
3601
+ if (holdEdge <= reached) {
3602
+ const side = reach[1] > reach[0] ? 1 : -1;
2883
3603
  throw new Error(
2884
- `auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${requestedHoldEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
3604
+ `auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${holdEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
2885
3605
  );
2886
3606
  }
2887
- const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
2888
- (_side, idx) => Math.min(
2889
- requestedHoldEdge,
2890
- Math.abs(
2891
- map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
2892
- ) - HOLD_CLEARANCE
2893
- )
2894
- );
2895
3607
  const offsets = [];
2896
3608
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2897
3609
  const x = partX + mesh.vertices[i];
2898
- const target = hairFrontHoldTarget(
2899
- x,
2900
- faceCenterX,
2901
- faceHalfWidth,
2902
- holdBase,
2903
- holdEdge,
2904
- map
3610
+ const y = partY + mesh.vertices[i + 1];
3611
+ offsets.push(
3612
+ deg === 0 ? 0 : hairFrontHoldTarget(
3613
+ x,
3614
+ y,
3615
+ deg,
3616
+ faceCenterX,
3617
+ plateLandingAt,
3618
+ edgeAt,
3619
+ holdBase,
3620
+ holdEdge
3621
+ ) - x,
3622
+ 0
2905
3623
  );
2906
- offsets.push(map.invertX(target) - x, 0);
2907
3624
  }
2908
3625
  return { value: deg, offsets };
2909
3626
  });
@@ -2912,7 +3629,6 @@ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, ho
2912
3629
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2913
3630
  var HAIR_SWAY_CURL = 1.5;
2914
3631
  var HAIR_SWAY_RANGE = 20;
2915
- var HAIR_SWAY_PEAK_FRACTION = 0.56;
2916
3632
  function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2917
3633
  let top = -Infinity;
2918
3634
  let bottom = Infinity;
@@ -2942,112 +3658,385 @@ function headTurnTravel(faceHalfWidth) {
2942
3658
  var BODY_TURN_FOLLOW = 0.3;
2943
3659
  var HEAD_BREATH_BOB = -12;
2944
3660
  var BODY_BREATH_FOLLOW = 0.5;
3661
+ var STANDARD_PARAMETERS = [
3662
+ {
3663
+ id: import_format3.StandardParameter.MouthOpen,
3664
+ name: "Mouth Open",
3665
+ min: 0,
3666
+ max: 1,
3667
+ default: 0
3668
+ },
3669
+ {
3670
+ id: import_format3.StandardParameter.MouthForm,
3671
+ name: "Mouth Form",
3672
+ min: -1,
3673
+ max: 1,
3674
+ default: 0
3675
+ },
3676
+ {
3677
+ id: import_format3.StandardParameter.EyeOpenLeft,
3678
+ name: "Eye L",
3679
+ min: 0,
3680
+ max: 1,
3681
+ default: 1
3682
+ },
3683
+ {
3684
+ id: import_format3.StandardParameter.EyeOpenRight,
3685
+ name: "Eye R",
3686
+ min: 0,
3687
+ max: 1,
3688
+ default: 1
3689
+ },
3690
+ {
3691
+ id: import_format3.StandardParameter.EyeballX,
3692
+ name: "Gaze X",
3693
+ min: -1,
3694
+ max: 1,
3695
+ default: 0
3696
+ },
3697
+ {
3698
+ id: import_format3.StandardParameter.EyeballY,
3699
+ name: "Gaze Y",
3700
+ min: -1,
3701
+ max: 1,
3702
+ default: 0
3703
+ },
3704
+ {
3705
+ id: import_format3.StandardParameter.AngleX,
3706
+ name: "Head Angle",
3707
+ min: -30,
3708
+ max: 30,
3709
+ default: 0
3710
+ },
3711
+ {
3712
+ id: import_format3.StandardParameter.AngleY,
3713
+ name: "Head Angle Y",
3714
+ min: -30,
3715
+ max: 30,
3716
+ default: 0
3717
+ },
3718
+ {
3719
+ id: import_format3.StandardParameter.AngleZ,
3720
+ name: "Head Angle Z",
3721
+ min: -30,
3722
+ max: 30,
3723
+ default: 0
3724
+ },
3725
+ {
3726
+ id: import_format3.StandardParameter.Breath,
3727
+ name: "Breath",
3728
+ min: 0,
3729
+ max: 1,
3730
+ default: 0
3731
+ },
3732
+ {
3733
+ id: import_format3.StandardParameter.BrowLeftY,
3734
+ name: "Brow L Y",
3735
+ min: -1,
3736
+ max: 1,
3737
+ default: 0
3738
+ },
3739
+ {
3740
+ id: import_format3.StandardParameter.BrowRightY,
3741
+ name: "Brow R Y",
3742
+ min: -1,
3743
+ max: 1,
3744
+ default: 0
3745
+ },
3746
+ {
3747
+ id: import_format3.StandardParameter.BrowLeftAngle,
3748
+ name: "Brow L Angle",
3749
+ min: -1,
3750
+ max: 1,
3751
+ default: 0
3752
+ },
3753
+ {
3754
+ id: import_format3.StandardParameter.BrowRightAngle,
3755
+ name: "Brow R Angle",
3756
+ min: -1,
3757
+ max: 1,
3758
+ default: 0
3759
+ }
3760
+ ];
3761
+ function transformUnder(bindings, valueOf) {
3762
+ const trs = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 };
3763
+ bindings.forEach((binding, i) => {
3764
+ const value = valueOf(binding, i);
3765
+ switch (binding.channel) {
3766
+ case "translateX":
3767
+ trs.x += value;
3768
+ break;
3769
+ case "translateY":
3770
+ trs.y += value;
3771
+ break;
3772
+ case "rotate":
3773
+ trs.rotation += value;
3774
+ break;
3775
+ case "scaleX":
3776
+ trs.scaleX += value;
3777
+ break;
3778
+ case "scaleY":
3779
+ trs.scaleY += value;
3780
+ break;
3781
+ case "opacity":
3782
+ break;
3783
+ }
3784
+ });
3785
+ return trs;
3786
+ }
3787
+ function defaultPoseOf(bindings) {
3788
+ return transformUnder(bindings, (binding) => {
3789
+ const p = STANDARD_PARAMETERS.find((p2) => p2.id === binding.parameter);
3790
+ if (p === void 0) {
3791
+ throw new Error(
3792
+ `auto-rig: defaultPoseOf: "${binding.parameter}" is not a standard parameter`
3793
+ );
3794
+ }
3795
+ const t = (p.default - p.min) / (p.max - p.min);
3796
+ return binding.from + (binding.to - binding.from) * t;
3797
+ });
3798
+ }
3799
+ function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
3800
+ let minX = Infinity;
3801
+ let maxX = -Infinity;
3802
+ let minY = Infinity;
3803
+ let maxY = -Infinity;
3804
+ for (const member of members) {
3805
+ const rest = member.mesh.vertices;
3806
+ const shapes = [rest];
3807
+ for (const warp of member.warps) {
3808
+ for (const keyform of warp.keyforms) {
3809
+ shapes.push(rest.map((v, i) => v + keyform.offsets[i]));
3810
+ }
3811
+ }
3812
+ const geometric = member.bindings.filter((b) => b.channel !== "opacity");
3813
+ for (let corner = 0; corner < 1 << geometric.length; corner++) {
3814
+ const trs = transformUnder(
3815
+ geometric,
3816
+ (b, i) => (corner & 1 << i) === 0 ? b.from : b.to
3817
+ );
3818
+ const cos = Math.cos(trs.rotation * (Math.PI / 180));
3819
+ const sin = Math.sin(trs.rotation * (Math.PI / 180));
3820
+ for (const shape of shapes) {
3821
+ for (let i = 0; i < shape.length; i += 2) {
3822
+ const sx = shape[i] * trs.scaleX;
3823
+ const sy = shape[i + 1] * trs.scaleY;
3824
+ const x = member.transform.x + trs.x + cos * sx - sin * sy;
3825
+ const y = member.transform.y + trs.y + sin * sx + cos * sy;
3826
+ minX = Math.min(minX, x);
3827
+ maxX = Math.max(maxX, x);
3828
+ minY = Math.min(minY, y);
3829
+ maxY = Math.max(maxY, y);
3830
+ }
3831
+ }
3832
+ }
3833
+ }
3834
+ minX -= turnShiftPx;
3835
+ maxX += turnShiftPx;
3836
+ minY -= nodShiftPx;
3837
+ maxY += nodShiftPx;
3838
+ const marginX = (maxX - minX) * GRID_MARGIN + 1;
3839
+ const marginY = (maxY - minY) * GRID_MARGIN + 1;
3840
+ return {
3841
+ cols: cells,
3842
+ rows: cells,
3843
+ points: generateGridPoints(
3844
+ cells,
3845
+ cells,
3846
+ minX - marginX,
3847
+ maxX + marginX,
3848
+ minY - marginY,
3849
+ maxY + marginY
3850
+ )
3851
+ };
3852
+ }
3853
+ function eyelidFoldFor(layers, layer, mesh, t) {
3854
+ const side = ROLE_TABLE[layer.role].eyeSide;
3855
+ const isLash = layer.role.startsWith("lash_");
3856
+ if (side === void 0 || !(layer.role.startsWith("eye_") || isLash)) {
3857
+ return void 0;
3858
+ }
3859
+ const white = layers.find((l) => l.role === `eye_${side}`);
3860
+ const creaseWorldY = bboxToTransform(white.bbox, white.canvasW, white.canvasH, white.role).y - EYELID_FOLD_CREASE * white.cropH;
3861
+ const hasLash = layers.some((l) => l.role === `lash_${side}`);
3862
+ return bakeEyelidFoldWarp(
3863
+ mesh,
3864
+ side === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight,
3865
+ creaseWorldY - t.y,
3866
+ isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3867
+ );
3868
+ }
3869
+ function turnSetup(layers) {
3870
+ const faceLayer = layers.find((l) => l.role === "face");
3871
+ const faceTransform = bboxToTransform(
3872
+ faceLayer.bbox,
3873
+ faceLayer.canvasW,
3874
+ faceLayer.canvasH,
3875
+ "face"
3876
+ );
3877
+ const faceCenterX = faceTransform.x;
3878
+ const faceCenterY = faceTransform.y;
3879
+ const faceHalfWidth = faceLayer.cropW / 2;
3880
+ const plateEdgeX = faceCenterX - faceHalfWidth;
3881
+ const hasNose = layers.some((l) => l.role === "nose");
3882
+ const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
3883
+ const turnLayers = layers.filter(
3884
+ (l) => isTurnGroup(ROLE_TABLE[l.role].deformer) || l.role === "hair_front"
3885
+ );
3886
+ const turnYs = turnLayers.map((l) => ({
3887
+ y: bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role).y,
3888
+ h: l.cropH
3889
+ }));
3890
+ let unionMinY = Math.min(...turnYs.map(({ y, h }) => y - h / 2));
3891
+ let unionMaxY = Math.max(...turnYs.map(({ y, h }) => y + h / 2));
3892
+ const spanY = unionMaxY - unionMinY;
3893
+ unionMinY -= spanY * GRID_MARGIN;
3894
+ unionMaxY += spanY * GRID_MARGIN;
3895
+ const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3896
+ const nodRadius = halfH * HEAD_CYLINDER_RADIUS_FACTOR;
3897
+ const parallaxUnitY = headNodParallaxUnit(halfH);
3898
+ const members = [];
3899
+ for (const layer of layers) {
3900
+ const spec = ROLE_TABLE[layer.role];
3901
+ if (!isTurnGroup(spec.deformer)) continue;
3902
+ const { role, cropW, cropH } = layer;
3903
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
3904
+ const { cols, rows } = meshCellsFor(cropW, cropH);
3905
+ const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
3906
+ const bindings = bindingsForRole(spec, role, cropW, cropH, {
3907
+ hasMouthOpen
3908
+ });
3909
+ const fold = eyelidFoldFor(layers, layer, mesh, t);
3910
+ const pose = defaultPoseOf(bindings);
3911
+ members.push({
3912
+ role,
3913
+ group: spec.deformer,
3914
+ transform: t,
3915
+ part: {
3916
+ x: t.x + pose.x,
3917
+ y: t.y + pose.y,
3918
+ cropW,
3919
+ cropH,
3920
+ cols,
3921
+ rows,
3922
+ scaleX: pose.scaleX,
3923
+ scaleY: pose.scaleY,
3924
+ rotation: pose.rotation
3925
+ },
3926
+ mesh,
3927
+ bindings,
3928
+ warps: fold === void 0 ? [] : [fold]
3929
+ });
3930
+ }
3931
+ const landmarks = turnLandmarks(layers);
3932
+ const turnShiftOf = (marks) => hasNose && marks.length > 0 ? familyReachPx(marks, plateEdgeX) : 0;
3933
+ const turnShift = {
3934
+ eye: turnShiftOf(landmarks.eye),
3935
+ nose: turnShiftOf(landmarks.nose),
3936
+ mouth: turnShiftOf(landmarks.mouth)
3937
+ };
3938
+ const groupGrids = /* @__PURE__ */ new Map();
3939
+ for (const { group, role } of members) {
3940
+ if (groupGrids.has(group)) continue;
3941
+ const own = members.filter((m) => m.group === group);
3942
+ const isPlate = group === "faceWarp";
3943
+ const nodShiftPx = hasNose ? Math.max(
3944
+ ...own.map((m) => FEATURE_NOD_DEPTH[roleFamily(m.role)] ?? 0)
3945
+ ) * parallaxUnitY : 0;
3946
+ groupGrids.set(
3947
+ group,
3948
+ groupGridFor(
3949
+ own,
3950
+ isPlate ? FACE_PLATE_CELLS : FEATURE_GRID_CELLS,
3951
+ isPlate ? 0 : turnShift[turnFamily(role)],
3952
+ nodShiftPx
3953
+ )
3954
+ );
3955
+ }
3956
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
3957
+ const mouthAnchor = {
3958
+ x: mouthAt.x,
3959
+ y: mouthAt.y,
3960
+ tiltDeg: MOUTH_TURN_TILT_DEG
3961
+ };
3962
+ const carriers = new Map(
3963
+ members.map((m) => [
3964
+ m.role,
3965
+ m.group === "mouthWarp" ? { grid: groupGrids.get(m.group), part: m.part, anchor: mouthAnchor } : { grid: groupGrids.get(m.group), part: m.part }
3966
+ ])
3967
+ );
3968
+ const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3969
+ let reach = 0;
3970
+ for (const grid of groupGrids.values()) {
3971
+ reach = Math.max(
3972
+ reach,
3973
+ faceCenterX - grid.points[0] + maxShift,
3974
+ grid.points[grid.cols * 2] - faceCenterX + maxShift
3975
+ );
3976
+ }
3977
+ const halfCells = Math.floor(reach / TURN_LATTICE_CELL_PX) + 1;
3978
+ const points = [];
3979
+ for (const y of [unionMaxY, unionMinY]) {
3980
+ for (let k = -halfCells; k <= halfCells; k++) {
3981
+ points.push(faceCenterX + k * TURN_LATTICE_CELL_PX, y);
3982
+ }
3983
+ }
3984
+ const lattice = { cols: 2 * halfCells, rows: 1, points };
3985
+ const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3986
+ const hairFront = hairFrontLayer && (() => {
3987
+ const t = bboxToTransform(
3988
+ hairFrontLayer.bbox,
3989
+ hairFrontLayer.canvasW,
3990
+ hairFrontLayer.canvasH,
3991
+ "hair_front"
3992
+ );
3993
+ return {
3994
+ x: t.x,
3995
+ centerY: t.y,
3996
+ cropW: hairFrontLayer.cropW,
3997
+ cropH: hairFrontLayer.cropH
3998
+ };
3999
+ })();
4000
+ const eyeRowY = eyeRowOf(landmarks, faceCenterY);
4001
+ const profile = faceRowProfile(faceLayer, faceCenterY);
4002
+ return {
4003
+ landmarks: turnLandmarks(layers, carriers),
4004
+ lattice,
4005
+ faceCenterX,
4006
+ faceHalfWidth,
4007
+ faceCenterY,
4008
+ nodRadius,
4009
+ carriers,
4010
+ profile,
4011
+ hairFront,
4012
+ eyeRowY,
4013
+ ...plateGuardsOf(carriers, faceHalfWidth, profile, hairFront),
4014
+ members,
4015
+ groupGrids,
4016
+ parallaxUnitY,
4017
+ hasNose,
4018
+ hasMouthOpen
4019
+ };
4020
+ }
4021
+ function solveInputsOf(s) {
4022
+ return [
4023
+ s.landmarks,
4024
+ s.lattice,
4025
+ s.faceCenterX,
4026
+ s.faceHalfWidth,
4027
+ s.faceCenterY,
4028
+ s.nodRadius,
4029
+ s.carriers,
4030
+ s.profile,
4031
+ s.hairFront
4032
+ ];
4033
+ }
2945
4034
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
2946
4035
  validateLayerInputs(layers, canvas);
4036
+ validateStrandEdges(layers, options.strandEdges);
2947
4037
  const hasHair = layers.some((l) => l.role === "hair_front");
2948
- const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2949
- const hasNose = layers.some((l) => l.role === "nose");
2950
4038
  const bodyLayer = layers.find((l) => l.role === "body");
2951
- const parameters = [
2952
- {
2953
- id: import_format3.StandardParameter.MouthOpen,
2954
- name: "Mouth Open",
2955
- min: 0,
2956
- max: 1,
2957
- default: 0
2958
- },
2959
- {
2960
- id: import_format3.StandardParameter.MouthForm,
2961
- name: "Mouth Form",
2962
- min: -1,
2963
- max: 1,
2964
- default: 0
2965
- },
2966
- {
2967
- id: import_format3.StandardParameter.EyeOpenLeft,
2968
- name: "Eye L",
2969
- min: 0,
2970
- max: 1,
2971
- default: 1
2972
- },
2973
- {
2974
- id: import_format3.StandardParameter.EyeOpenRight,
2975
- name: "Eye R",
2976
- min: 0,
2977
- max: 1,
2978
- default: 1
2979
- },
2980
- {
2981
- id: import_format3.StandardParameter.EyeballX,
2982
- name: "Gaze X",
2983
- min: -1,
2984
- max: 1,
2985
- default: 0
2986
- },
2987
- {
2988
- id: import_format3.StandardParameter.EyeballY,
2989
- name: "Gaze Y",
2990
- min: -1,
2991
- max: 1,
2992
- default: 0
2993
- },
2994
- {
2995
- id: import_format3.StandardParameter.AngleX,
2996
- name: "Head Angle",
2997
- min: -30,
2998
- max: 30,
2999
- default: 0
3000
- },
3001
- {
3002
- id: import_format3.StandardParameter.AngleY,
3003
- name: "Head Angle Y",
3004
- min: -30,
3005
- max: 30,
3006
- default: 0
3007
- },
3008
- {
3009
- id: import_format3.StandardParameter.AngleZ,
3010
- name: "Head Angle Z",
3011
- min: -30,
3012
- max: 30,
3013
- default: 0
3014
- },
3015
- {
3016
- id: import_format3.StandardParameter.Breath,
3017
- name: "Breath",
3018
- min: 0,
3019
- max: 1,
3020
- default: 0
3021
- },
3022
- {
3023
- id: import_format3.StandardParameter.BrowLeftY,
3024
- name: "Brow L Y",
3025
- min: -1,
3026
- max: 1,
3027
- default: 0
3028
- },
3029
- {
3030
- id: import_format3.StandardParameter.BrowRightY,
3031
- name: "Brow R Y",
3032
- min: -1,
3033
- max: 1,
3034
- default: 0
3035
- },
3036
- {
3037
- id: import_format3.StandardParameter.BrowLeftAngle,
3038
- name: "Brow L Angle",
3039
- min: -1,
3040
- max: 1,
3041
- default: 0
3042
- },
3043
- {
3044
- id: import_format3.StandardParameter.BrowRightAngle,
3045
- name: "Brow R Angle",
3046
- min: -1,
3047
- max: 1,
3048
- default: 0
3049
- }
3050
- ];
4039
+ const parameters = [...STANDARD_PARAMETERS];
3051
4040
  if (hasHair) {
3052
4041
  parameters.push(
3053
4042
  {
@@ -3066,87 +4055,31 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3066
4055
  }
3067
4056
  );
3068
4057
  }
3069
- const faceLayers = layers.filter((l) => l.role === "face");
3070
- const faceLayer = faceLayers[0];
3071
- const faceTransform = bboxToTransform(
3072
- faceLayer.bbox,
3073
- faceLayer.canvasW,
3074
- faceLayer.canvasH,
3075
- "face"
3076
- );
3077
- const faceCenterX = faceTransform.x;
3078
- const faceCropH = faceLayer.cropH;
3079
- const faceWarpLayers = layers.filter(
3080
- (l) => ROLE_TABLE[l.role].deformer === "faceWarp"
3081
- );
3082
- let unionMinX = -canvas.width / 2;
3083
- let unionMaxX = canvas.width / 2;
3084
- let unionMinY = -canvas.height / 2;
3085
- let unionMaxY = canvas.height / 2;
3086
- if (faceWarpLayers.length > 0) {
3087
- const transforms = faceWarpLayers.map(
3088
- (l) => bboxToTransform(l.bbox, l.canvasW, l.canvasH, l.role)
3089
- );
3090
- unionMinX = Math.min(
3091
- ...transforms.map((t, i) => t.x - faceWarpLayers[i].cropW / 2)
3092
- );
3093
- unionMaxX = Math.max(
3094
- ...transforms.map((t, i) => t.x + faceWarpLayers[i].cropW / 2)
3095
- );
3096
- unionMinY = Math.min(
3097
- ...transforms.map((t, i) => t.y - faceWarpLayers[i].cropH / 2)
3098
- );
3099
- unionMaxY = Math.max(
3100
- ...transforms.map((t, i) => t.y + faceWarpLayers[i].cropH / 2)
3101
- );
3102
- const spanX = unionMaxX - unionMinX;
3103
- const spanY = unionMaxY - unionMinY;
3104
- const MARGIN = 0.12;
3105
- unionMinX -= spanX * MARGIN;
3106
- unionMaxX += spanX * MARGIN;
3107
- unionMinY -= spanY * MARGIN;
3108
- unionMaxY += spanY * MARGIN;
3109
- }
3110
- const halfW = Math.max(faceCenterX - unionMinX, unionMaxX - faceCenterX);
3111
- const faceGridMinX = faceCenterX - halfW;
3112
- const faceGridMaxX = faceCenterX + halfW;
3113
- const faceGrid = {
3114
- cols: FACE_GRID_CELLS,
3115
- rows: FACE_GRID_CELLS,
3116
- points: generateGridPoints(
3117
- FACE_GRID_CELLS,
3118
- FACE_GRID_CELLS,
3119
- faceGridMinX,
3120
- faceGridMaxX,
3121
- unionMinY,
3122
- unionMaxY
3123
- )
3124
- };
3125
- const faceCenterY = faceTransform.y;
3126
- const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
3127
- const faceHalfWidth = faceLayer.cropW / 2;
3128
- const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3129
- const turn = hasNose ? solveTurnModel(
3130
- resolveTurnTargets(options.turnTargets),
3131
- turnLandmarks(layers),
3132
- faceGrid,
4058
+ const setup = turnSetup(layers);
4059
+ const {
4060
+ lattice,
3133
4061
  faceCenterX,
3134
4062
  faceHalfWidth,
3135
- hairFrontLayer && (() => {
3136
- const t = bboxToTransform(
3137
- hairFrontLayer.bbox,
3138
- hairFrontLayer.canvasW,
3139
- hairFrontLayer.canvasH,
3140
- "hair_front"
3141
- );
3142
- return {
3143
- x: t.x,
3144
- centerY: t.y,
3145
- cropW: hairFrontLayer.cropW,
3146
- cropH: hairFrontLayer.cropH
3147
- };
3148
- })(),
3149
- options.headEdges
4063
+ faceCenterY,
4064
+ nodRadius,
4065
+ profile,
4066
+ eyeRowY,
4067
+ plate,
4068
+ edgeAt,
4069
+ plateGuardRows,
4070
+ members,
4071
+ groupGrids,
4072
+ carriers,
4073
+ parallaxUnitY,
4074
+ hasNose,
4075
+ hasMouthOpen
4076
+ } = setup;
4077
+ const faceCropH = plate.part.cropH;
4078
+ const turn = hasNose ? solveTurnModel(
4079
+ resolveTurnTargets(options.turnTargets),
4080
+ ...solveInputsOf(setup),
4081
+ options.headEdges,
4082
+ options.strandEdges
3150
4083
  ) : void 0;
3151
4084
  if (turn?.unreachable) {
3152
4085
  throw new TurnTargetError(
@@ -3164,28 +4097,32 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3164
4097
  holdBase: turn.holdBase,
3165
4098
  depths: turn.depths,
3166
4099
  achieved: turn.achieved,
3167
- clamped: turn.clamped
4100
+ clamped: turn.clamped,
4101
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3168
4102
  });
3169
4103
  }
3170
- const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
3171
- const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
3172
- const parallaxUnit = headTurnParallaxUnit(faceRadius);
3173
- const parallaxUnitY = headNodParallaxUnit(halfH);
3174
- const faceBottom = faceTransform.y - faceCropH / 2;
4104
+ const latticeReach = Math.max(
4105
+ faceCenterX - lattice.points[0],
4106
+ lattice.points[lattice.cols * 2] - faceCenterX
4107
+ );
4108
+ const surface = turn?.surface ?? turnSurface({
4109
+ faceCenterX,
4110
+ faceCenterY,
4111
+ radius: latticeReach * HEAD_CYLINDER_RADIUS_FACTOR,
4112
+ travel: headTurnTravel(faceHalfWidth),
4113
+ nodRadius,
4114
+ lattice,
4115
+ profile,
4116
+ eyeRowY
4117
+ });
4118
+ const parallaxUnit = headTurnParallaxUnit(surface.radius);
4119
+ const plateLandingAt = plateLandingOn(plate, rowMapsOf(surface));
4120
+ const faceBottom = faceCenterY - faceCropH / 2;
3175
4121
  const neckPivot = {
3176
4122
  x: faceCenterX,
3177
4123
  y: faceBottom - faceCropH * 0.15
3178
4124
  // 15% below face bottom = neck
3179
4125
  };
3180
- const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
3181
- faceGrid,
3182
- import_format3.StandardParameter.AngleX,
3183
- import_format3.StandardParameter.AngleY,
3184
- faceCenterX,
3185
- faceCenterY,
3186
- faceRadius,
3187
- headTravel
3188
- );
3189
4126
  const deformers = [
3190
4127
  // headDeformer: rigid matrix rotating/translating the whole head about the
3191
4128
  // neck pivot. Same bindings as sample-model.ts apart from the turn, which
@@ -3195,14 +4132,14 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3195
4132
  pivot: neckPivot,
3196
4133
  bindings: [
3197
4134
  // No AngleX binding: the turn moves NOTHING rigidly. Its sideways
3198
- // travel is baked into faceWarp's own grid (see
3199
- // bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
3200
- // a silhouette the bangs hold; a translate here would take the hair
3201
- // shell with it. Nor is the turn a roll — the sample model's ±6° "lean
3202
- // into the turn" was tried here and dropped, since rotating about the
3203
- // neck pivot swings the crown (~500px above it) far more than the chin,
3204
- // so the top of the head appeared to lunge ahead of the face on every
3205
- // turn. Roll is AngleZ's job, below.
4135
+ // travel is baked into every group grid (the surface's `travel`, see
4136
+ // bakeTurnGroupWarp2D) so the face slides inside a silhouette the
4137
+ // bangs hold; a translate here would take the hair shell with it. Nor
4138
+ // is the turn a roll — the sample model's ±6° "lean into the turn" was
4139
+ // tried here and dropped, since rotating about the neck pivot swings
4140
+ // the crown (~500px above it) far more than the chin, so the top of
4141
+ // the head appeared to lunge ahead of the face on every turn. Roll is
4142
+ // AngleZ's job, below.
3206
4143
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
3207
4144
  // rigid rotation would sum with the AngleZ roll below at diagonal
3208
4145
  // poses, collapsing pitch into roll.
@@ -3231,19 +4168,27 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3231
4168
  to: HEAD_BREATH_BOB
3232
4169
  }
3233
4170
  ]
3234
- },
3235
- // faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
3236
- // about faceCenterX so the bake's cylinder axis aligns with the face center.
3237
- // One 2D warp carries both the turn and the nod (a deformer holds either
3238
- // `warps` or `warp2d`, never both).
3239
- {
3240
- kind: "warp",
3241
- id: "faceWarp",
3242
- parent: "headDeformer",
3243
- grid: faceGrid,
3244
- warp2d: faceWarp2d
3245
4171
  }
3246
4172
  ];
4173
+ for (const [group, grid] of groupGrids) {
4174
+ const { role } = members.find((m) => m.group === group);
4175
+ const family = turnFamily(role);
4176
+ const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
4177
+ deformers.push({
4178
+ kind: "warp",
4179
+ id: group,
4180
+ parent: "headDeformer",
4181
+ grid,
4182
+ warp2d: bakeTurnGroupWarp2D(
4183
+ grid,
4184
+ import_format3.StandardParameter.AngleX,
4185
+ import_format3.StandardParameter.AngleY,
4186
+ surface,
4187
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4188
+ carriers.get(role).anchor
4189
+ )
4190
+ });
4191
+ }
3247
4192
  if (bodyLayer) {
3248
4193
  const bt = bboxToTransform(
3249
4194
  bodyLayer.bbox,
@@ -3260,8 +4205,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3260
4205
  {
3261
4206
  parameter: import_format3.StandardParameter.AngleX,
3262
4207
  channel: "translateX",
3263
- from: -BODY_TURN_FOLLOW * headTravel,
3264
- to: BODY_TURN_FOLLOW * headTravel
4208
+ from: -BODY_TURN_FOLLOW * surface.travel,
4209
+ to: BODY_TURN_FOLLOW * surface.travel
3265
4210
  },
3266
4211
  // Breath: follow the head's bob, same direction, half amplitude.
3267
4212
  {
@@ -3273,34 +4218,21 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3273
4218
  ]
3274
4219
  });
3275
4220
  }
3276
- const eyeCreaseBySide = {};
3277
- for (const layer of layers) {
3278
- const side = ROLE_TABLE[layer.role].eyeSide;
3279
- if ((layer.role === "eye_L" || layer.role === "eye_R") && side) {
3280
- const ey = bboxToTransform(
3281
- layer.bbox,
3282
- layer.canvasW,
3283
- layer.canvasH,
3284
- layer.role
3285
- ).y;
3286
- eyeCreaseBySide[side] = ey - EYELID_FOLD_CREASE * layer.cropH;
3287
- }
3288
- }
4221
+ const memberByRole = new Map(members.map((m) => [m.role, m]));
3289
4222
  const parts = layers.map((layer) => {
3290
4223
  const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
3291
4224
  const spec = ROLE_TABLE[role];
3292
4225
  const t = bboxToTransform(bbox, canvasW, canvasH, role);
3293
4226
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
3294
4227
  hasMouthOpen,
3295
- parallaxUnit,
3296
4228
  parallaxUnitY,
3297
- hasNose,
3298
- turnDepths: turn?.depths
4229
+ hasNose
3299
4230
  });
3300
4231
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
3301
4232
  if (spec.mesh) {
4233
+ const member = memberByRole.get(role);
3302
4234
  const { cols, rows } = meshCellsFor(cropW, cropH);
3303
- const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
4235
+ const mesh = member?.mesh ?? createPixelGridMesh(cols, rows, cropW, cropH);
3304
4236
  const part = {
3305
4237
  id: role,
3306
4238
  color: [1, 1, 1, 1],
@@ -3315,35 +4247,38 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3315
4247
  part.bindings = roleBindings;
3316
4248
  }
3317
4249
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
3318
- let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
3319
- let leadWarp;
3320
- let silhouetteWarp;
4250
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
4251
+ const ownWarps = [];
3321
4252
  if (role === "hair_front") {
3322
- const shift = HAIR_FRONT_DEPTH * parallaxUnit;
3323
- const headroom = Math.min(
3324
- faceGridMaxX - (t.x + cropW / 2),
3325
- t.x - cropW / 2 - faceGridMinX
3326
- ) - shift;
3327
- tipShift = Math.min(
3328
- tipShift,
3329
- Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
4253
+ ownWarps.push(
4254
+ bakeHairSwayWarp(
4255
+ mesh,
4256
+ import_format3.StandardParameter.AngleX,
4257
+ HAIR_FRONT_DEPTH * parallaxUnit,
4258
+ HEAD_TURN_MAX_DEG
4259
+ )
3330
4260
  );
3331
- leadWarp = bakeHairSwayWarp(
3332
- mesh,
3333
- import_format3.StandardParameter.AngleX,
3334
- shift,
3335
- HEAD_TURN_MAX_DEG
4261
+ const holdBase = turn?.holdBase ?? plateReach(plateLandingAt, faceCenterX, edgeAt, plateGuardRows) + HOLD_CLEARANCE;
4262
+ ownWarps.push(
4263
+ bakeHairFrontSilhouetteWarp(
4264
+ mesh,
4265
+ t.x,
4266
+ t.y,
4267
+ faceCenterX,
4268
+ plateLandingAt,
4269
+ edgeAt,
4270
+ holdBase,
4271
+ turn?.holdEdgeAt ?? (() => holdBase),
4272
+ plateGuardRows
4273
+ )
3336
4274
  );
3337
- const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
3338
- const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
3339
- silhouetteWarp = bakeHairFrontSilhouetteWarp(
3340
- mesh,
3341
- t.x,
3342
- faceCenterX,
3343
- faceHalfWidth,
3344
- holdBase,
3345
- turn?.holdEdgeAt ?? (() => holdBase),
3346
- columnMapAt
4275
+ ownWarps.push(
4276
+ bakeNodWarp(
4277
+ mesh,
4278
+ t.y,
4279
+ surface,
4280
+ hasNose ? HAIR_FRONT_NOD_DEPTH * parallaxUnitY : 0
4281
+ )
3347
4282
  );
3348
4283
  }
3349
4284
  part.warps = [
@@ -3359,24 +4294,12 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3359
4294
  tipShift,
3360
4295
  HAIR_SWAY_RANGE
3361
4296
  ),
3362
- ...leadWarp ? [leadWarp] : [],
3363
- ...silhouetteWarp ? [silhouetteWarp] : []
4297
+ ...ownWarps
3364
4298
  ];
3365
4299
  }
3366
4300
  if (spec.eyeSide !== void 0) {
3367
- const isLash = role.startsWith("lash_");
3368
- if (role.startsWith("eye_") || isLash) {
3369
- const openParam = spec.eyeSide === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight;
3370
- const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
3371
- const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
3372
- part.warps = [
3373
- bakeEyelidFoldWarp(
3374
- mesh,
3375
- openParam,
3376
- creaseWorldY - t.y,
3377
- isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
3378
- )
3379
- ];
4301
+ if (role.startsWith("eye_") || role.startsWith("lash_")) {
4302
+ part.warps = [...member.warps];
3380
4303
  } else {
3381
4304
  part.clip = { masks: [`eye_${spec.eyeSide}`] };
3382
4305
  }