@ikijs/editor 0.4.0 → 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,16 +1845,757 @@ 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
2594
  var HAIR_BACK_NOD_DEPTH = -0.135;
1845
2595
  function headNodParallaxUnit(gridHalfHeight) {
1846
2596
  return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
1847
2597
  }
1848
- var FEATURE_DEPTH = {
2598
+ var FEATURE_NOD_DEPTH = {
1849
2599
  eye: 0.04,
1850
2600
  iris: 0.04,
1851
2601
  pupil: 0.04,
@@ -1857,16 +2607,23 @@ var FEATURE_DEPTH = {
1857
2607
  mouth_open: 0.04,
1858
2608
  nose: 0.08
1859
2609
  };
1860
- var HAIR_FRONT_NOD_DEPTH = FEATURE_DEPTH.brow;
2610
+ var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
1861
2611
  function roleFamily(role) {
1862
2612
  return role.replace(/_[LR]$/, "");
1863
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
+ }
1864
2620
  function featureParallaxBindings(role, options) {
1865
2621
  if (!options.hasNose) return [];
1866
- const depth = FEATURE_DEPTH[roleFamily(role)];
1867
- if (depth === void 0) return [];
2622
+ const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2623
+ if (nodDepth === void 0) return [];
1868
2624
  const bindings = [];
1869
- const shiftX = depth * (options.parallaxUnit ?? 0);
2625
+ const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2626
+ const shiftX = turnDepth * (options.parallaxUnit ?? 0);
1870
2627
  if (shiftX !== 0) {
1871
2628
  bindings.push({
1872
2629
  parameter: StandardParameter.AngleX,
@@ -1875,7 +2632,7 @@ function featureParallaxBindings(role, options) {
1875
2632
  to: shiftX
1876
2633
  });
1877
2634
  }
1878
- const shiftY = depth * (options.parallaxUnitY ?? 0);
2635
+ const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
1879
2636
  if (shiftY !== 0) {
1880
2637
  bindings.push({
1881
2638
  parameter: StandardParameter.AngleY,
@@ -1995,17 +2752,6 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
1995
2752
  if (role === "hair_front" || role === "hair_back") {
1996
2753
  const isFront = role === "hair_front";
1997
2754
  const parallax = [];
1998
- if (!isFront) {
1999
- const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
2000
- if (shiftX !== 0) {
2001
- parallax.push({
2002
- parameter: StandardParameter.AngleX,
2003
- channel: "translateX",
2004
- from: -shiftX,
2005
- to: shiftX
2006
- });
2007
- }
2008
- }
2009
2755
  const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2010
2756
  const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2011
2757
  if (shiftY !== 0) {
@@ -2039,35 +2785,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2039
2785
  ]
2040
2786
  };
2041
2787
  }
2042
- var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
2043
- var HAIR_BACK_FAR_BULGE = 0.22;
2044
- function bakeHairBackTurnWarp(mesh, parameter) {
2045
- let left = Infinity;
2046
- let right = -Infinity;
2047
- for (let i = 0; i < mesh.vertices.length; i += 2) {
2048
- left = Math.min(left, mesh.vertices[i]);
2049
- 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
+ );
2050
2795
  }
2051
- const halfWidth = (right - left) / 2;
2052
- const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
2053
- const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
2054
- 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];
2055
2813
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2056
- const theta = deg * DEG_TO_RAD;
2057
- const s = Math.sign(deg);
2058
- const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
2059
- const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
2060
- 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
+ );
2061
2834
  const offsets = [];
2062
2835
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2063
- const x = mesh.vertices[i];
2064
- const far = Math.max(0, -s * x / halfWidth);
2065
- const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
2066
- 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);
2067
2846
  }
2068
2847
  return { value: deg, offsets };
2069
2848
  });
2070
- return { parameter, keyforms };
2849
+ return { parameter: StandardParameter.AngleX, keyforms };
2071
2850
  }
2072
2851
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2073
2852
  var HAIR_SWAY_CURL = 1.5;
@@ -2095,11 +2874,14 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2095
2874
  ]
2096
2875
  };
2097
2876
  }
2098
- 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
+ }
2099
2881
  var BODY_TURN_FOLLOW = 0.3;
2100
2882
  var HEAD_BREATH_BOB = -12;
2101
2883
  var BODY_BREATH_FOLLOW = 0.5;
