@ikijs/editor 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1605,9 +1605,10 @@ import {
1605
1605
  } from "@ikijs/format";
1606
1606
  var ROLE_TABLE = {
1607
1607
  // The silhouette behind the face. It rides the rigid head (not faceWarp,
1608
- // whose grid it would stretch across the shoulders) as a MESH: it bends on
1609
- // the turn through its own part warp (bakeHairBackTurnWarp) and swings its
1610
- // ends on the hair-sway warps.
1608
+ // whose grid it would stretch across the shoulders) as a MESH, so its ends
1609
+ // can swing on the hair-sway warps. It holds the head's outline STILL
1610
+ // through the turn — the head it hangs off no longer travels and it has no
1611
+ // turn warp of its own — while the face slides inside it.
1611
1612
  hair_back: { deformer: "headDeformer", order: 0, mesh: true },
1612
1613
  // The torso. It rides its own rigid deformer, not the head's: the head turns
1613
1614
  // about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
@@ -1789,8 +1790,8 @@ var FACE_GRID_CELLS = 6;
1789
1790
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1790
1791
  var HEAD_TURN_MAX_DEG = 30;
1791
1792
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1792
- function headTurnParallaxUnit(gridHalfWidth) {
1793
- return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1793
+ function headTurnParallaxUnit(radius) {
1794
+ return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1794
1795
  }
1795
1796
  function gridReach(grid, axis, center) {
1796
1797
  let reach = 0;
@@ -1803,9 +1804,16 @@ function pinnedCylinderBend(local, radius, theta) {
1803
1804
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1804
1805
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
1805
1806
  }
1806
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
1807
+ function boundedCylinderBend(local, radius, theta) {
1808
+ const bound = radius / HEAD_CYLINDER_RADIUS_FACTOR;
1809
+ const onSurface = Math.max(-bound, Math.min(bound, local));
1810
+ return pinnedCylinderBend(onSurface, radius, theta);
1811
+ }
1812
+ function turnSlide(travel, angleX) {
1813
+ return travel * angleX / HEAD_TURN_MAX_DEG;
1814
+ }
1815
+ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
1807
1816
  const STOPS = [...HEAD_TURN_STOPS];
1808
- const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
1809
1817
  const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1810
1818
  const pointCount = grid.points.length / 2;
1811
1819
  const DEG_TO_RAD = Math.PI / 180;
@@ -1814,10 +1822,11 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
1814
1822
  const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1815
1823
  for (const angleX of STOPS) {
1816
1824
  const thetaX = angleX * DEG_TO_RAD;
1825
+ const slide = turnSlide(travel, angleX);
1817
1826
  const offsets = [];
1818
1827
  for (let i = 0; i < pointCount; i++) {
1819
1828
  offsets.push(
1820
- pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
1829
+ boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
1821
1830
  pinnedCylinderBend(
1822
1831
  grid.points[i * 2 + 1] - centerY,
1823
1832
  RADIUS_Y,
@@ -1836,13 +1845,811 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
1836
1845
  keyforms2d
1837
1846
  };
1838
1847
  }
1848
+ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1849
+ if (Math.abs(angleX) > HEAD_TURN_MAX_DEG) {
1850
+ throw new Error(
1851
+ `auto-rig: turnColumnMap: angleX ${angleX}\xB0 is outside the turn's \xB1${HEAD_TURN_MAX_DEG}\xB0 range`
1852
+ );
1853
+ }
1854
+ const theta = angleX * (Math.PI / 180);
1855
+ const slide = turnSlide(travel, angleX);
1856
+ const restX = [];
1857
+ const warpedX = [];
1858
+ for (let col = 0; col <= grid.cols; col++) {
1859
+ const x = grid.points[col * 2];
1860
+ restX.push(x);
1861
+ warpedX.push(
1862
+ x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
1863
+ );
1864
+ }
1865
+ const cellFor = (v, edges) => {
1866
+ for (let c = 0; c < grid.cols; c++) {
1867
+ if (v < edges[c + 1]) return c;
1868
+ }
1869
+ return grid.cols - 1;
1870
+ };
1871
+ return {
1872
+ restX,
1873
+ warpedX,
1874
+ mapX(x) {
1875
+ const c = cellFor(x, restX);
1876
+ const s = Math.max(
1877
+ 0,
1878
+ Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
1879
+ );
1880
+ return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
1881
+ },
1882
+ invertX(X) {
1883
+ const c = cellFor(X, warpedX);
1884
+ const s = Math.max(
1885
+ 0,
1886
+ Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
1887
+ );
1888
+ return restX[c] + (restX[c + 1] - restX[c]) * s;
1889
+ }
1890
+ };
1891
+ }
1892
+ var TurnTargetError = class extends Error {
1893
+ constructor(message) {
1894
+ super(message);
1895
+ this.name = "TurnTargetError";
1896
+ }
1897
+ };
1898
+ var DEFAULT_TURN_TARGETS = {
1899
+ eyeShift: 0.22,
1900
+ farEyeRatio: 0.67,
1901
+ silhouetteRatio: 1
1902
+ };
1903
+ var NOSE_SHIFT_SHARE = 1.36;
1904
+ var MOUTH_SHIFT_SHARE = 1.18;
1905
+ var TURN_RATIO_MAX = 1.5;
1906
+ var SILHOUETTE_RATIO_MIN = 0.5;
1907
+ function resolveTurnTargets(targets = {}) {
1908
+ const eyeShift = targets.eyeShift ?? DEFAULT_TURN_TARGETS.eyeShift;
1909
+ const check = (field, value, ok, expected) => {
1910
+ if (value === void 0) return;
1911
+ if (!Number.isFinite(value) || !ok(value)) {
1912
+ const derived = (field === "noseShift" || field === "mouthShift") && targets[field] === void 0;
1913
+ const from = derived ? `, derived from turnTargets.eyeShift (${eyeShift}),` : "";
1914
+ throw new TurnTargetError(
1915
+ `auto-rig: turnTargets.${field} (${value})${from} must be ${expected}`
1916
+ );
1917
+ }
1918
+ };
1919
+ const farEyeRatio = targets.farEyeRatio ?? DEFAULT_TURN_TARGETS.farEyeRatio;
1920
+ const silhouetteRatio = targets.silhouetteRatio ?? DEFAULT_TURN_TARGETS.silhouetteRatio;
1921
+ const noseShift = targets.noseShift ?? NOSE_SHIFT_SHARE * eyeShift;
1922
+ const mouthShift = targets.mouthShift ?? MOUTH_SHIFT_SHARE * eyeShift;
1923
+ const isShift = (v) => Math.abs(v) <= 1;
1924
+ const shiftRange = "a fraction of the head half-width, |value| <= 1";
1925
+ check("eyeShift", eyeShift, isShift, shiftRange);
1926
+ check(
1927
+ "farEyeRatio",
1928
+ farEyeRatio,
1929
+ (v) => v > 0 && v <= TURN_RATIO_MAX,
1930
+ `in (0, ${TURN_RATIO_MAX}]`
1931
+ );
1932
+ check(
1933
+ "silhouetteRatio",
1934
+ silhouetteRatio,
1935
+ (v) => v >= SILHOUETTE_RATIO_MIN && v <= TURN_RATIO_MAX,
1936
+ `in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
1937
+ );
1938
+ check("noseShift", noseShift, isShift, shiftRange);
1939
+ check("mouthShift", mouthShift, isShift, shiftRange);
1940
+ check("headHalfWidth", targets.headHalfWidth, (v) => v > 0, "positive");
1941
+ const defaulted = new Set(
1942
+ [
1943
+ "eyeShift",
1944
+ "farEyeRatio",
1945
+ "silhouetteRatio",
1946
+ "noseShift",
1947
+ "mouthShift"
1948
+ ].filter((field) => targets[field] === void 0)
1949
+ );
1950
+ return {
1951
+ eyeShift,
1952
+ farEyeRatio,
1953
+ silhouetteRatio,
1954
+ noseShift,
1955
+ mouthShift,
1956
+ headHalfWidth: targets.headHalfWidth,
1957
+ defaulted
1958
+ };
1959
+ }
1960
+ function turnLandmarks(layers) {
1961
+ const markOf = (role) => {
1962
+ const layer = layers.find((l) => l.role === role);
1963
+ if (!layer) return void 0;
1964
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1965
+ return { x: t.x, y: t.y, w: layer.cropW };
1966
+ };
1967
+ const pairOf = (left, right) => {
1968
+ const l = markOf(left);
1969
+ const r = markOf(right);
1970
+ return l && r ? [l, r] : void 0;
1971
+ };
1972
+ const one = (role) => {
1973
+ const m = markOf(role);
1974
+ return m ? [m] : [];
1975
+ };
1976
+ return {
1977
+ eye: pairOf("eye_L", "eye_R") ?? [],
1978
+ nose: one("nose"),
1979
+ mouth: one("mouth")
1980
+ };
1981
+ }
1982
+ var TURN_BISECT_STEPS = 40;
1983
+ var TURN_DEPTH_EPS = 1e-6;
1984
+ function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
1985
+ if (landmarks.length === 0) {
1986
+ throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
1987
+ }
1988
+ const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
1989
+ const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
1990
+ const cap = Math.max(0, (farEdge - floor) / unit);
1991
+ const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
1992
+ const atCap = achieved(cap);
1993
+ const atRest = achieved(0);
1994
+ const attainable = [atCap, atRest];
1995
+ if (target > atRest + TURN_DEPTH_EPS)
1996
+ return { reached: false, depth: 0, achieved: atRest, attainable };
1997
+ if (target < atCap - TURN_DEPTH_EPS)
1998
+ return { reached: false, depth: cap, achieved: atCap, attainable };
1999
+ const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2000
+ let lo = 0;
2001
+ let hi = cap;
2002
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2003
+ const mid = (lo + hi) / 2;
2004
+ if (achieved(mid) > clampedTarget) lo = mid;
2005
+ else hi = mid;
2006
+ }
2007
+ const depth = (lo + hi) / 2;
2008
+ return { reached: true, depth, achieved: achieved(depth), attainable };
2009
+ }
2010
+ var TURN_SWEEP_MAX_FACTOR = 8;
2011
+ var TURN_SWEEP_SAMPLES = 32;
2012
+ var HOLD_CLEARANCE = 1;
2013
+ var TURN_HOLD_SAMPLES = 16;
2014
+ var TURN_HOLD_SATURATION_PASSES = 4;
2015
+ function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2016
+ const local = x - faceCenterX;
2017
+ const dist = Math.abs(local);
2018
+ const side = Math.sign(local);
2019
+ const sideIdx = side < 0 ? 0 : 1;
2020
+ if (dist <= faceHalfWidth) return map.mapX(x);
2021
+ if (dist <= holdBase) {
2022
+ const inner = map.mapX(faceCenterX + side * faceHalfWidth);
2023
+ const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
2024
+ return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
2025
+ }
2026
+ return x + side * (holdEdge[sideIdx] - holdBase);
2027
+ }
2028
+ function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2029
+ const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2030
+ const preBindAt = (col2) => {
2031
+ const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
2032
+ const target = hairFrontHoldTarget(
2033
+ colX,
2034
+ faceCenterX,
2035
+ faceHalfWidth,
2036
+ holdBase,
2037
+ cappedHoldEdge,
2038
+ map
2039
+ );
2040
+ return map.invertX(target);
2041
+ };
2042
+ const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
2043
+ const bracket = (v, cells) => {
2044
+ const lo = Math.max(0, Math.min(cells, Math.floor(v)));
2045
+ return {
2046
+ lo,
2047
+ hi: Math.max(0, Math.min(cells, lo + 1)),
2048
+ t: Math.max(0, Math.min(1, v - lo))
2049
+ };
2050
+ };
2051
+ const col = bracket(
2052
+ (x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
2053
+ cols
2054
+ );
2055
+ const row = bracket(leadRow * rows, rows);
2056
+ const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
2057
+ const tl = landingAt(col.lo, row.lo);
2058
+ const br = landingAt(col.hi, row.hi);
2059
+ if (col.t <= row.t) {
2060
+ const bl = landingAt(col.lo, row.hi);
2061
+ return tl + row.t * (bl - tl) + col.t * (br - bl);
2062
+ }
2063
+ const tr = landingAt(col.hi, row.lo);
2064
+ return tl + col.t * (tr - tl) + row.t * (br - tr);
2065
+ }
2066
+ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
2067
+ let cap = Infinity;
2068
+ for (const deg of HEAD_TURN_STOPS) {
2069
+ if (deg === 0) continue;
2070
+ const map = bendOnlyMapAt(deg);
2071
+ const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
2072
+ const towardReach = Math.max(
2073
+ ...[-1, 1].map(
2074
+ (side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
2075
+ )
2076
+ );
2077
+ cap = Math.min(
2078
+ cap,
2079
+ Math.max(0, holdEdgeAt(deg, slideSign) - HOLD_CLEARANCE - towardReach) * HEAD_TURN_MAX_DEG / Math.abs(deg)
2080
+ );
2081
+ }
2082
+ return cap;
2083
+ }
2084
+ function evaluateTurnCandidate(ctx, radius) {
2085
+ const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
2086
+ const measuredEdgeAt = (side) => {
2087
+ const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2088
+ if (candidates === void 0 || candidates.length === 0) return void 0;
2089
+ const xs = candidates.map((c) => c.x);
2090
+ return side < 0 ? Math.min(...xs) : Math.max(...xs);
2091
+ };
2092
+ const shellHalfWidthAt = (side) => {
2093
+ const edge = measuredEdgeAt(side);
2094
+ return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2095
+ };
2096
+ const unit = headTurnParallaxUnit(radius);
2097
+ const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2098
+ const leadRow = ctx.hairFrontLeadFraction ?? 0;
2099
+ const evaluateHold = (ratio) => {
2100
+ const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2101
+ const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2102
+ ctx.travel,
2103
+ shellTravelCap(
2104
+ ctx.faceCenterX,
2105
+ ctx.faceHalfWidth,
2106
+ ctx.travel,
2107
+ bendOnlyMapAt,
2108
+ (deg, side) => {
2109
+ const rest = shellHalfWidthAt(side);
2110
+ return Math.min(rest, holdEdgeFrom(rest)(deg));
2111
+ }
2112
+ )
2113
+ );
2114
+ const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
2115
+ const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
2116
+ const holdEdgeAt2 = holdEdgeFrom(holdBase2);
2117
+ let foldLowerBound = -Infinity;
2118
+ let folds = false;
2119
+ for (const deg of HEAD_TURN_STOPS) {
2120
+ if (deg === 0) continue;
2121
+ const reach = plateReachAt(
2122
+ columnMapAt(deg),
2123
+ ctx.faceCenterX,
2124
+ ctx.faceHalfWidth
2125
+ );
2126
+ foldLowerBound = Math.max(
2127
+ foldLowerBound,
2128
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2129
+ );
2130
+ if (holdEdgeAt2(deg) <= reach) folds = true;
2131
+ }
2132
+ const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
2133
+ const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
2134
+ const gridReach2 = [
2135
+ Math.abs(map2.warpedX[0] - ctx.faceCenterX),
2136
+ Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
2137
+ ];
2138
+ const cappedHoldEdge = [
2139
+ Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
2140
+ Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
2141
+ ];
2142
+ const restAt = (side) => {
2143
+ const edge = measuredEdgeAt(side);
2144
+ if (edge !== void 0) return edge;
2145
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2146
+ };
2147
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2148
+ x,
2149
+ ctx.hairFrontSilhouette,
2150
+ ctx.faceCenterX,
2151
+ ctx.faceHalfWidth,
2152
+ holdBase2,
2153
+ cappedHoldEdge,
2154
+ map2,
2155
+ leadTipShift,
2156
+ leadRow
2157
+ );
2158
+ const landingOfRole = (role, x) => {
2159
+ if (role === "hair_front") return hairFrontAt(x);
2160
+ if (role === "hair_back") {
2161
+ return x;
2162
+ }
2163
+ if (role === "body") {
2164
+ return x - BODY_TURN_FOLLOW * travel2;
2165
+ }
2166
+ if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2167
+ return map2.mapX(x);
2168
+ }
2169
+ throw new Error(
2170
+ `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
2171
+ );
2172
+ };
2173
+ const landingAt = (side) => {
2174
+ const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2175
+ if (candidates === void 0 || candidates.length === 0) {
2176
+ return hairFrontAt(restAt(side));
2177
+ }
2178
+ const landings = candidates.map((c) => landingOfRole(c.role, c.x));
2179
+ return side < 0 ? Math.min(...landings) : Math.max(...landings);
2180
+ };
2181
+ return {
2182
+ ratio,
2183
+ travel: travel2,
2184
+ map: map2,
2185
+ holdBase: holdBase2,
2186
+ holdEdgeAt: holdEdgeAt2,
2187
+ folds,
2188
+ foldLowerBound,
2189
+ gridReach: gridReach2,
2190
+ // How far the silhouette's own centre drifts off the face centre at full
2191
+ // turn, against how far it already sat off centre at rest (zero unless
2192
+ // hair_front's crop is itself off-centre) — the held shell keeps its own
2193
+ // place while the face slides inside it, so this is the drift the hold
2194
+ // and the bangs' lead leave behind, and the slide itself shows up in the
2195
+ // eye cue instead of cancelling out of it.
2196
+ // `measure_turn_reference` reads the eye pair against THIS moved centre,
2197
+ // not the face centre, so it is what the eye cue is corrected by.
2198
+ silhouetteCenterShift: (landingAt(1) + landingAt(-1)) / 2 - (restAt(1) + restAt(-1)) / 2,
2199
+ // What a render actually shows: the real landing span over the real rest
2200
+ // span, the same pair silhouetteCenterShift reads — the measure the
2201
+ // hold is FITTED against, see this function's own doc.
2202
+ renderedSilhouetteRatio: (landingAt(1) - landingAt(-1)) / (restAt(1) - restAt(-1)),
2203
+ restSpan: restAt(1) - restAt(-1)
2204
+ };
2205
+ };
2206
+ const saturatedHold = () => {
2207
+ let hold2 = evaluateHold(1);
2208
+ for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
2209
+ const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
2210
+ if (needed <= hold2.ratio) break;
2211
+ hold2 = evaluateHold(needed);
2212
+ }
2213
+ return hold2;
2214
+ };
2215
+ const holdRange = () => {
2216
+ const ceiling = saturatedHold();
2217
+ if (ceiling.folds) return void 0;
2218
+ let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2219
+ if (floor.folds) {
2220
+ let lo = floor;
2221
+ let hi = ceiling;
2222
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2223
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2224
+ if (mid.folds) lo = mid;
2225
+ else hi = mid;
2226
+ }
2227
+ floor = hi;
2228
+ }
2229
+ const samples = [floor];
2230
+ for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2231
+ const sample = evaluateHold(
2232
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2233
+ );
2234
+ if (!sample.folds) samples.push(sample);
2235
+ }
2236
+ samples.push(ceiling);
2237
+ return samples;
2238
+ };
2239
+ const silhouetteMiss = (span) => {
2240
+ const lower = Math.max(span?.[0] ?? Infinity, SILHOUETTE_RATIO_MIN);
2241
+ const upper = Math.min(span?.[1] ?? -Infinity, TURN_RATIO_MAX);
2242
+ return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2243
+ };
2244
+ const asked = ctx.silhouetteRatio;
2245
+ const natural = evaluateHold(asked);
2246
+ const eps = TURN_DEPTH_EPS / natural.restSpan;
2247
+ let hold;
2248
+ let silhouetteClamped = false;
2249
+ if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2250
+ hold = natural;
2251
+ } else {
2252
+ const samples = holdRange();
2253
+ if (samples === void 0) return silhouetteMiss();
2254
+ const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2255
+ const span = [
2256
+ Math.min(...rendered),
2257
+ Math.max(...rendered)
2258
+ ];
2259
+ const nearer = (a, b) => {
2260
+ const ea = Math.abs(a.renderedSilhouetteRatio - asked);
2261
+ const eb = Math.abs(b.renderedSilhouetteRatio - asked);
2262
+ if (ea <= eps && eb <= eps) {
2263
+ return Math.abs(a.ratio - asked) <= Math.abs(b.ratio - asked) ? a : b;
2264
+ }
2265
+ return ea <= eb ? a : b;
2266
+ };
2267
+ if (asked < span[0] - eps || asked > span[1] + eps) {
2268
+ if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2269
+ const wanted = asked < span[0] ? span[0] : span[1];
2270
+ hold = samples.reduce(
2271
+ (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2272
+ );
2273
+ silhouetteClamped = true;
2274
+ } else {
2275
+ let best = samples.reduce(nearer);
2276
+ let lo;
2277
+ let hi;
2278
+ for (let i = 0; i + 1 < samples.length; i++) {
2279
+ const a = samples[i].renderedSilhouetteRatio - asked;
2280
+ const b = samples[i + 1].renderedSilhouetteRatio - asked;
2281
+ if (a * b <= 0) {
2282
+ lo = samples[i];
2283
+ hi = samples[i + 1];
2284
+ break;
2285
+ }
2286
+ }
2287
+ for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2288
+ if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2289
+ break;
2290
+ }
2291
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2292
+ if (!mid.folds) best = nearer(best, mid);
2293
+ if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2294
+ lo = mid;
2295
+ } else {
2296
+ hi = mid;
2297
+ }
2298
+ }
2299
+ hold = best;
2300
+ }
2301
+ }
2302
+ if (hold.folds) {
2303
+ throw new Error(
2304
+ "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2305
+ );
2306
+ }
2307
+ const {
2308
+ travel,
2309
+ map,
2310
+ holdBase,
2311
+ holdEdgeAt,
2312
+ silhouetteCenterShift,
2313
+ renderedSilhouetteRatio
2314
+ } = hold;
2315
+ const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
2316
+ const eye = solveTurnDepthSigned(
2317
+ silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2318
+ ctx.landmarks.eye,
2319
+ unit,
2320
+ map,
2321
+ holdEdgeX,
2322
+ ctx.plateEdgeX
2323
+ );
2324
+ if (!eye.reached && !ctx.clampEyeShift) {
2325
+ return {
2326
+ blocked: "eyeShift",
2327
+ // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
2328
+ // negative lower bound here would advertise a value the field's own
2329
+ // contract already rules out; 0 is the true floor.
2330
+ offeredShift: [
2331
+ Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2332
+ (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2333
+ ]
2334
+ };
2335
+ }
2336
+ const scaleOf = (l) => (map.mapX(l.x + l.w / 2 - eye.depth * unit) - map.mapX(l.x - l.w / 2 - eye.depth * unit)) / l.w;
2337
+ const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2338
+ const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2339
+ return {
2340
+ radius,
2341
+ ratio: scaleOf(far) / scaleOf(near),
2342
+ unit,
2343
+ travel,
2344
+ map,
2345
+ holdBase,
2346
+ holdEdgeAt,
2347
+ holdEdgeX,
2348
+ eye,
2349
+ silhouetteClamped,
2350
+ silhouetteCenterShift,
2351
+ renderedSilhouetteRatio
2352
+ };
2353
+ }
2354
+ function sweepTurnRadii(ctx) {
2355
+ const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
2356
+ const maxRadius = ctx.faceHalfWidth * TURN_SWEEP_MAX_FACTOR;
2357
+ const candidates = [];
2358
+ let heldRatioLower = Infinity;
2359
+ let heldRatioUpper = -Infinity;
2360
+ let offeredShift;
2361
+ for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2362
+ const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2363
+ const result = evaluateTurnCandidate(ctx, radius);
2364
+ if (!("blocked" in result)) {
2365
+ candidates.push({ ...result, sweepIndex: i });
2366
+ } else if (result.blocked === "silhouetteRatio") {
2367
+ heldRatioLower = Math.min(heldRatioLower, result.lower);
2368
+ heldRatioUpper = Math.max(heldRatioUpper, result.upper);
2369
+ } else {
2370
+ offeredShift = offeredShift ? [
2371
+ Math.min(offeredShift[0], result.offeredShift[0]),
2372
+ Math.max(offeredShift[1], result.offeredShift[1])
2373
+ ] : result.offeredShift;
2374
+ }
2375
+ }
2376
+ return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
2377
+ }
2378
+ function ratioClusters(candidates) {
2379
+ const clusters = [];
2380
+ let run = [];
2381
+ const close = () => {
2382
+ if (run.length === 0) return;
2383
+ const ratios = run.map((c) => c.ratio);
2384
+ clusters.push({
2385
+ candidates: run,
2386
+ ratios: [Math.min(...ratios), Math.max(...ratios)]
2387
+ });
2388
+ run = [];
2389
+ };
2390
+ for (const candidate of candidates) {
2391
+ const previous = run[run.length - 1];
2392
+ if (previous !== void 0 && candidate.sweepIndex !== previous.sweepIndex + 1) {
2393
+ close();
2394
+ }
2395
+ run.push(candidate);
2396
+ }
2397
+ close();
2398
+ return clusters;
2399
+ }
2400
+ function fitTurnRadius(ctx, candidates, target) {
2401
+ let lo = candidates[0];
2402
+ let hi = candidates[candidates.length - 1];
2403
+ for (let i = candidates.length - 2; i >= 0; i--) {
2404
+ if ((candidates[i].ratio - target) * (candidates[i + 1].ratio - target) <= 0) {
2405
+ lo = candidates[i];
2406
+ hi = candidates[i + 1];
2407
+ break;
2408
+ }
2409
+ }
2410
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2411
+ const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2412
+ if ("blocked" in mid) break;
2413
+ if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2414
+ else lo = mid;
2415
+ }
2416
+ return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
2417
+ }
2418
+ function solveFeatureDepths(ctx, targets, best, eyeShift) {
2419
+ const clamped = [];
2420
+ const solved = {};
2421
+ const families = [
2422
+ ["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
2423
+ ["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
2424
+ ];
2425
+ for (const [field, marks, share] of families) {
2426
+ const m = Math.abs(
2427
+ targets.defaulted.has(field) ? share * eyeShift : targets[field]
2428
+ );
2429
+ const solution = solveTurnDepthSigned(
2430
+ best.silhouetteCenterShift - m * ctx.hh,
2431
+ marks,
2432
+ best.unit,
2433
+ best.map,
2434
+ best.holdEdgeX,
2435
+ ctx.plateEdgeX
2436
+ );
2437
+ if (!solution.reached) {
2438
+ if (!targets.defaulted.has(field)) {
2439
+ return {
2440
+ blocked: field,
2441
+ // noseShift/mouthShift are MAGNITUDES too (see the correction
2442
+ // above), so a negative lower bound here would advertise a value
2443
+ // the field's own contract already rules out; 0 is the true floor.
2444
+ attainable: [
2445
+ Math.max(
2446
+ 0,
2447
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2448
+ ),
2449
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2450
+ ]
2451
+ };
2452
+ }
2453
+ clamped.push(field);
2454
+ }
2455
+ solved[field] = solution.depth;
2456
+ }
2457
+ return {
2458
+ nose: solved.noseShift,
2459
+ mouth: solved.mouthShift,
2460
+ clamped
2461
+ };
2462
+ }
2463
+ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
2464
+ if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2465
+ throw new TurnTargetError(
2466
+ `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2467
+ );
2468
+ }
2469
+ const eyeRowY = landmarks.eye.length === 2 && landmarks.eye[0].y !== void 0 && landmarks.eye[1].y !== void 0 ? (landmarks.eye[0].y + landmarks.eye[1].y) / 2 : void 0;
2470
+ const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
2471
+ 0,
2472
+ Math.min(
2473
+ 1,
2474
+ (hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
2475
+ )
2476
+ );
2477
+ const ctx = {
2478
+ landmarks,
2479
+ faceGrid,
2480
+ faceCenterX,
2481
+ faceHalfWidth,
2482
+ plateEdgeX: faceCenterX - faceHalfWidth,
2483
+ // What this plate asks the turn for; each candidate caps it to its own
2484
+ // shell — see TurnSolveContext.travel.
2485
+ travel: headTurnTravel(faceHalfWidth),
2486
+ headHalfWidth: targets.headHalfWidth,
2487
+ // The pixels the shift fractions are fractions of. Without a measured head
2488
+ // the face plate stands in for it — see TurnTargets.headHalfWidth.
2489
+ hh: targets.headHalfWidth ?? faceHalfWidth,
2490
+ silhouetteRatio: targets.silhouetteRatio,
2491
+ // The sign is ignored (see TurnTargets.eyeShift) — taken here, once, so
2492
+ // every downstream use already has the magnitude.
2493
+ eyeShift: Math.abs(targets.eyeShift),
2494
+ clampEyeShift: targets.defaulted.has("eyeShift"),
2495
+ clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2496
+ hairFrontLeadFraction,
2497
+ hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
2498
+ // A companion to a MEASURED headHalfWidth only — see its own doc.
2499
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
2500
+ };
2501
+ const clamped = [];
2502
+ const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2503
+ const pass = sweepTurnRadii(ctx);
2504
+ if (pass.candidates.length === 0) {
2505
+ return pass.offeredShift ? {
2506
+ unreachable: true,
2507
+ field: "eyeShift",
2508
+ value: targets.eyeShift,
2509
+ attainable: pass.offeredShift
2510
+ } : {
2511
+ unreachable: true,
2512
+ field: "silhouetteRatio",
2513
+ value: targets.silhouetteRatio,
2514
+ // Two different bounds, not one number twice, and computed the
2515
+ // same way regardless of which ratio was actually rejected: both
2516
+ // are ratios a RENDER could have shown (the measure the caller's
2517
+ // own number came from), the narrowest and the widest any radius
2518
+ // could be fitted to, with every ratio between them one it can be
2519
+ // fitted to exactly; see evaluateTurnCandidate, which clips each
2520
+ // radius' own range to the domain `resolveTurnTargets` accepts
2521
+ // before it gets here. Absent when that left every radius with
2522
+ // nothing: no ratio a caller is allowed to ask for renders at all.
2523
+ attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2524
+ };
2525
+ }
2526
+ const clusters = ratioClusters(pass.candidates);
2527
+ const distanceTo = (c) => Math.max(
2528
+ c.ratios[0] - targets.farEyeRatio,
2529
+ targets.farEyeRatio - c.ratios[1],
2530
+ 0
2531
+ );
2532
+ let farEyeRatio = targets.farEyeRatio;
2533
+ let cluster = clusters.find(
2534
+ (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2535
+ );
2536
+ if (cluster === void 0) {
2537
+ cluster = clusters.reduce(
2538
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
2539
+ );
2540
+ if (!targets.defaulted.has("farEyeRatio")) {
2541
+ return {
2542
+ unreachable: true,
2543
+ field: "farEyeRatio",
2544
+ value: targets.farEyeRatio,
2545
+ attainable: cluster.ratios
2546
+ };
2547
+ }
2548
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2549
+ clamped.push("farEyeRatio");
2550
+ }
2551
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2552
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2553
+ if (!targets.defaulted.has("farEyeRatio")) {
2554
+ return {
2555
+ unreachable: true,
2556
+ field: "farEyeRatio",
2557
+ value: targets.farEyeRatio,
2558
+ attainable: cluster.ratios
2559
+ };
2560
+ }
2561
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2562
+ }
2563
+ if (!best.eye.reached) clamped.push("eyeShift");
2564
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2565
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2566
+ const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2567
+ if ("blocked" in features) {
2568
+ return {
2569
+ unreachable: true,
2570
+ field: features.blocked,
2571
+ value: targets[features.blocked],
2572
+ attainable: features.attainable
2573
+ };
2574
+ }
2575
+ return {
2576
+ unreachable: false,
2577
+ radius: best.radius,
2578
+ holdBase: best.holdBase,
2579
+ holdEdgeAt: best.holdEdgeAt,
2580
+ travel: best.travel,
2581
+ depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2582
+ achieved: {
2583
+ eyeShift,
2584
+ farEyeRatio: best.ratio,
2585
+ silhouetteRatio: best.renderedSilhouetteRatio
2586
+ },
2587
+ clamped: [...clamped, ...features.clamped]
2588
+ };
2589
+ }
1839
2590
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
1840
2591
  var HAIR_FRONT_DEPTH = 0.1;
1841
- var HAIR_BACK_DEPTH = -0.08;
1842
2592
  var NOD_TRAVEL = 30;
1843
2593
  var NOD_BEND = 0.5;
1844
- var HAIR_BACK_NOD_DEPTH = -0.07;
2594
+ var HAIR_BACK_NOD_DEPTH = -0.135;
2595
+ function headNodParallaxUnit(gridHalfHeight) {
2596
+ return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
2597
+ }
2598
+ var FEATURE_NOD_DEPTH = {
2599
+ eye: 0.04,
2600
+ iris: 0.04,
2601
+ pupil: 0.04,
2602
+ highlight: 0.04,
2603
+ lash: 0.04,
2604
+ brow: 0.04,
2605
+ blush: 0.04,
2606
+ mouth: 0.04,
2607
+ mouth_open: 0.04,
2608
+ nose: 0.08
2609
+ };
2610
+ var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
2611
+ function roleFamily(role) {
2612
+ return role.replace(/_[LR]$/, "");
2613
+ }
2614
+ function turnFamily(role) {
2615
+ const family = roleFamily(role);
2616
+ if (family === "nose") return "nose";
2617
+ if (family === "mouth" || family === "mouth_open") return "mouth";
2618
+ return "eye";
2619
+ }
2620
+ function featureParallaxBindings(role, options) {
2621
+ if (!options.hasNose) return [];
2622
+ const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2623
+ if (nodDepth === void 0) return [];
2624
+ const bindings = [];
2625
+ const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2626
+ const shiftX = turnDepth * (options.parallaxUnit ?? 0);
2627
+ if (shiftX !== 0) {
2628
+ bindings.push({
2629
+ parameter: StandardParameter.AngleX,
2630
+ channel: "translateX",
2631
+ from: -shiftX,
2632
+ to: shiftX
2633
+ });
2634
+ }
2635
+ const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2636
+ if (shiftY !== 0) {
2637
+ bindings.push({
2638
+ parameter: StandardParameter.AngleY,
2639
+ channel: "translateY",
2640
+ from: -shiftY,
2641
+ to: shiftY
2642
+ });
2643
+ }
2644
+ return bindings;
2645
+ }
1845
2646
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2647
+ return [
2648
+ ...roleOwnBindings(spec, role, cropW, cropH, options),
2649
+ ...featureParallaxBindings(role, options)
2650
+ ];
2651
+ }
2652
+ function roleOwnBindings(spec, role, cropW, cropH, options) {
1846
2653
  const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
1847
2654
  if (isEyeStack && spec.eyeSide !== void 0) {
1848
2655
  const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
@@ -1945,18 +2752,8 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
1945
2752
  if (role === "hair_front" || role === "hair_back") {
1946
2753
  const isFront = role === "hair_front";
1947
2754
  const parallax = [];
1948
- if (!isFront) {
1949
- const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
1950
- if (shiftX !== 0) {
1951
- parallax.push({
1952
- parameter: StandardParameter.AngleX,
1953
- channel: "translateX",
1954
- from: -shiftX,
1955
- to: shiftX
1956
- });
1957
- }
1958
- }
1959
- const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2755
+ const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2756
+ const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
1960
2757
  if (shiftY !== 0) {
1961
2758
  parallax.push({
1962
2759
  parameter: StandardParameter.AngleY,
@@ -1988,35 +2785,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
1988
2785
  ]
1989
2786
  };
1990
2787
  }
1991
- var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
1992
- var HAIR_BACK_FAR_BULGE = 0.22;
1993
- function bakeHairBackTurnWarp(mesh, parameter) {
1994
- let left = Infinity;
1995
- let right = -Infinity;
1996
- for (let i = 0; i < mesh.vertices.length; i += 2) {
1997
- left = Math.min(left, mesh.vertices[i]);
1998
- right = Math.max(right, mesh.vertices[i]);
2788
+ function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
2789
+ let reach = 0;
2790
+ for (const deg of HEAD_TURN_STOPS) {
2791
+ reach = Math.max(
2792
+ reach,
2793
+ plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
2794
+ );
1999
2795
  }
2000
- const halfWidth = (right - left) / 2;
2001
- const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
2002
- const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
2003
- const DEG_TO_RAD = Math.PI / 180;
2796
+ return reach;
2797
+ }
2798
+ function plateReachAt(map, faceCenterX, faceHalfWidth) {
2799
+ let reach = 0;
2800
+ for (const side of [-1, 1]) {
2801
+ const dest = map.mapX(faceCenterX + side * faceHalfWidth);
2802
+ reach = Math.max(reach, Math.abs(dest - faceCenterX));
2803
+ }
2804
+ return reach;
2805
+ }
2806
+ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
2807
+ if (holdEdgeAt(0) !== holdBase) {
2808
+ throw new Error(
2809
+ `auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
2810
+ );
2811
+ }
2812
+ const SIDES = [-1, 1];
2004
2813
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2005
- const theta = deg * DEG_TO_RAD;
2006
- const s = Math.sign(deg);
2007
- const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
2008
- const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
2009
- const bulgeAtStop = bulge * deg / HEAD_TURN_MAX_DEG;
2814
+ const map = columnMapAt(deg);
2815
+ const requestedHoldEdge = holdEdgeAt(deg);
2816
+ const plateDest = SIDES.map(
2817
+ (side) => map.mapX(faceCenterX + side * faceHalfWidth)
2818
+ );
2819
+ const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
2820
+ if (requestedHoldEdge <= reached) {
2821
+ const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
2822
+ throw new Error(
2823
+ `auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${requestedHoldEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
2824
+ );
2825
+ }
2826
+ const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
2827
+ (_side, idx) => Math.min(
2828
+ requestedHoldEdge,
2829
+ Math.abs(
2830
+ map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
2831
+ ) - HOLD_CLEARANCE
2832
+ )
2833
+ );
2010
2834
  const offsets = [];
2011
2835
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2012
- const x = mesh.vertices[i];
2013
- const far = Math.max(0, -s * x / halfWidth);
2014
- const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
2015
- offsets.push(bend - bulgeAtStop * far, 0);
2836
+ const x = partX + mesh.vertices[i];
2837
+ const target = hairFrontHoldTarget(
2838
+ x,
2839
+ faceCenterX,
2840
+ faceHalfWidth,
2841
+ holdBase,
2842
+ holdEdge,
2843
+ map
2844
+ );
2845
+ offsets.push(map.invertX(target) - x, 0);
2016
2846
  }
2017
2847
  return { value: deg, offsets };
2018
2848
  });
2019
- return { parameter, keyforms };
2849
+ return { parameter: StandardParameter.AngleX, keyforms };
2020
2850
  }
2021
2851
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2022
2852
  var HAIR_SWAY_CURL = 1.5;
@@ -2044,14 +2874,18 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2044
2874
  ]
2045
2875
  };
2046
2876
  }
2047
- var HEAD_TURN_TRAVEL = 50;
2877
+ var HEAD_TURN_TRAVEL_RATIO = 0.25;
2878
+ function headTurnTravel(faceHalfWidth) {
2879
+ return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
2880
+ }
2048
2881
  var BODY_TURN_FOLLOW = 0.3;
2049
2882
  var HEAD_BREATH_BOB = -12;
2050
2883
  var BODY_BREATH_FOLLOW = 0.5;
2051
- function generateIkiFromLayerSet(layers, canvas) {
2884
+ function generateIkiFromLayerSet(layers, canvas, options = {}) {
2052
2885
  validateLayerInputs(layers, canvas);
2053
2886
  const hasHair = layers.some((l) => l.role === "hair_front");
2054
2887
  const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2888
+ const hasNose = layers.some((l) => l.role === "nose");
2055
2889
  const bodyLayer = layers.find((l) => l.role === "body");
2056
2890
  const parameters = [
2057
2891
  {
@@ -2229,8 +3063,53 @@ function generateIkiFromLayerSet(layers, canvas) {
2229
3063
  };
2230
3064
  const faceCenterY = faceTransform.y;
2231
3065
  const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
2232
- const parallaxUnit = headTurnParallaxUnit(halfW);
2233
- const parallaxUnitY = headTurnParallaxUnit(halfH);
3066
+ const faceHalfWidth = faceLayer.cropW / 2;
3067
+ const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3068
+ const turn = hasNose ? solveTurnModel(
3069
+ resolveTurnTargets(options.turnTargets),
3070
+ turnLandmarks(layers),
3071
+ faceGrid,
3072
+ faceCenterX,
3073
+ faceHalfWidth,
3074
+ hairFrontLayer && (() => {
3075
+ const t = bboxToTransform(
3076
+ hairFrontLayer.bbox,
3077
+ hairFrontLayer.canvasW,
3078
+ hairFrontLayer.canvasH,
3079
+ "hair_front"
3080
+ );
3081
+ return {
3082
+ x: t.x,
3083
+ centerY: t.y,
3084
+ cropW: hairFrontLayer.cropW,
3085
+ cropH: hairFrontLayer.cropH
3086
+ };
3087
+ })(),
3088
+ options.headEdges
3089
+ ) : void 0;
3090
+ if (turn?.unreachable) {
3091
+ throw new TurnTargetError(
3092
+ turn.attainable === void 0 ? (
3093
+ // Only a silhouette can land here: nothing the field's own accepted
3094
+ // range allows renders on this layer set, so there is no interval to
3095
+ // offer instead — see `silhouetteMiss` in evaluateTurnCandidate.
3096
+ `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set, and so is every ${turn.field} in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
3097
+ ) : `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set (attainable ${turn.attainable[0]}\u2026${turn.attainable[1]})`
3098
+ );
3099
+ }
3100
+ if (turn) {
3101
+ options.onTurnSolved?.({
3102
+ radius: turn.radius,
3103
+ holdBase: turn.holdBase,
3104
+ depths: turn.depths,
3105
+ achieved: turn.achieved,
3106
+ clamped: turn.clamped
3107
+ });
3108
+ }
3109
+ const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
3110
+ const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
3111
+ const parallaxUnit = headTurnParallaxUnit(faceRadius);
3112
+ const parallaxUnitY = headNodParallaxUnit(halfH);
2234
3113
  const faceBottom = faceTransform.y - faceCropH / 2;
