@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.js CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  AddPart: () => AddPart,
27
27
  AddPhysicsRig: () => AddPhysicsRig,
28
28
  CaptureGridKeyform: () => CaptureGridKeyform,
29
+ DEFAULT_TURN_TARGETS: () => DEFAULT_TURN_TARGETS,
29
30
  DeleteDeformer: () => DeleteDeformer,
30
31
  DeletePart: () => DeletePart,
31
32
  DeletePhysicsRig: () => DeletePhysicsRig,
@@ -45,6 +46,7 @@ __export(index_exports, {
45
46
  SetPartTransform: () => SetPartTransform,
46
47
  SetPartWidth: () => SetPartWidth,
47
48
  SetPhysicsRig: () => SetPhysicsRig,
49
+ TurnTargetError: () => TurnTargetError,
48
50
  UV_INSET_PX: () => UV_INSET_PX,
49
51
  captureBindingEndpoint: () => captureBindingEndpoint,
50
52
  computeGridOffsets: () => computeGridOffsets,
@@ -1664,9 +1666,10 @@ function createDefaultWarpDeformer(model) {
1664
1666
  var import_format3 = require("@ikijs/format");
1665
1667
  var ROLE_TABLE = {
1666
1668
  // The silhouette behind the face. It rides the rigid head (not faceWarp,
1667
- // whose grid it would stretch across the shoulders) as a MESH: it bends on
1668
- // the turn through its own part warp (bakeHairBackTurnWarp) and swings its
1669
- // ends on the hair-sway warps.
1669
+ // whose grid it would stretch across the shoulders) as a MESH, so its ends
1670
+ // can swing on the hair-sway warps. It holds the head's outline STILL
1671
+ // through the turn — the head it hangs off no longer travels and it has no
1672
+ // turn warp of its own — while the face slides inside it.
1670
1673
  hair_back: { deformer: "headDeformer", order: 0, mesh: true },
1671
1674
  // The torso. It rides its own rigid deformer, not the head's: the head turns
1672
1675
  // about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
@@ -1848,8 +1851,8 @@ var FACE_GRID_CELLS = 6;
1848
1851
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1849
1852
  var HEAD_TURN_MAX_DEG = 30;
1850
1853
  var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1851
- function headTurnParallaxUnit(gridHalfWidth) {
1852
- return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1854
+ function headTurnParallaxUnit(radius) {
1855
+ return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1853
1856
  }
1854
1857
  function gridReach(grid, axis, center) {
1855
1858
  let reach = 0;
@@ -1862,9 +1865,16 @@ function pinnedCylinderBend(local, radius, theta) {
1862
1865
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1863
1866
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
1864
1867
  }
1865
- function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
1868
+ function boundedCylinderBend(local, radius, theta) {
1869
+ const bound = radius / HEAD_CYLINDER_RADIUS_FACTOR;
1870
+ const onSurface = Math.max(-bound, Math.min(bound, local));
1871
+ return pinnedCylinderBend(onSurface, radius, theta);
1872
+ }
1873
+ function turnSlide(travel, angleX) {
1874
+ return travel * angleX / HEAD_TURN_MAX_DEG;
1875
+ }
1876
+ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
1866
1877
  const STOPS = [...HEAD_TURN_STOPS];
1867
- const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
1868
1878
  const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1869
1879
  const pointCount = grid.points.length / 2;
1870
1880
  const DEG_TO_RAD = Math.PI / 180;
@@ -1873,10 +1883,11 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
1873
1883
  const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1874
1884
  for (const angleX of STOPS) {
1875
1885
  const thetaX = angleX * DEG_TO_RAD;
1886
+ const slide = turnSlide(travel, angleX);
1876
1887
  const offsets = [];
1877
1888
  for (let i = 0; i < pointCount; i++) {
1878
1889
  offsets.push(
1879
- pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
1890
+ boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
1880
1891
  pinnedCylinderBend(
1881
1892
  grid.points[i * 2 + 1] - centerY,
1882
1893
  RADIUS_Y,
@@ -1895,13 +1906,811 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
1895
1906
  keyforms2d
1896
1907
  };
1897
1908
  }
1909
+ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
1910
+ if (Math.abs(angleX) > HEAD_TURN_MAX_DEG) {
1911
+ throw new Error(
1912
+ `auto-rig: turnColumnMap: angleX ${angleX}\xB0 is outside the turn's \xB1${HEAD_TURN_MAX_DEG}\xB0 range`
1913
+ );
1914
+ }
1915
+ const theta = angleX * (Math.PI / 180);
1916
+ const slide = turnSlide(travel, angleX);
1917
+ const restX = [];
1918
+ const warpedX = [];
1919
+ for (let col = 0; col <= grid.cols; col++) {
1920
+ const x = grid.points[col * 2];
1921
+ restX.push(x);
1922
+ warpedX.push(
1923
+ x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
1924
+ );
1925
+ }
1926
+ const cellFor = (v, edges) => {
1927
+ for (let c = 0; c < grid.cols; c++) {
1928
+ if (v < edges[c + 1]) return c;
1929
+ }
1930
+ return grid.cols - 1;
1931
+ };
1932
+ return {
1933
+ restX,
1934
+ warpedX,
1935
+ mapX(x) {
1936
+ const c = cellFor(x, restX);
1937
+ const s = Math.max(
1938
+ 0,
1939
+ Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
1940
+ );
1941
+ return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
1942
+ },
1943
+ invertX(X) {
1944
+ const c = cellFor(X, warpedX);
1945
+ const s = Math.max(
1946
+ 0,
1947
+ Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
1948
+ );
1949
+ return restX[c] + (restX[c + 1] - restX[c]) * s;
1950
+ }
1951
+ };
1952
+ }
1953
+ var TurnTargetError = class extends Error {
1954
+ constructor(message) {
1955
+ super(message);
1956
+ this.name = "TurnTargetError";
1957
+ }
1958
+ };
1959
+ var DEFAULT_TURN_TARGETS = {
1960
+ eyeShift: 0.22,
1961
+ farEyeRatio: 0.67,
1962
+ silhouetteRatio: 1
1963
+ };
1964
+ var NOSE_SHIFT_SHARE = 1.36;
1965
+ var MOUTH_SHIFT_SHARE = 1.18;
1966
+ var TURN_RATIO_MAX = 1.5;
1967
+ var SILHOUETTE_RATIO_MIN = 0.5;
1968
+ function resolveTurnTargets(targets = {}) {
1969
+ const eyeShift = targets.eyeShift ?? DEFAULT_TURN_TARGETS.eyeShift;
1970
+ const check = (field, value, ok, expected) => {
1971
+ if (value === void 0) return;
1972
+ if (!Number.isFinite(value) || !ok(value)) {
1973
+ const derived = (field === "noseShift" || field === "mouthShift") && targets[field] === void 0;
1974
+ const from = derived ? `, derived from turnTargets.eyeShift (${eyeShift}),` : "";
1975
+ throw new TurnTargetError(
1976
+ `auto-rig: turnTargets.${field} (${value})${from} must be ${expected}`
1977
+ );
1978
+ }
1979
+ };
1980
+ const farEyeRatio = targets.farEyeRatio ?? DEFAULT_TURN_TARGETS.farEyeRatio;
1981
+ const silhouetteRatio = targets.silhouetteRatio ?? DEFAULT_TURN_TARGETS.silhouetteRatio;
1982
+ const noseShift = targets.noseShift ?? NOSE_SHIFT_SHARE * eyeShift;
1983
+ const mouthShift = targets.mouthShift ?? MOUTH_SHIFT_SHARE * eyeShift;
1984
+ const isShift = (v) => Math.abs(v) <= 1;
1985
+ const shiftRange = "a fraction of the head half-width, |value| <= 1";
1986
+ check("eyeShift", eyeShift, isShift, shiftRange);
1987
+ check(
1988
+ "farEyeRatio",
1989
+ farEyeRatio,
1990
+ (v) => v > 0 && v <= TURN_RATIO_MAX,
1991
+ `in (0, ${TURN_RATIO_MAX}]`
1992
+ );
1993
+ check(
1994
+ "silhouetteRatio",
1995
+ silhouetteRatio,
1996
+ (v) => v >= SILHOUETTE_RATIO_MIN && v <= TURN_RATIO_MAX,
1997
+ `in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
1998
+ );
1999
+ check("noseShift", noseShift, isShift, shiftRange);
2000
+ check("mouthShift", mouthShift, isShift, shiftRange);
2001
+ check("headHalfWidth", targets.headHalfWidth, (v) => v > 0, "positive");
2002
+ const defaulted = new Set(
2003
+ [
2004
+ "eyeShift",
2005
+ "farEyeRatio",
2006
+ "silhouetteRatio",
2007
+ "noseShift",
2008
+ "mouthShift"
2009
+ ].filter((field) => targets[field] === void 0)
2010
+ );
2011
+ return {
2012
+ eyeShift,
2013
+ farEyeRatio,
2014
+ silhouetteRatio,
2015
+ noseShift,
2016
+ mouthShift,
2017
+ headHalfWidth: targets.headHalfWidth,
2018
+ defaulted
2019
+ };
2020
+ }
2021
+ function turnLandmarks(layers) {
2022
+ const markOf = (role) => {
2023
+ const layer = layers.find((l) => l.role === role);
2024
+ if (!layer) return void 0;
2025
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2026
+ return { x: t.x, y: t.y, w: layer.cropW };
2027
+ };
2028
+ const pairOf = (left, right) => {
2029
+ const l = markOf(left);
2030
+ const r = markOf(right);
2031
+ return l && r ? [l, r] : void 0;
2032
+ };
2033
+ const one = (role) => {
2034
+ const m = markOf(role);
2035
+ return m ? [m] : [];
2036
+ };
2037
+ return {
2038
+ eye: pairOf("eye_L", "eye_R") ?? [],
2039
+ nose: one("nose"),
2040
+ mouth: one("mouth")
2041
+ };
2042
+ }
2043
+ var TURN_BISECT_STEPS = 40;
2044
+ var TURN_DEPTH_EPS = 1e-6;
2045
+ function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
2046
+ if (landmarks.length === 0) {
2047
+ throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2048
+ }
2049
+ const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2050
+ const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
2051
+ const cap = Math.max(0, (farEdge - floor) / unit);
2052
+ const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
2053
+ const atCap = achieved(cap);
2054
+ const atRest = achieved(0);
2055
+ const attainable = [atCap, atRest];
2056
+ if (target > atRest + TURN_DEPTH_EPS)
2057
+ return { reached: false, depth: 0, achieved: atRest, attainable };
2058
+ if (target < atCap - TURN_DEPTH_EPS)
2059
+ return { reached: false, depth: cap, achieved: atCap, attainable };
2060
+ const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2061
+ let lo = 0;
2062
+ let hi = cap;
2063
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2064
+ const mid = (lo + hi) / 2;
2065
+ if (achieved(mid) > clampedTarget) lo = mid;
2066
+ else hi = mid;
2067
+ }
2068
+ const depth = (lo + hi) / 2;
2069
+ return { reached: true, depth, achieved: achieved(depth), attainable };
2070
+ }
2071
+ var TURN_SWEEP_MAX_FACTOR = 8;
2072
+ var TURN_SWEEP_SAMPLES = 32;
2073
+ var HOLD_CLEARANCE = 1;
2074
+ var TURN_HOLD_SAMPLES = 16;
2075
+ var TURN_HOLD_SATURATION_PASSES = 4;
2076
+ function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
2077
+ const local = x - faceCenterX;
2078
+ const dist = Math.abs(local);
2079
+ const side = Math.sign(local);
2080
+ const sideIdx = side < 0 ? 0 : 1;
2081
+ if (dist <= faceHalfWidth) return map.mapX(x);
2082
+ if (dist <= holdBase) {
2083
+ const inner = map.mapX(faceCenterX + side * faceHalfWidth);
2084
+ const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
2085
+ return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
2086
+ }
2087
+ return x + side * (holdEdge[sideIdx] - holdBase);
2088
+ }
2089
+ function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
2090
+ const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2091
+ const preBindAt = (col2) => {
2092
+ const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
2093
+ const target = hairFrontHoldTarget(
2094
+ colX,
2095
+ faceCenterX,
2096
+ faceHalfWidth,
2097
+ holdBase,
2098
+ cappedHoldEdge,
2099
+ map
2100
+ );
2101
+ return map.invertX(target);
2102
+ };
2103
+ const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
2104
+ const bracket = (v, cells) => {
2105
+ const lo = Math.max(0, Math.min(cells, Math.floor(v)));
2106
+ return {
2107
+ lo,
2108
+ hi: Math.max(0, Math.min(cells, lo + 1)),
2109
+ t: Math.max(0, Math.min(1, v - lo))
2110
+ };
2111
+ };
2112
+ const col = bracket(
2113
+ (x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
2114
+ cols
2115
+ );
2116
+ const row = bracket(leadRow * rows, rows);
2117
+ const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
2118
+ const tl = landingAt(col.lo, row.lo);
2119
+ const br = landingAt(col.hi, row.hi);
2120
+ if (col.t <= row.t) {
2121
+ const bl = landingAt(col.lo, row.hi);
2122
+ return tl + row.t * (bl - tl) + col.t * (br - bl);
2123
+ }
2124
+ const tr = landingAt(col.hi, row.lo);
2125
+ return tl + col.t * (tr - tl) + row.t * (br - tr);
2126
+ }
2127
+ function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
2128
+ let cap = Infinity;
2129
+ for (const deg of HEAD_TURN_STOPS) {
2130
+ if (deg === 0) continue;
2131
+ const map = bendOnlyMapAt(deg);
2132
+ const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
2133
+ const towardReach = Math.max(
2134
+ ...[-1, 1].map(
2135
+ (side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
2136
+ )
2137
+ );
2138
+ cap = Math.min(
2139
+ cap,
2140
+ Math.max(0, holdEdgeAt(deg, slideSign) - HOLD_CLEARANCE - towardReach) * HEAD_TURN_MAX_DEG / Math.abs(deg)
2141
+ );
2142
+ }
2143
+ return cap;
2144
+ }
2145
+ function evaluateTurnCandidate(ctx, radius) {
2146
+ const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
2147
+ const measuredEdgeAt = (side) => {
2148
+ const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2149
+ if (candidates === void 0 || candidates.length === 0) return void 0;
2150
+ const xs = candidates.map((c) => c.x);
2151
+ return side < 0 ? Math.min(...xs) : Math.max(...xs);
2152
+ };
2153
+ const shellHalfWidthAt = (side) => {
2154
+ const edge = measuredEdgeAt(side);
2155
+ return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2156
+ };
2157
+ const unit = headTurnParallaxUnit(radius);
2158
+ const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2159
+ const leadRow = ctx.hairFrontLeadFraction ?? 0;
2160
+ const evaluateHold = (ratio) => {
2161
+ const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2162
+ const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
2163
+ ctx.travel,
2164
+ shellTravelCap(
2165
+ ctx.faceCenterX,
2166
+ ctx.faceHalfWidth,
2167
+ ctx.travel,
2168
+ bendOnlyMapAt,
2169
+ (deg, side) => {
2170
+ const rest = shellHalfWidthAt(side);
2171
+ return Math.min(rest, holdEdgeFrom(rest)(deg));
2172
+ }
2173
+ )
2174
+ );
2175
+ const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
2176
+ const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
2177
+ const holdEdgeAt2 = holdEdgeFrom(holdBase2);
2178
+ let foldLowerBound = -Infinity;
2179
+ let folds = false;
2180
+ for (const deg of HEAD_TURN_STOPS) {
2181
+ if (deg === 0) continue;
2182
+ const reach = plateReachAt(
2183
+ columnMapAt(deg),
2184
+ ctx.faceCenterX,
2185
+ ctx.faceHalfWidth
2186
+ );
2187
+ foldLowerBound = Math.max(
2188
+ foldLowerBound,
2189
+ 1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
2190
+ );
2191
+ if (holdEdgeAt2(deg) <= reach) folds = true;
2192
+ }
2193
+ const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
2194
+ const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
2195
+ const gridReach2 = [
2196
+ Math.abs(map2.warpedX[0] - ctx.faceCenterX),
2197
+ Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
2198
+ ];
2199
+ const cappedHoldEdge = [
2200
+ Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
2201
+ Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
2202
+ ];
2203
+ const restAt = (side) => {
2204
+ const edge = measuredEdgeAt(side);
2205
+ if (edge !== void 0) return edge;
2206
+ return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2207
+ };
2208
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2209
+ x,
2210
+ ctx.hairFrontSilhouette,
2211
+ ctx.faceCenterX,
2212
+ ctx.faceHalfWidth,
2213
+ holdBase2,
2214
+ cappedHoldEdge,
2215
+ map2,
2216
+ leadTipShift,
2217
+ leadRow
2218
+ );
2219
+ const landingOfRole = (role, x) => {
2220
+ if (role === "hair_front") return hairFrontAt(x);
2221
+ if (role === "hair_back") {
2222
+ return x;
2223
+ }
2224
+ if (role === "body") {
2225
+ return x - BODY_TURN_FOLLOW * travel2;
2226
+ }
2227
+ if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
2228
+ return map2.mapX(x);
2229
+ }
2230
+ throw new Error(
2231
+ `auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
2232
+ );
2233
+ };
2234
+ const landingAt = (side) => {
2235
+ const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
2236
+ if (candidates === void 0 || candidates.length === 0) {
2237
+ return hairFrontAt(restAt(side));
2238
+ }
2239
+ const landings = candidates.map((c) => landingOfRole(c.role, c.x));
2240
+ return side < 0 ? Math.min(...landings) : Math.max(...landings);
2241
+ };
2242
+ return {
2243
+ ratio,
2244
+ travel: travel2,
2245
+ map: map2,
2246
+ holdBase: holdBase2,
2247
+ holdEdgeAt: holdEdgeAt2,
2248
+ folds,
2249
+ foldLowerBound,
2250
+ gridReach: gridReach2,
2251
+ // How far the silhouette's own centre drifts off the face centre at full
2252
+ // turn, against how far it already sat off centre at rest (zero unless
2253
+ // hair_front's crop is itself off-centre) — the held shell keeps its own
2254
+ // place while the face slides inside it, so this is the drift the hold
2255
+ // and the bangs' lead leave behind, and the slide itself shows up in the
2256
+ // eye cue instead of cancelling out of it.
2257
+ // `measure_turn_reference` reads the eye pair against THIS moved centre,
2258
+ // not the face centre, so it is what the eye cue is corrected by.
2259
+ silhouetteCenterShift: (landingAt(1) + landingAt(-1)) / 2 - (restAt(1) + restAt(-1)) / 2,
2260
+ // What a render actually shows: the real landing span over the real rest
2261
+ // span, the same pair silhouetteCenterShift reads — the measure the
2262
+ // hold is FITTED against, see this function's own doc.
2263
+ renderedSilhouetteRatio: (landingAt(1) - landingAt(-1)) / (restAt(1) - restAt(-1)),
2264
+ restSpan: restAt(1) - restAt(-1)
2265
+ };
2266
+ };
2267
+ const saturatedHold = () => {
2268
+ let hold2 = evaluateHold(1);
2269
+ for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
2270
+ const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
2271
+ if (needed <= hold2.ratio) break;
2272
+ hold2 = evaluateHold(needed);
2273
+ }
2274
+ return hold2;
2275
+ };
2276
+ const holdRange = () => {
2277
+ const ceiling = saturatedHold();
2278
+ if (ceiling.folds) return void 0;
2279
+ let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
2280
+ if (floor.folds) {
2281
+ let lo = floor;
2282
+ let hi = ceiling;
2283
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2284
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2285
+ if (mid.folds) lo = mid;
2286
+ else hi = mid;
2287
+ }
2288
+ floor = hi;
2289
+ }
2290
+ const samples = [floor];
2291
+ for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
2292
+ const sample = evaluateHold(
2293
+ floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
2294
+ );
2295
+ if (!sample.folds) samples.push(sample);
2296
+ }
2297
+ samples.push(ceiling);
2298
+ return samples;
2299
+ };
2300
+ const silhouetteMiss = (span) => {
2301
+ const lower = Math.max(span?.[0] ?? Infinity, SILHOUETTE_RATIO_MIN);
2302
+ const upper = Math.min(span?.[1] ?? -Infinity, TURN_RATIO_MAX);
2303
+ return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
2304
+ };
2305
+ const asked = ctx.silhouetteRatio;
2306
+ const natural = evaluateHold(asked);
2307
+ const eps = TURN_DEPTH_EPS / natural.restSpan;
2308
+ let hold;
2309
+ let silhouetteClamped = false;
2310
+ if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
2311
+ hold = natural;
2312
+ } else {
2313
+ const samples = holdRange();
2314
+ if (samples === void 0) return silhouetteMiss();
2315
+ const rendered = samples.map((h) => h.renderedSilhouetteRatio);
2316
+ const span = [
2317
+ Math.min(...rendered),
2318
+ Math.max(...rendered)
2319
+ ];
2320
+ const nearer = (a, b) => {
2321
+ const ea = Math.abs(a.renderedSilhouetteRatio - asked);
2322
+ const eb = Math.abs(b.renderedSilhouetteRatio - asked);
2323
+ if (ea <= eps && eb <= eps) {
2324
+ return Math.abs(a.ratio - asked) <= Math.abs(b.ratio - asked) ? a : b;
2325
+ }
2326
+ return ea <= eb ? a : b;
2327
+ };
2328
+ if (asked < span[0] - eps || asked > span[1] + eps) {
2329
+ if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
2330
+ const wanted = asked < span[0] ? span[0] : span[1];
2331
+ hold = samples.reduce(
2332
+ (a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
2333
+ );
2334
+ silhouetteClamped = true;
2335
+ } else {
2336
+ let best = samples.reduce(nearer);
2337
+ let lo;
2338
+ let hi;
2339
+ for (let i = 0; i + 1 < samples.length; i++) {
2340
+ const a = samples[i].renderedSilhouetteRatio - asked;
2341
+ const b = samples[i + 1].renderedSilhouetteRatio - asked;
2342
+ if (a * b <= 0) {
2343
+ lo = samples[i];
2344
+ hi = samples[i + 1];
2345
+ break;
2346
+ }
2347
+ }
2348
+ for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
2349
+ if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
2350
+ break;
2351
+ }
2352
+ const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
2353
+ if (!mid.folds) best = nearer(best, mid);
2354
+ if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
2355
+ lo = mid;
2356
+ } else {
2357
+ hi = mid;
2358
+ }
2359
+ }
2360
+ hold = best;
2361
+ }
2362
+ }
2363
+ if (hold.folds) {
2364
+ throw new Error(
2365
+ "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2366
+ );
2367
+ }
2368
+ const {
2369
+ travel,
2370
+ map,
2371
+ holdBase,
2372
+ holdEdgeAt,
2373
+ silhouetteCenterShift,
2374
+ renderedSilhouetteRatio
2375
+ } = hold;
2376
+ const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
2377
+ const eye = solveTurnDepthSigned(
2378
+ silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2379
+ ctx.landmarks.eye,
2380
+ unit,
2381
+ map,
2382
+ holdEdgeX,
2383
+ ctx.plateEdgeX
2384
+ );
2385
+ if (!eye.reached && !ctx.clampEyeShift) {
2386
+ return {
2387
+ blocked: "eyeShift",
2388
+ // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
2389
+ // negative lower bound here would advertise a value the field's own
2390
+ // contract already rules out; 0 is the true floor.
2391
+ offeredShift: [
2392
+ Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
2393
+ (-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
2394
+ ]
2395
+ };
2396
+ }
2397
+ const scaleOf = (l) => (map.mapX(l.x + l.w / 2 - eye.depth * unit) - map.mapX(l.x - l.w / 2 - eye.depth * unit)) / l.w;
2398
+ const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2399
+ const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2400
+ return {
2401
+ radius,
2402
+ ratio: scaleOf(far) / scaleOf(near),
2403
+ unit,
2404
+ travel,
2405
+ map,
2406
+ holdBase,
2407
+ holdEdgeAt,
2408
+ holdEdgeX,
2409
+ eye,
2410
+ silhouetteClamped,
2411
+ silhouetteCenterShift,
2412
+ renderedSilhouetteRatio
2413
+ };
2414
+ }
2415
+ function sweepTurnRadii(ctx) {
2416
+ const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
2417
+ const maxRadius = ctx.faceHalfWidth * TURN_SWEEP_MAX_FACTOR;
2418
+ const candidates = [];
2419
+ let heldRatioLower = Infinity;
2420
+ let heldRatioUpper = -Infinity;
2421
+ let offeredShift;
2422
+ for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
2423
+ const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
2424
+ const result = evaluateTurnCandidate(ctx, radius);
2425
+ if (!("blocked" in result)) {
2426
+ candidates.push({ ...result, sweepIndex: i });
2427
+ } else if (result.blocked === "silhouetteRatio") {
2428
+ heldRatioLower = Math.min(heldRatioLower, result.lower);
2429
+ heldRatioUpper = Math.max(heldRatioUpper, result.upper);
2430
+ } else {
2431
+ offeredShift = offeredShift ? [
2432
+ Math.min(offeredShift[0], result.offeredShift[0]),
2433
+ Math.max(offeredShift[1], result.offeredShift[1])
2434
+ ] : result.offeredShift;
2435
+ }
2436
+ }
2437
+ return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
2438
+ }
2439
+ function ratioClusters(candidates) {
2440
+ const clusters = [];
2441
+ let run = [];
2442
+ const close = () => {
2443
+ if (run.length === 0) return;
2444
+ const ratios = run.map((c) => c.ratio);
2445
+ clusters.push({
2446
+ candidates: run,
2447
+ ratios: [Math.min(...ratios), Math.max(...ratios)]
2448
+ });
2449
+ run = [];
2450
+ };
2451
+ for (const candidate of candidates) {
2452
+ const previous = run[run.length - 1];
2453
+ if (previous !== void 0 && candidate.sweepIndex !== previous.sweepIndex + 1) {
2454
+ close();
2455
+ }
2456
+ run.push(candidate);
2457
+ }
2458
+ close();
2459
+ return clusters;
2460
+ }
2461
+ function fitTurnRadius(ctx, candidates, target) {
2462
+ let lo = candidates[0];
2463
+ let hi = candidates[candidates.length - 1];
2464
+ for (let i = candidates.length - 2; i >= 0; i--) {
2465
+ if ((candidates[i].ratio - target) * (candidates[i + 1].ratio - target) <= 0) {
2466
+ lo = candidates[i];
2467
+ hi = candidates[i + 1];
2468
+ break;
2469
+ }
2470
+ }
2471
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2472
+ const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2473
+ if ("blocked" in mid) break;
2474
+ if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2475
+ else lo = mid;
2476
+ }
2477
+ return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
2478
+ }
2479
+ function solveFeatureDepths(ctx, targets, best, eyeShift) {
2480
+ const clamped = [];
2481
+ const solved = {};
2482
+ const families = [
2483
+ ["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
2484
+ ["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
2485
+ ];
2486
+ for (const [field, marks, share] of families) {
2487
+ const m = Math.abs(
2488
+ targets.defaulted.has(field) ? share * eyeShift : targets[field]
2489
+ );
2490
+ const solution = solveTurnDepthSigned(
2491
+ best.silhouetteCenterShift - m * ctx.hh,
2492
+ marks,
2493
+ best.unit,
2494
+ best.map,
2495
+ best.holdEdgeX,
2496
+ ctx.plateEdgeX
2497
+ );
2498
+ if (!solution.reached) {
2499
+ if (!targets.defaulted.has(field)) {
2500
+ return {
2501
+ blocked: field,
2502
+ // noseShift/mouthShift are MAGNITUDES too (see the correction
2503
+ // above), so a negative lower bound here would advertise a value
2504
+ // the field's own contract already rules out; 0 is the true floor.
2505
+ attainable: [
2506
+ Math.max(
2507
+ 0,
2508
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2509
+ ),
2510
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2511
+ ]
2512
+ };
2513
+ }
2514
+ clamped.push(field);
2515
+ }
2516
+ solved[field] = solution.depth;
2517
+ }
2518
+ return {
2519
+ nose: solved.noseShift,
2520
+ mouth: solved.mouthShift,
2521
+ clamped
2522
+ };
2523
+ }
2524
+ function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
2525
+ if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2526
+ throw new TurnTargetError(
2527
+ `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2528
+ );
2529
+ }
2530
+ const eyeRowY = landmarks.eye.length === 2 && landmarks.eye[0].y !== void 0 && landmarks.eye[1].y !== void 0 ? (landmarks.eye[0].y + landmarks.eye[1].y) / 2 : void 0;
2531
+ const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
2532
+ 0,
2533
+ Math.min(
2534
+ 1,
2535
+ (hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
2536
+ )
2537
+ );
2538
+ const ctx = {
2539
+ landmarks,
2540
+ faceGrid,
2541
+ faceCenterX,
2542
+ faceHalfWidth,
2543
+ plateEdgeX: faceCenterX - faceHalfWidth,
2544
+ // What this plate asks the turn for; each candidate caps it to its own
2545
+ // shell — see TurnSolveContext.travel.
2546
+ travel: headTurnTravel(faceHalfWidth),
2547
+ headHalfWidth: targets.headHalfWidth,
2548
+ // The pixels the shift fractions are fractions of. Without a measured head
2549
+ // the face plate stands in for it — see TurnTargets.headHalfWidth.
2550
+ hh: targets.headHalfWidth ?? faceHalfWidth,
2551
+ silhouetteRatio: targets.silhouetteRatio,
2552
+ // The sign is ignored (see TurnTargets.eyeShift) — taken here, once, so
2553
+ // every downstream use already has the magnitude.
2554
+ eyeShift: Math.abs(targets.eyeShift),
2555
+ clampEyeShift: targets.defaulted.has("eyeShift"),
2556
+ clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2557
+ hairFrontLeadFraction,
2558
+ hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
2559
+ // A companion to a MEASURED headHalfWidth only — see its own doc.
2560
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
2561
+ };
2562
+ const clamped = [];
2563
+ const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2564
+ const pass = sweepTurnRadii(ctx);
2565
+ if (pass.candidates.length === 0) {
2566
+ return pass.offeredShift ? {
2567
+ unreachable: true,
2568
+ field: "eyeShift",
2569
+ value: targets.eyeShift,
2570
+ attainable: pass.offeredShift
2571
+ } : {
2572
+ unreachable: true,
2573
+ field: "silhouetteRatio",
2574
+ value: targets.silhouetteRatio,
2575
+ // Two different bounds, not one number twice, and computed the
2576
+ // same way regardless of which ratio was actually rejected: both
2577
+ // are ratios a RENDER could have shown (the measure the caller's
2578
+ // own number came from), the narrowest and the widest any radius
2579
+ // could be fitted to, with every ratio between them one it can be
2580
+ // fitted to exactly; see evaluateTurnCandidate, which clips each
2581
+ // radius' own range to the domain `resolveTurnTargets` accepts
2582
+ // before it gets here. Absent when that left every radius with
2583
+ // nothing: no ratio a caller is allowed to ask for renders at all.
2584
+ attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2585
+ };
2586
+ }
2587
+ const clusters = ratioClusters(pass.candidates);
2588
+ const distanceTo = (c) => Math.max(
2589
+ c.ratios[0] - targets.farEyeRatio,
2590
+ targets.farEyeRatio - c.ratios[1],
2591
+ 0
2592
+ );
2593
+ let farEyeRatio = targets.farEyeRatio;
2594
+ let cluster = clusters.find(
2595
+ (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2596
+ );
2597
+ if (cluster === void 0) {
2598
+ cluster = clusters.reduce(
2599
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
2600
+ );
2601
+ if (!targets.defaulted.has("farEyeRatio")) {
2602
+ return {
2603
+ unreachable: true,
2604
+ field: "farEyeRatio",
2605
+ value: targets.farEyeRatio,
2606
+ attainable: cluster.ratios
2607
+ };
2608
+ }
2609
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2610
+ clamped.push("farEyeRatio");
2611
+ }
2612
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2613
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2614
+ if (!targets.defaulted.has("farEyeRatio")) {
2615
+ return {
2616
+ unreachable: true,
2617
+ field: "farEyeRatio",
2618
+ value: targets.farEyeRatio,
2619
+ attainable: cluster.ratios
2620
+ };
2621
+ }
2622
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2623
+ }
2624
+ if (!best.eye.reached) clamped.push("eyeShift");
2625
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2626
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2627
+ const features = solveFeatureDepths(ctx, targets, best, eyeShift);
2628
+ if ("blocked" in features) {
2629
+ return {
2630
+ unreachable: true,
2631
+ field: features.blocked,
2632
+ value: targets[features.blocked],
2633
+ attainable: features.attainable
2634
+ };
2635
+ }
2636
+ return {
2637
+ unreachable: false,
2638
+ radius: best.radius,
2639
+ holdBase: best.holdBase,
2640
+ holdEdgeAt: best.holdEdgeAt,
2641
+ travel: best.travel,
2642
+ depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
2643
+ achieved: {
2644
+ eyeShift,
2645
+ farEyeRatio: best.ratio,
2646
+ silhouetteRatio: best.renderedSilhouetteRatio
2647
+ },
2648
+ clamped: [...clamped, ...features.clamped]
2649
+ };
2650
+ }
1898
2651
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
1899
2652
  var HAIR_FRONT_DEPTH = 0.1;
1900
- var HAIR_BACK_DEPTH = -0.08;
1901
2653
  var NOD_TRAVEL = 30;
1902
2654
  var NOD_BEND = 0.5;
1903
- var HAIR_BACK_NOD_DEPTH = -0.07;
2655
+ var HAIR_BACK_NOD_DEPTH = -0.135;
2656
+ function headNodParallaxUnit(gridHalfHeight) {
2657
+ return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
2658
+ }
2659
+ var FEATURE_NOD_DEPTH = {
2660
+ eye: 0.04,
2661
+ iris: 0.04,
2662
+ pupil: 0.04,
2663
+ highlight: 0.04,
2664
+ lash: 0.04,
2665
+ brow: 0.04,
2666
+ blush: 0.04,
2667
+ mouth: 0.04,
2668
+ mouth_open: 0.04,
2669
+ nose: 0.08
2670
+ };
2671
+ var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
2672
+ function roleFamily(role) {
2673
+ return role.replace(/_[LR]$/, "");
2674
+ }
2675
+ function turnFamily(role) {
2676
+ const family = roleFamily(role);
2677
+ if (family === "nose") return "nose";
2678
+ if (family === "mouth" || family === "mouth_open") return "mouth";
2679
+ return "eye";
2680
+ }
2681
+ function featureParallaxBindings(role, options) {
2682
+ if (!options.hasNose) return [];
2683
+ const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
2684
+ if (nodDepth === void 0) return [];
2685
+ const bindings = [];
2686
+ const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
2687
+ const shiftX = turnDepth * (options.parallaxUnit ?? 0);
2688
+ if (shiftX !== 0) {
2689
+ bindings.push({
2690
+ parameter: import_format3.StandardParameter.AngleX,
2691
+ channel: "translateX",
2692
+ from: -shiftX,
2693
+ to: shiftX
2694
+ });
2695
+ }
2696
+ const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
2697
+ if (shiftY !== 0) {
2698
+ bindings.push({
2699
+ parameter: import_format3.StandardParameter.AngleY,
2700
+ channel: "translateY",
2701
+ from: -shiftY,
2702
+ to: shiftY
2703
+ });
2704
+ }
2705
+ return bindings;
2706
+ }
1904
2707
  function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2708
+ return [
2709
+ ...roleOwnBindings(spec, role, cropW, cropH, options),
2710
+ ...featureParallaxBindings(role, options)
2711
+ ];
2712
+ }
2713
+ function roleOwnBindings(spec, role, cropW, cropH, options) {
1905
2714
  const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
1906
2715
  if (isEyeStack && spec.eyeSide !== void 0) {
1907
2716
  const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
@@ -2004,18 +2813,8 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
2004
2813
  if (role === "hair_front" || role === "hair_back") {
2005
2814
  const isFront = role === "hair_front";
2006
2815
  const parallax = [];
2007
- if (!isFront) {
2008
- const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
2009
- if (shiftX !== 0) {
2010
- parallax.push({
2011
- parameter: import_format3.StandardParameter.AngleX,
2012
- channel: "translateX",
2013
- from: -shiftX,
2014
- to: shiftX
2015
- });
2016
- }
2017
- }
2018
- const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2816
+ const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
2817
+ const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2019
2818
  if (shiftY !== 0) {
2020
2819
  parallax.push({
2021
2820
  parameter: import_format3.StandardParameter.AngleY,
@@ -2047,35 +2846,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
2047
2846
  ]
2048
2847
  };
2049
2848
  }
2050
- var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
2051
- var HAIR_BACK_FAR_BULGE = 0.22;
2052
- function bakeHairBackTurnWarp(mesh, parameter) {
2053
- let left = Infinity;
2054
- let right = -Infinity;
2055
- for (let i = 0; i < mesh.vertices.length; i += 2) {
2056
- left = Math.min(left, mesh.vertices[i]);
2057
- right = Math.max(right, mesh.vertices[i]);
2849
+ function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
2850
+ let reach = 0;
2851
+ for (const deg of HEAD_TURN_STOPS) {
2852
+ reach = Math.max(
2853
+ reach,
2854
+ plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
2855
+ );
2058
2856
  }
2059
- const halfWidth = (right - left) / 2;
2060
- const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
2061
- const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
2062
- const DEG_TO_RAD = Math.PI / 180;
2857
+ return reach;
2858
+ }
2859
+ function plateReachAt(map, faceCenterX, faceHalfWidth) {
2860
+ let reach = 0;
2861
+ for (const side of [-1, 1]) {
2862
+ const dest = map.mapX(faceCenterX + side * faceHalfWidth);
2863
+ reach = Math.max(reach, Math.abs(dest - faceCenterX));
2864
+ }
2865
+ return reach;
2866
+ }
2867
+ function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
2868
+ if (holdEdgeAt(0) !== holdBase) {
2869
+ throw new Error(
2870
+ `auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
2871
+ );
2872
+ }
2873
+ const SIDES = [-1, 1];
2063
2874
  const keyforms = HEAD_TURN_STOPS.map((deg) => {
2064
- const theta = deg * DEG_TO_RAD;
2065
- const s = Math.sign(deg);
2066
- const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
2067
- const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
2068
- const bulgeAtStop = bulge * deg / HEAD_TURN_MAX_DEG;
2875
+ const map = columnMapAt(deg);
2876
+ const requestedHoldEdge = holdEdgeAt(deg);
2877
+ const plateDest = SIDES.map(
2878
+ (side) => map.mapX(faceCenterX + side * faceHalfWidth)
2879
+ );
2880
+ const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
2881
+ if (requestedHoldEdge <= reached) {
2882
+ const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
2883
+ throw new Error(
2884
+ `auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${requestedHoldEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
2885
+ );
2886
+ }
2887
+ const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
2888
+ (_side, idx) => Math.min(
2889
+ requestedHoldEdge,
2890
+ Math.abs(
2891
+ map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
2892
+ ) - HOLD_CLEARANCE
2893
+ )
2894
+ );
2069
2895
  const offsets = [];
2070
2896
  for (let i = 0; i < mesh.vertices.length; i += 2) {
2071
- const x = mesh.vertices[i];
2072
- const far = Math.max(0, -s * x / halfWidth);
2073
- const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
2074
- offsets.push(bend - bulgeAtStop * far, 0);
2897
+ const x = partX + mesh.vertices[i];
2898
+ const target = hairFrontHoldTarget(
2899
+ x,
2900
+ faceCenterX,
2901
+ faceHalfWidth,
2902
+ holdBase,
2903
+ holdEdge,
2904
+ map
2905
+ );
2906
+ offsets.push(map.invertX(target) - x, 0);
2075
2907
  }
2076
2908
  return { value: deg, offsets };
2077
2909
  });
2078
- return { parameter, keyforms };
2910
+ return { parameter: import_format3.StandardParameter.AngleX, keyforms };
2079
2911
  }
2080
2912
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2081
2913
  var HAIR_SWAY_CURL = 1.5;
@@ -2103,14 +2935,18 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2103
2935
  ]
2104
2936
  };
2105
2937
  }
2106
- var HEAD_TURN_TRAVEL = 50;
2938
+ var HEAD_TURN_TRAVEL_RATIO = 0.25;
2939
+ function headTurnTravel(faceHalfWidth) {
2940
+ return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
2941
+ }
2107
2942
  var BODY_TURN_FOLLOW = 0.3;
2108
2943
  var HEAD_BREATH_BOB = -12;
2109
2944
  var BODY_BREATH_FOLLOW = 0.5;
2110
- function generateIkiFromLayerSet(layers, canvas) {
2945
+ function generateIkiFromLayerSet(layers, canvas, options = {}) {
2111
2946
  validateLayerInputs(layers, canvas);
2112
2947
  const hasHair = layers.some((l) => l.role === "hair_front");
2113
2948
  const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2949
+ const hasNose = layers.some((l) => l.role === "nose");
2114
2950
  const bodyLayer = layers.find((l) => l.role === "body");
2115
2951
  const parameters = [
2116
2952
  {
@@ -2288,8 +3124,53 @@ function generateIkiFromLayerSet(layers, canvas) {
2288
3124
  };
2289
3125
  const faceCenterY = faceTransform.y;
2290
3126
  const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
2291
- const parallaxUnit = headTurnParallaxUnit(halfW);
2292
- const parallaxUnitY = headTurnParallaxUnit(halfH);
3127
+ const faceHalfWidth = faceLayer.cropW / 2;
3128
+ const hairFrontLayer = layers.find((l) => l.role === "hair_front");
3129
+ const turn = hasNose ? solveTurnModel(
3130
+ resolveTurnTargets(options.turnTargets),
3131
+ turnLandmarks(layers),
3132
+ faceGrid,
3133
+ faceCenterX,
3134
+ faceHalfWidth,
3135
+ hairFrontLayer && (() => {
3136
+ const t = bboxToTransform(
3137
+ hairFrontLayer.bbox,
3138
+ hairFrontLayer.canvasW,
3139
+ hairFrontLayer.canvasH,
3140
+ "hair_front"
3141
+ );
3142
+ return {
3143
+ x: t.x,
3144
+ centerY: t.y,
3145
+ cropW: hairFrontLayer.cropW,
3146
+ cropH: hairFrontLayer.cropH
3147
+ };
3148
+ })(),
3149
+ options.headEdges
3150
+ ) : void 0;
3151
+ if (turn?.unreachable) {
3152
+ throw new TurnTargetError(
3153
+ turn.attainable === void 0 ? (
3154
+ // Only a silhouette can land here: nothing the field's own accepted
3155
+ // range allows renders on this layer set, so there is no interval to
3156
+ // offer instead — see `silhouetteMiss` in evaluateTurnCandidate.
3157
+ `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}]`
3158
+ ) : `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set (attainable ${turn.attainable[0]}\u2026${turn.attainable[1]})`
3159
+ );
3160
+ }
3161
+ if (turn) {
3162
+ options.onTurnSolved?.({
3163
+ radius: turn.radius,
3164
+ holdBase: turn.holdBase,
3165
+ depths: turn.depths,
3166
+ achieved: turn.achieved,
3167
+ clamped: turn.clamped
3168
+ });
3169
+ }
3170
+ const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
3171
+ const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
3172
+ const parallaxUnit = headTurnParallaxUnit(faceRadius);
3173
+ const parallaxUnitY = headNodParallaxUnit(halfH);
2293
3174
  const faceBottom = faceTransform.y - faceCropH / 2;
2294
3175
  const neckPivot = {
2295
3176
  x: faceCenterX,
@@ -2301,26 +3182,27 @@ function generateIkiFromLayerSet(layers, canvas) {
2301
3182
  import_format3.StandardParameter.AngleX,
2302
3183
  import_format3.StandardParameter.AngleY,
2303
3184
  faceCenterX,
2304
- faceCenterY
3185
+ faceCenterY,
3186
+ faceRadius,
3187
+ headTravel
2305
3188
  );
2306
3189
  const deformers = [
2307
3190
  // headDeformer: rigid matrix rotating/translating the whole head about the
2308
- // neck pivot; bindings mirror sample-model.ts exactly.
3191
+ // neck pivot. Same bindings as sample-model.ts apart from the turn, which
3192
+ // moves the face inside the head rather than the head itself (see below).
2309
3193
  {
2310
3194
  id: "headDeformer",
2311
3195
  pivot: neckPivot,
2312
3196
  bindings: [
2313
- // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2314
- // ±6° "lean into the turn" was tried here and dropped — rotating about
2315
- // the neck pivot swings the crown (~500px above it) far more than the
2316
- // chin, so the top of the head appeared to lunge ahead of the face on
2317
- // every turn. Roll is AngleZ's job, below.
2318
- {
2319
- parameter: import_format3.StandardParameter.AngleX,
2320
- channel: "translateX",
2321
- from: -HEAD_TURN_TRAVEL,
2322
- to: HEAD_TURN_TRAVEL
2323
- },
3197
+ // No AngleX binding: the turn moves NOTHING rigidly. Its sideways
3198
+ // travel is baked into faceWarp's own grid (see
3199
+ // bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
3200
+ // a silhouette the bangs hold; a translate here would take the hair
3201
+ // shell with it. Nor is the turn a roll — the sample model's ±6° "lean
3202
+ // into the turn" was tried here and dropped, since rotating about the
3203
+ // neck pivot swings the crown (~500px above it) far more than the chin,
3204
+ // so the top of the head appeared to lunge ahead of the face on every
3205
+ // turn. Roll is AngleZ's job, below.
2324
3206
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
2325
3207
  // rigid rotation would sum with the AngleZ roll below at diagonal
2326
3208
  // poses, collapsing pitch into roll.
@@ -2373,12 +3255,13 @@ function generateIkiFromLayerSet(layers, canvas) {
2373
3255
  id: "bodyDeformer",
2374
3256
  pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
2375
3257
  bindings: [
2376
- // Low-weight follow of the turn, same direction as the head.
3258
+ // Low-weight follow of the turn, same direction as the face's own
3259
+ // slide — the only rigid part of the travel left.
2377
3260
  {
2378
3261
  parameter: import_format3.StandardParameter.AngleX,
2379
3262
  channel: "translateX",
2380
- from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
2381
- to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
3263
+ from: -BODY_TURN_FOLLOW * headTravel,
3264
+ to: BODY_TURN_FOLLOW * headTravel
2382
3265
  },
2383
3266
  // Breath: follow the head's bob, same direction, half amplitude.
2384
3267
  {
@@ -2410,7 +3293,9 @@ function generateIkiFromLayerSet(layers, canvas) {
2410
3293
  const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
2411
3294
  hasMouthOpen,
2412
3295
  parallaxUnit,
2413
- parallaxUnitY
3296
+ parallaxUnitY,
3297
+ hasNose,
3298
+ turnDepths: turn?.depths
2414
3299
  });
2415
3300
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2416
3301
  if (spec.mesh) {
@@ -2432,6 +3317,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2432
3317
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
2433
3318
  let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2434
3319
  let leadWarp;
3320
+ let silhouetteWarp;
2435
3321
  if (role === "hair_front") {
2436
3322
  const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2437
3323
  const headroom = Math.min(
@@ -2448,6 +3334,17 @@ function generateIkiFromLayerSet(layers, canvas) {
2448
3334
  shift,
2449
3335
  HEAD_TURN_MAX_DEG
2450
3336
  );
3337
+ const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
3338
+ const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
3339
+ silhouetteWarp = bakeHairFrontSilhouetteWarp(
3340
+ mesh,
3341
+ t.x,
3342
+ faceCenterX,
3343
+ faceHalfWidth,
3344
+ holdBase,
3345
+ turn?.holdEdgeAt ?? (() => holdBase),
3346
+ columnMapAt
3347
+ );
2451
3348
  }
2452
3349
  part.warps = [
2453
3350
  bakeHairSwayWarp(
@@ -2462,13 +3359,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2462
3359
  tipShift,
2463
3360
  HAIR_SWAY_RANGE
2464
3361
  ),
2465
- ...leadWarp ? [leadWarp] : []
2466
- ];
2467
- }
2468
- if (role === "hair_back") {
2469
- part.warps = [
2470
- ...part.warps ?? [],
2471
- bakeHairBackTurnWarp(mesh, import_format3.StandardParameter.AngleX)
3362
+ ...leadWarp ? [leadWarp] : [],
3363
+ ...silhouetteWarp ? [silhouetteWarp] : []
2472
3364
  ];
2473
3365
  }
2474
3366
  if (spec.eyeSide !== void 0) {
@@ -2549,6 +3441,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2549
3441
  AddPart,
2550
3442
  AddPhysicsRig,
2551
3443
  CaptureGridKeyform,
3444
+ DEFAULT_TURN_TARGETS,
2552
3445
  DeleteDeformer,
2553
3446
  DeletePart,
2554
3447
  DeletePhysicsRig,
@@ -2568,6 +3461,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2568
3461
  SetPartTransform,
2569
3462
  SetPartWidth,
2570
3463
  SetPhysicsRig,
3464
+ TurnTargetError,
2571
3465
  UV_INSET_PX,
2572
3466
  captureBindingEndpoint,
2573
3467
  computeGridOffsets,