2102
- function generateIkiFromLayerSet(layers, canvas) {
2884
+ function generateIkiFromLayerSet(layers, canvas, options = {}) {
2103
2885
  validateLayerInputs(layers, canvas);
2104
2886
  const hasHair = layers.some((l) => l.role === "hair_front");
2105
2887
  const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
@@ -2281,7 +3063,52 @@ function generateIkiFromLayerSet(layers, canvas) {
2281
3063
  };
2282
3064
  const faceCenterY = faceTransform.y;
2283
3065
  const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
2284
- const parallaxUnit = headTurnParallaxUnit(halfW);
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);
2285
3112
  const parallaxUnitY = headNodParallaxUnit(halfH);
2286
3113
  const faceBottom = faceTransform.y - faceCropH / 2;
2287
3114
  const neckPivot = {
@@ -2294,26 +3121,27 @@ function generateIkiFromLayerSet(layers, canvas) {
2294
3121
  StandardParameter.AngleX,
2295
3122
  StandardParameter.AngleY,
2296
3123
  faceCenterX,
2297
- faceCenterY
3124
+ faceCenterY,
3125
+ faceRadius,
3126
+ headTravel
2298
3127
  );
2299
3128
  const deformers = [
2300
3129
  // headDeformer: rigid matrix rotating/translating the whole head about the
2301
- // 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).
2302
3132
  {
2303
3133
  id: "headDeformer",
2304
3134
  pivot: neckPivot,
2305
3135
  bindings: [
2306
- // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2307
- // ±6° "lean into the turn" was tried here and dropped — rotating about
2308
- // the neck pivot swings the crown (~500px above it) far more than the
2309
- // chin, so the top of the head appeared to lunge ahead of the face on
2310
- // every turn. Roll is AngleZ's job, below.
2311
- {
2312
- parameter: StandardParameter.AngleX,
2313
- channel: "translateX",
2314
- from: -HEAD_TURN_TRAVEL,
2315
- to: HEAD_TURN_TRAVEL
2316
- },
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.
2317
3145
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
2318
3146
  // rigid rotation would sum with the AngleZ roll below at diagonal
2319
3147
  // poses, collapsing pitch into roll.
@@ -2366,12 +3194,13 @@ function generateIkiFromLayerSet(layers, canvas) {
2366
3194
  id: "bodyDeformer",
2367
3195
  pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
2368
3196
  bindings: [
2369
- // 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.
2370
3199
  {
2371
3200
  parameter: StandardParameter.AngleX,
2372
3201
  channel: "translateX",
2373
- from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
2374
- to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
3202
+ from: -BODY_TURN_FOLLOW * headTravel,
3203
+ to: BODY_TURN_FOLLOW * headTravel
2375
3204
  },
2376
3205
  // Breath: follow the head's bob, same direction, half amplitude.
2377
3206
  {
@@ -2404,7 +3233,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2404
3233
  hasMouthOpen,
2405
3234
  parallaxUnit,
2406
3235
  parallaxUnitY,
2407
- hasNose
3236
+ hasNose,
3237
+ turnDepths: turn?.depths
2408
3238
  });
2409
3239
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2410
3240
  if (spec.mesh) {
@@ -2426,6 +3256,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2426
3256
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
2427
3257
  let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2428
3258
  let leadWarp;
3259
+ let silhouetteWarp;
2429
3260
  if (role === "hair_front") {
2430
3261
  const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2431
3262
  const headroom = Math.min(
@@ -2442,6 +3273,17 @@ function generateIkiFromLayerSet(layers, canvas) {
2442
3273
  shift,
2443
3274
  HEAD_TURN_MAX_DEG
2444
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
+ );
2445
3287
  }
2446
3288
  part.warps = [
2447
3289
  bakeHairSwayWarp(
@@ -2456,13 +3298,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2456
3298
  tipShift,
2457
3299
  HAIR_SWAY_RANGE
2458
3300
  ),
2459
- ...leadWarp ? [leadWarp] : []
2460
- ];
2461
- }
2462
- if (role === "hair_back") {
2463
- part.warps = [
2464
- ...part.warps ?? [],
2465
- bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
3301
+ ...leadWarp ? [leadWarp] : [],
3302
+ ...silhouetteWarp ? [silhouetteWarp] : []
2466
3303
  ];
2467
3304
  }
2468
3305
  if (spec.eyeSide !== void 0) {
@@ -2542,6 +3379,7 @@ export {
2542
3379
  AddPart,
2543
3380
  AddPhysicsRig,
2544
3381
  CaptureGridKeyform,
3382
+ DEFAULT_TURN_TARGETS,
2545
3383
  DeleteDeformer,
2546
3384
  DeletePart,
2547
3385
  DeletePhysicsRig,
@@ -2561,6 +3399,7 @@ export {
2561
3399
  SetPartTransform,
2562
3400
  SetPartWidth,
2563
3401
  SetPhysicsRig,
3402
+ TurnTargetError,
2564
3403
  UV_INSET_PX,
2565
3404
  captureBindingEndpoint,
2566
3405
  computeGridOffsets,