2235
3114
  const neckPivot = {
2236
3115
  x: faceCenterX,
@@ -2242,26 +3121,27 @@ function generateIkiFromLayerSet(layers, canvas) {
2242
3121
  StandardParameter.AngleX,
2243
3122
  StandardParameter.AngleY,
2244
3123
  faceCenterX,
2245
- faceCenterY
3124
+ faceCenterY,
3125
+ faceRadius,
3126
+ headTravel
2246
3127
  );
2247
3128
  const deformers = [
2248
3129
  // headDeformer: rigid matrix rotating/translating the whole head about the
2249
- // neck pivot; bindings mirror sample-model.ts exactly.
3130
+ // neck pivot. Same bindings as sample-model.ts apart from the turn, which
3131
+ // moves the face inside the head rather than the head itself (see below).
2250
3132
  {
2251
3133
  id: "headDeformer",
2252
3134
  pivot: neckPivot,
2253
3135
  bindings: [
2254
- // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2255
- // ±6° "lean into the turn" was tried here and dropped — rotating about
2256
- // the neck pivot swings the crown (~500px above it) far more than the
2257
- // chin, so the top of the head appeared to lunge ahead of the face on
2258
- // every turn. Roll is AngleZ's job, below.
2259
- {
2260
- parameter: StandardParameter.AngleX,
2261
- channel: "translateX",
2262
- from: -HEAD_TURN_TRAVEL,
2263
- to: HEAD_TURN_TRAVEL
2264
- },
3136
+ // No AngleX binding: the turn moves NOTHING rigidly. Its sideways
3137
+ // travel is baked into faceWarp's own grid (see
3138
+ // bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
3139
+ // a silhouette the bangs hold; a translate here would take the hair
3140
+ // shell with it. Nor is the turn a roll — the sample model's ±6° "lean
3141
+ // into the turn" was tried here and dropped, since rotating about the
3142
+ // neck pivot swings the crown (~500px above it) far more than the chin,
3143
+ // so the top of the head appeared to lunge ahead of the face on every
3144
+ // turn. Roll is AngleZ's job, below.
2265
3145
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
2266
3146
  // rigid rotation would sum with the AngleZ roll below at diagonal
2267
3147
  // poses, collapsing pitch into roll.
@@ -2314,12 +3194,13 @@ function generateIkiFromLayerSet(layers, canvas) {
2314
3194
  id: "bodyDeformer",
2315
3195
  pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
2316
3196
  bindings: [
2317
- // Low-weight follow of the turn, same direction as the head.
3197
+ // Low-weight follow of the turn, same direction as the face's own
3198
+ // slide — the only rigid part of the travel left.
2318
3199
  {
2319
3200
  parameter: StandardParameter.AngleX,
2320
3201
  channel: "translateX",
2321
- from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
2322
- to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
3202
+ from: -BODY_TURN_FOLLOW * headTravel,
3203
+ to: BODY_TURN_FOLLOW * headTravel
2323
3204
  },
2324
3205
  // Breath: follow the head's bob, same direction, half amplitude.
2325
3206
  {
@@ -2351,7 +3232,9 @@ function generateIkiFromLayerSet(layers, canvas) {
2351
3232
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
2352
3233
  hasMouthOpen,
2353
3234
  parallaxUnit,
2354
- parallaxUnitY
3235
+ parallaxUnitY,
3236
+ hasNose,
3237
+ turnDepths: turn?.depths
2355
3238
  });
2356
3239
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2357
3240
  if (spec.mesh) {
@@ -2373,6 +3256,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2373
3256
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
2374
3257
  let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2375
3258
  let leadWarp;
3259
+ let silhouetteWarp;
2376
3260
  if (role === "hair_front") {
2377
3261
  const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2378
3262
  const headroom = Math.min(
@@ -2389,6 +3273,17 @@ function generateIkiFromLayerSet(layers, canvas) {
2389
3273
  shift,
2390
3274
  HEAD_TURN_MAX_DEG
2391
3275
  );
3276
+ const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
3277
+ const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
3278
+ silhouetteWarp = bakeHairFrontSilhouetteWarp(
3279
+ mesh,
3280
+ t.x,
3281
+ faceCenterX,
3282
+ faceHalfWidth,
3283
+ holdBase,
3284
+ turn?.holdEdgeAt ?? (() => holdBase),
3285
+ columnMapAt
3286
+ );
2392
3287
  }
2393
3288
  part.warps = [
2394
3289
  bakeHairSwayWarp(
@@ -2403,13 +3298,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2403
3298
  tipShift,
2404
3299
  HAIR_SWAY_RANGE
2405
3300
  ),
2406
- ...leadWarp ? [leadWarp] : []
2407
- ];
2408
- }
2409
- if (role === "hair_back") {
2410
- part.warps = [
2411
- ...part.warps ?? [],
2412
- bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
3301
+ ...leadWarp ? [leadWarp] : [],
3302
+ ...silhouetteWarp ? [silhouetteWarp] : []
2413
3303
  ];
2414
3304
  }
2415
3305
  if (spec.eyeSide !== void 0) {
@@ -2489,6 +3379,7 @@ export {
2489
3379
  AddPart,
2490
3380
  AddPhysicsRig,
2491
3381
  CaptureGridKeyform,
3382
+ DEFAULT_TURN_TARGETS,
2492
3383
  DeleteDeformer,
2493
3384
  DeletePart,
2494
3385
  DeletePhysicsRig,
@@ -2508,6 +3399,7 @@ export {
2508
3399
  SetPartTransform,
2509
3400
  SetPartWidth,
2510
3401
  SetPhysicsRig,
3402
+ TurnTargetError,
2511
3403
  UV_INSET_PX,
2512
3404
  captureBindingEndpoint,
2513
3405
  computeGridOffsets,