@ikijs/editor 0.6.0 → 0.8.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
@@ -1837,6 +1837,164 @@ function validateLayerInputs(layers, canvas) {
1837
1837
  }
1838
1838
  }
1839
1839
  }
1840
+ if (layer.denseCore !== void 0) {
1841
+ const where = `auto-rig: validateLayerInputs: role "${role}" denseCore`;
1842
+ const core = layer.denseCore;
1843
+ if (typeof core !== "object" || core === null || Array.isArray(core)) {
1844
+ throw new Error(`${where} must be a plain object { x, y, w, h }`);
1845
+ }
1846
+ for (const field of ["x", "y", "w", "h"]) {
1847
+ const v = core[field];
1848
+ const size = field === "w" || field === "h";
1849
+ if (typeof v !== "number" || !Number.isFinite(v) || size && v <= 0) {
1850
+ throw new Error(
1851
+ `${where}.${field} is ${String(v)}, not a finite number${size ? " > 0" : ""}`
1852
+ );
1853
+ }
1854
+ }
1855
+ const { x, y, w, h } = layer.denseCore;
1856
+ const right = bbox.x + bbox.w;
1857
+ const bottom = bbox.y + bbox.h;
1858
+ if (x < bbox.x) {
1859
+ throw new Error(
1860
+ `${where}.x (${x}) lies left of the bbox's left edge (${bbox.x})`
1861
+ );
1862
+ }
1863
+ if (y < bbox.y) {
1864
+ throw new Error(
1865
+ `${where}.y (${y}) lies above the bbox's top edge (${bbox.y})`
1866
+ );
1867
+ }
1868
+ if (x + w > right) {
1869
+ throw new Error(
1870
+ `${where}.w (${w}) puts its right edge at ${x + w}, past the bbox's right edge (${right})`
1871
+ );
1872
+ }
1873
+ if (y + h > bottom) {
1874
+ throw new Error(
1875
+ `${where}.h (${h}) puts its bottom edge at ${y + h}, past the bbox's bottom edge (${bottom})`
1876
+ );
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+ function validateStrandEdges(layers, strandEdges) {
1882
+ if (strandEdges === void 0) return;
1883
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1884
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1885
+ if (!isPlainObject(strandEdges)) {
1886
+ throw new Error(
1887
+ `${where} must be a plain object with optional left / right sides`
1888
+ );
1889
+ }
1890
+ for (const key of ["left", "right"]) {
1891
+ const raw = strandEdges[key];
1892
+ if (raw === void 0) continue;
1893
+ const path = `${where}.${key}`;
1894
+ if (!isPlainObject(raw)) {
1895
+ throw new Error(`${path} must be a plain object`);
1896
+ }
1897
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1898
+ const v = raw[field];
1899
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1900
+ throw new Error(
1901
+ `${path}.${field} is ${String(v)}, not a finite number`
1902
+ );
1903
+ }
1904
+ }
1905
+ const rawFace = raw.runFace;
1906
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1907
+ throw new Error(
1908
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1909
+ );
1910
+ }
1911
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1912
+ const cropOf = (role) => {
1913
+ const layer = layers.find((l) => l.role === role);
1914
+ if (layer === void 0) {
1915
+ throw new Error(
1916
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1917
+ );
1918
+ }
1919
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1920
+ return {
1921
+ role,
1922
+ x: t.x,
1923
+ left: t.x - layer.cropW / 2,
1924
+ right: t.x + layer.cropW / 2,
1925
+ bottom: t.y - layer.cropH / 2,
1926
+ top: t.y + layer.cropH / 2
1927
+ };
1928
+ };
1929
+ const hair = cropOf("hair_front");
1930
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1931
+ (a, b) => a.x - b.x
1932
+ );
1933
+ const sign = key === "left" ? -1 : 1;
1934
+ const iris = key === "left" ? lowIris : highIris;
1935
+ const other = key === "left" ? highIris : lowIris;
1936
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1937
+ for (const crop of [iris, hair]) {
1938
+ if (y < crop.bottom || y > crop.top) {
1939
+ throw fail(
1940
+ "y",
1941
+ y,
1942
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1943
+ );
1944
+ }
1945
+ }
1946
+ for (const [field, x] of [
1947
+ ["irisOuter", irisOuter],
1948
+ ["irisInner", irisInner]
1949
+ ]) {
1950
+ if (x < iris.left || x > iris.right) {
1951
+ throw fail(
1952
+ field,
1953
+ x,
1954
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1955
+ );
1956
+ }
1957
+ }
1958
+ if (sign * (irisOuter - irisInner) <= 0) {
1959
+ throw fail(
1960
+ "irisOuter",
1961
+ irisOuter,
1962
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1963
+ );
1964
+ }
1965
+ for (const [field, x] of [
1966
+ ["runOuter", runOuter],
1967
+ ["runFace", runFace]
1968
+ ]) {
1969
+ if (x !== null && (x < hair.left || x > hair.right)) {
1970
+ throw fail(
1971
+ field,
1972
+ x,
1973
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1974
+ );
1975
+ }
1976
+ }
1977
+ if (sign * (runOuter - iris.x) <= 0) {
1978
+ throw fail(
1979
+ "runOuter",
1980
+ runOuter,
1981
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1982
+ );
1983
+ }
1984
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1985
+ throw fail(
1986
+ "runOuter",
1987
+ runOuter,
1988
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1989
+ );
1990
+ }
1991
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1992
+ throw fail(
1993
+ "runFace",
1994
+ runFace,
1995
+ `must lie strictly on this side of the "${other.role}" crop's centre (${other.x}); a run that reaches it has no face-side end here, which is runFace null`
1996
+ );
1997
+ }
1840
1998
  }
1841
1999
  }
1842
2000
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1995,21 +2153,26 @@ function turnSurface(spec) {
1995
2153
  )
1996
2154
  };
1997
2155
  }
1998
- function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
2156
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1999
2157
  const STOPS = [...HEAD_TURN_STOPS];
2000
2158
  const pointCount = grid.points.length / 2;
2001
2159
  const keyforms2d = [];
2002
2160
  for (const angleY of STOPS) {
2003
2161
  for (const angleX of STOPS) {
2004
2162
  const shift = shiftAt(angleX);
2163
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
2005
2164
  const offsets = [];
2006
2165
  for (let i = 0; i < pointCount; i++) {
2007
2166
  const x = grid.points[i * 2];
2008
2167
  const y = grid.points[i * 2 + 1];
2009
- offsets.push(
2010
- angleX === 0 ? 0 : surface.mapAt(angleX, y).mapX(x + shift) - x,
2011
- angleY === 0 ? 0 : surface.nodBendAt(y, angleY)
2012
- );
2168
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2169
+ if (angleX === 0) {
2170
+ offsets.push(0, nodDy);
2171
+ continue;
2172
+ }
2173
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2174
+ const turnDy = landed.y - y;
2175
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
2013
2176
  }
2014
2177
  keyforms2d.push({ offsets });
2015
2178
  }
@@ -2129,6 +2292,22 @@ function carrierLandingX(carrier, nodeLanding, px, py) {
2129
2292
  py
2130
2293
  );
2131
2294
  }
2295
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2296
+ if (anchor === void 0) {
2297
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2298
+ }
2299
+ const anchorRow = mapAt(anchor.y);
2300
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2301
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2302
+ const dy = nodeY - anchor.y;
2303
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2304
+ const cos = Math.cos(phi);
2305
+ const sin = Math.sin(phi);
2306
+ return {
2307
+ x: pivotX + cos * dx - sin * dy,
2308
+ y: anchor.y + sin * dx + cos * dy
2309
+ };
2310
+ }
2132
2311
  function rowMapsOf(surface) {
2133
2312
  const maps = /* @__PURE__ */ new Map();
2134
2313
  return (deg, nodeY) => {
@@ -2251,12 +2430,17 @@ function resolveTurnTargets(targets = {}) {
2251
2430
  defaulted
2252
2431
  };
2253
2432
  }
2433
+ function noseDrawnBox(layer) {
2434
+ return layer.denseCore ?? layer.bbox;
2435
+ }
2254
2436
  function turnLandmarks(layers, carriers) {
2255
2437
  const markOf = (role) => {
2256
2438
  const layer = layers.find((l) => l.role === role);
2257
2439
  if (!layer) return void 0;
2258
- const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2259
- const mark = { x: t.x, y: t.y, w: layer.cropW };
2440
+ const drawn = role === "nose" ? noseDrawnBox(layer) : layer.bbox;
2441
+ const t = bboxToTransform(drawn, layer.canvasW, layer.canvasH, role);
2442
+ const w = drawn === layer.bbox ? layer.cropW : drawn.w;
2443
+ const mark = { x: t.x, y: t.y, w };
2260
2444
  const carrier = carriers?.get(role);
2261
2445
  if (carrier !== void 0) mark.carrier = carrier;
2262
2446
  return mark;
@@ -2282,11 +2466,12 @@ function eyeRowOf(landmarks, fallback) {
2282
2466
  }
2283
2467
  var TURN_BISECT_STEPS = 40;
2284
2468
  var TURN_DEPTH_EPS = 1e-6;
2285
- function landmarkLandingX(landmark, mapAt, shift, px, py) {
2286
- if (landmark.carrier === void 0) return mapAt(py).mapX(px + shift);
2469
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2470
+ const { carrier } = landmark;
2471
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2287
2472
  return carrierLandingX(
2288
- landmark.carrier,
2289
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2473
+ carrier,
2474
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2290
2475
  px,
2291
2476
  py
2292
2477
  );
@@ -2295,22 +2480,47 @@ function familyReachPx(landmarks, plateEdgeX) {
2295
2480
  const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2296
2481
  return Math.max(0, farEdge - plateEdgeX);
2297
2482
  }
2298
- function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
2483
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2484
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2485
+ }
2486
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
2299
2487
  if (landmarks.length === 0) {
2300
2488
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2301
2489
  }
2302
- const cap = familyReachPx(landmarks, plateEdgeX) / unit;
2490
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2491
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2303
2492
  const achieved = (d) => landmarks.reduce(
2304
- (sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
2493
+ (sum, l) => sum + (landmarkLandingX(
2494
+ l,
2495
+ mapAt,
2496
+ -HEAD_TURN_MAX_DEG,
2497
+ -d * unit,
2498
+ l.x,
2499
+ l.y ?? 0
2500
+ ) - l.x),
2305
2501
  0
2306
2502
  ) / landmarks.length;
2307
2503
  const atCap = achieved(cap);
2308
2504
  const atRest = achieved(0);
2309
2505
  const attainable = [atCap, atRest];
2310
- if (target > atRest + TURN_DEPTH_EPS)
2311
- return { reached: false, depth: 0, achieved: atRest, attainable };
2312
- if (target < atCap - TURN_DEPTH_EPS)
2313
- return { reached: false, depth: cap, achieved: atCap, attainable };
2506
+ if (target > atRest + TURN_DEPTH_EPS) {
2507
+ return {
2508
+ reached: false,
2509
+ depth: 0,
2510
+ achieved: atRest,
2511
+ attainable,
2512
+ limit: "floor"
2513
+ };
2514
+ }
2515
+ if (target < atCap - TURN_DEPTH_EPS) {
2516
+ return {
2517
+ reached: false,
2518
+ depth: cap,
2519
+ achieved: atCap,
2520
+ attainable,
2521
+ limit: "cap"
2522
+ };
2523
+ }
2314
2524
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2315
2525
  let lo = 0;
2316
2526
  let hi = cap;
@@ -2340,7 +2550,7 @@ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, hol
2340
2550
  }
2341
2551
  return x + side * (holdEdge - holdBase);
2342
2552
  }
2343
- function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2553
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2344
2554
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2345
2555
  const top = hairFront.centerY + hairFront.cropH / 2;
2346
2556
  return renderedLandingX(
@@ -2355,7 +2565,7 @@ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2355
2565
  scaleY: 1,
2356
2566
  rotation: 0
2357
2567
  },
2358
- (vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2568
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2359
2569
  x,
2360
2570
  y
2361
2571
  );
@@ -2388,6 +2598,19 @@ function aitken(x0, x1, x2, cap) {
2388
2598
  const x = x2 - d2 * d2 / denominator;
2389
2599
  return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2390
2600
  }
2601
+ function strandDepthCap(margins, plateCap) {
2602
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2603
+ if (holds(plateCap)) return plateCap;
2604
+ if (!holds(0)) return void 0;
2605
+ let lo = 0;
2606
+ let hi = plateCap;
2607
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2608
+ const mid = (lo + hi) / 2;
2609
+ if (holds(mid)) lo = mid;
2610
+ else hi = mid;
2611
+ }
2612
+ return lo;
2613
+ }
2391
2614
  function evaluateTurnCandidate(ctx, radius) {
2392
2615
  const surfaceAt = (travel) => turnSurface({
2393
2616
  faceCenterX: ctx.faceCenterX,
@@ -2411,7 +2634,104 @@ function evaluateTurnCandidate(ctx, radius) {
2411
2634
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2412
2635
  };
2413
2636
  const unit = headTurnParallaxUnit(radius);
2637
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2638
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2414
2639
  const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2640
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2641
+ x,
2642
+ y,
2643
+ ctx.hairFrontSilhouette,
2644
+ (vx, vy) => hairFrontHoldTarget(
2645
+ vx,
2646
+ vy,
2647
+ deg,
2648
+ ctx.faceCenterX,
2649
+ plateLandingAt,
2650
+ ctx.edgeAt,
2651
+ hold.holdBase,
2652
+ hold.holdEdgeAt(deg)
2653
+ ),
2654
+ leadShift
2655
+ );
2656
+ const strandStopsOn = (hold) => {
2657
+ const rowMaps = rowMapsOf(hold.surface);
2658
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2659
+ return (ctx.strands ?? []).map((strand) => {
2660
+ const iris = {
2661
+ x: strand.carrier.part.x,
2662
+ w: strand.carrier.part.cropW,
2663
+ carrier: strand.carrier
2664
+ };
2665
+ const stops = HEAD_TURN_STOPS.filter(
2666
+ (deg) => Math.sign(deg) === strand.side
2667
+ ).map((deg) => {
2668
+ const runAt = (x) => bangsLandingAt(
2669
+ hold,
2670
+ plateLandingAt,
2671
+ deg,
2672
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2673
+ x,
2674
+ strand.y
2675
+ );
2676
+ return {
2677
+ deg,
2678
+ runOuter: runAt(strand.runOuter),
2679
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2680
+ irisAt: (depth, x) => landmarkLandingX(
2681
+ iris,
2682
+ (y) => rowMaps(deg, y),
2683
+ deg,
2684
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2685
+ x,
2686
+ strand.y
2687
+ )
2688
+ };
2689
+ });
2690
+ const { runFace } = strand;
2691
+ if (runFace === null) return { strand, stops, margins: void 0 };
2692
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2693
+ const margins = stops.map(
2694
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2695
+ );
2696
+ return { strand, stops, margins };
2697
+ });
2698
+ };
2699
+ const strandOverlapOf = (sides, depth) => {
2700
+ const overlap = {};
2701
+ for (const { strand, stops, margins } of sides) {
2702
+ const s = strand.side;
2703
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2704
+ 0,
2705
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2706
+ );
2707
+ let worst;
2708
+ for (const stop of stops) {
2709
+ const px = covered(
2710
+ stop.irisAt(depth, strand.irisOuter),
2711
+ stop.irisAt(depth, strand.irisInner),
2712
+ stop.runOuter,
2713
+ stop.runFace
2714
+ );
2715
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2716
+ worst = { deg: stop.deg, px };
2717
+ }
2718
+ }
2719
+ if (worst === void 0) continue;
2720
+ overlap[s < 0 ? "left" : "right"] = {
2721
+ deg: worst.deg,
2722
+ px: worst.px,
2723
+ hh: worst.px / ctx.hh,
2724
+ restPx: covered(
2725
+ strand.irisOuter,
2726
+ strand.irisInner,
2727
+ strand.runOuter,
2728
+ strand.runFace
2729
+ ),
2730
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2731
+ };
2732
+ }
2733
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2734
+ };
2415
2735
  const evaluateHold = (ratio, depths2) => {
2416
2736
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2417
2737
  const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
@@ -2461,21 +2781,13 @@ function evaluateTurnCandidate(ctx, radius) {
2461
2781
  if (edge !== void 0) return edge;
2462
2782
  return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2463
2783
  };
2464
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2784
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2785
+ { holdBase, holdEdgeAt },
2786
+ plateLandingAt,
2787
+ -HEAD_TURN_MAX_DEG,
2788
+ -leadTipShift,
2465
2789
  x,
2466
- ctx.eyeRowY,
2467
- ctx.hairFrontSilhouette,
2468
- (vx, vy) => hairFrontHoldTarget(
2469
- vx,
2470
- vy,
2471
- -HEAD_TURN_MAX_DEG,
2472
- ctx.faceCenterX,
2473
- plateLandingAt,
2474
- ctx.edgeAt,
2475
- holdBase,
2476
- holdEdgeAt(-HEAD_TURN_MAX_DEG)
2477
- ),
2478
- leadTipShift
2790
+ ctx.eyeRowY
2479
2791
  );
2480
2792
  const landingOfRole = (role, x) => {
2481
2793
  if (role === "hair_front") return hairFrontAt(x);
@@ -2495,7 +2807,14 @@ function evaluateTurnCandidate(ctx, radius) {
2495
2807
  const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2496
2808
  return carrierLandingX(
2497
2809
  carrier,
2498
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2810
+ (nodeX, nodeY) => groupNodeLanding(
2811
+ carrier.anchor,
2812
+ mapAt,
2813
+ -HEAD_TURN_MAX_DEG,
2814
+ shift,
2815
+ nodeX,
2816
+ nodeY
2817
+ ).x,
2499
2818
  x,
2500
2819
  ctx.eyeRowY
2501
2820
  );
@@ -2636,12 +2955,25 @@ function evaluateTurnCandidate(ctx, radius) {
2636
2955
  "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2637
2956
  );
2638
2957
  }
2958
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2959
+ let strandFeasible = true;
2960
+ let maxDepth;
2961
+ if (strandSides !== void 0) {
2962
+ const margins = strandSides.map((s) => s.margins);
2963
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2964
+ margins.flatMap((m) => m),
2965
+ eyePlateCap
2966
+ ) : void 0;
2967
+ strandFeasible = cap !== void 0;
2968
+ maxDepth = cap ?? 0;
2969
+ }
2639
2970
  const eye = solveTurnDepthSigned(
2640
2971
  hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2641
2972
  ctx.landmarks.eye,
2642
2973
  unit,
2643
2974
  hold.mapAt,
2644
- ctx.plateEdgeX
2975
+ ctx.plateEdgeX,
2976
+ maxDepth
2645
2977
  );
2646
2978
  const features = solveFeatureDepths(
2647
2979
  ctx,
@@ -2662,25 +2994,19 @@ function evaluateTurnCandidate(ctx, radius) {
2662
2994
  seed = depths;
2663
2995
  depths = solved;
2664
2996
  } else {
2665
- const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
2666
2997
  depths = {
2667
- eye: aitken(
2668
- seed.eye,
2669
- depths.eye,
2670
- solved.eye,
2671
- capOf(ctx.landmarks.eye)
2672
- ),
2998
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2673
2999
  nose: aitken(
2674
3000
  seed.nose,
2675
3001
  depths.nose,
2676
3002
  solved.nose,
2677
- capOf(ctx.landmarks.nose)
3003
+ plateCapOf(ctx.landmarks.nose)
2678
3004
  ),
2679
3005
  mouth: aitken(
2680
3006
  seed.mouth,
2681
3007
  depths.mouth,
2682
3008
  solved.mouth,
2683
- capOf(ctx.landmarks.mouth)
3009
+ plateCapOf(ctx.landmarks.mouth)
2684
3010
  )
2685
3011
  };
2686
3012
  seed = void 0;
@@ -2699,7 +3025,7 @@ function evaluateTurnCandidate(ctx, radius) {
2699
3025
  if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2700
3026
  return silhouetteMiss(span);
2701
3027
  }
2702
- if (!eye.reached && !ctx.clampEyeShift) {
3028
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2703
3029
  return {
2704
3030
  blocked: "eyeShift",
2705
3031
  // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
@@ -2712,9 +3038,24 @@ function evaluateTurnCandidate(ctx, radius) {
2712
3038
  };
2713
3039
  }
2714
3040
  const eyeShiftPx = -eye.depth * unit;
2715
- const scaleOf = (l) => (landmarkLandingX(l, mapAt, eyeShiftPx, l.x + l.w / 2, l.y ?? 0) - landmarkLandingX(l, mapAt, eyeShiftPx, l.x - l.w / 2, l.y ?? 0)) / l.w;
3041
+ const scaleOf = (l) => (landmarkLandingX(
3042
+ l,
3043
+ mapAt,
3044
+ -HEAD_TURN_MAX_DEG,
3045
+ eyeShiftPx,
3046
+ l.x + l.w / 2,
3047
+ l.y ?? 0
3048
+ ) - landmarkLandingX(
3049
+ l,
3050
+ mapAt,
3051
+ -HEAD_TURN_MAX_DEG,
3052
+ eyeShiftPx,
3053
+ l.x - l.w / 2,
3054
+ l.y ?? 0
3055
+ )) / l.w;
2716
3056
  const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2717
3057
  const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
3058
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2718
3059
  return {
2719
3060
  radius,
2720
3061
  ratio: scaleOf(far) / scaleOf(near),
@@ -2729,7 +3070,9 @@ function evaluateTurnCandidate(ctx, radius) {
2729
3070
  mouth: features.mouth,
2730
3071
  silhouetteClamped,
2731
3072
  silhouetteCenterShift,
2732
- renderedSilhouetteRatio
3073
+ renderedSilhouetteRatio,
3074
+ strandFeasible,
3075
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2733
3076
  };
2734
3077
  }
2735
3078
  }
@@ -2766,6 +3109,9 @@ function sweepTurnRadii(ctx) {
2766
3109
  unsettled
2767
3110
  };
2768
3111
  }
3112
+ function runHolds(run, ratio) {
3113
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
3114
+ }
2769
3115
  function ratioClusters(candidates) {
2770
3116
  const clusters = [];
2771
3117
  let run = [];
@@ -2788,7 +3134,7 @@ function ratioClusters(candidates) {
2788
3134
  close();
2789
3135
  return clusters;
2790
3136
  }
2791
- function fitTurnRadius(ctx, candidates, target) {
3137
+ function fitTurnRadius(ctx, candidates, target, accept) {
2792
3138
  let lo = candidates[0];
2793
3139
  let hi = candidates[candidates.length - 1];
2794
3140
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2798,13 +3144,42 @@ function fitTurnRadius(ctx, candidates, target) {
2798
3144
  break;
2799
3145
  }
2800
3146
  }
3147
+ return bisectTurnRadius(
3148
+ lo,
3149
+ hi,
3150
+ target,
3151
+ (radius) => {
3152
+ const mid = evaluateTurnCandidate(ctx, radius);
3153
+ return "blocked" in mid ? void 0 : mid;
3154
+ },
3155
+ accept
3156
+ );
3157
+ }
3158
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3159
+ if (!accept(lo) || !accept(hi)) {
3160
+ throw new Error(
3161
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3162
+ );
3163
+ }
3164
+ const miss = (c) => Math.abs(c.ratio - target);
3165
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2801
3166
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2802
- const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2803
- if ("blocked" in mid) break;
3167
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3168
+ if (mid === void 0) break;
3169
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2804
3170
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2805
3171
  else lo = mid;
2806
3172
  }
2807
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3173
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3174
+ return nearest;
3175
+ }
3176
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3177
+ const feasible = candidates.filter((c) => c.strandFeasible);
3178
+ if (feasible.length === candidates.length) return candidates;
3179
+ const holds = ratioClusters(feasible).some(
3180
+ (run) => runHolds(run, farEyeRatio)
3181
+ );
3182
+ return holds ? feasible : candidates;
2808
3183
  }
2809
3184
  function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2810
3185
  const solve = (measured, marks, share) => solveTurnDepthSigned(
@@ -2819,13 +3194,38 @@ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2819
3194
  mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2820
3195
  };
2821
3196
  }
2822
- function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
3197
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2823
3198
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2824
3199
  throw new TurnTargetError(
2825
3200
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2826
3201
  );
2827
3202
  }
2828
3203
  const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3204
+ let strands;
3205
+ const strandSides = ["left", "right"].filter(
3206
+ (key) => strandEdges?.[key] !== void 0
3207
+ );
3208
+ if (strandSides.length > 0) {
3209
+ if (hairFront === void 0) {
3210
+ throw new Error(
3211
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3212
+ );
3213
+ }
3214
+ const irises = ["iris_L", "iris_R"].map((role) => carriers.get(role)).filter((c) => c !== void 0).sort((a, b) => a.part.x - b.part.x);
3215
+ strands = strandSides.map((key) => {
3216
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3217
+ if (carrier === void 0) {
3218
+ throw new Error(
3219
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3220
+ );
3221
+ }
3222
+ return {
3223
+ ...strandEdges[key],
3224
+ side: key === "left" ? -1 : 1,
3225
+ carrier
3226
+ };
3227
+ });
3228
+ }
2829
3229
  const ctx = {
2830
3230
  landmarks,
2831
3231
  lattice,
@@ -2857,9 +3257,9 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2857
3257
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2858
3258
  hairFrontSilhouette: hairFront,
2859
3259
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2860
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3260
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3261
+ strands
2861
3262
  };
2862
- const clamped = [];
2863
3263
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2864
3264
  const pass = sweepTurnRadii(ctx);
2865
3265
  if (pass.candidates.length === 0) {
@@ -2890,92 +3290,108 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2890
3290
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2891
3291
  };
2892
3292
  }
2893
- const clusters = ratioClusters(pass.candidates);
2894
- const distanceTo = (c) => Math.max(
2895
- c.ratios[0] - targets.farEyeRatio,
2896
- targets.farEyeRatio - c.ratios[1],
2897
- 0
2898
- );
2899
- let farEyeRatio = targets.farEyeRatio;
2900
- let cluster = clusters.find(
2901
- (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2902
- );
2903
- if (cluster === void 0) {
2904
- cluster = clusters.reduce(
2905
- (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3293
+ const fitFrom = (pool, accept) => {
3294
+ const clamped = [];
3295
+ const clusters = ratioClusters(pool);
3296
+ const distanceTo = (c) => Math.max(
3297
+ c.ratios[0] - targets.farEyeRatio,
3298
+ targets.farEyeRatio - c.ratios[1],
3299
+ 0
2906
3300
  );
2907
- if (!targets.defaulted.has("farEyeRatio")) {
2908
- return {
2909
- unreachable: true,
2910
- field: "farEyeRatio",
2911
- value: targets.farEyeRatio,
2912
- attainable: cluster.ratios
2913
- };
2914
- }
2915
- farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2916
- clamped.push("farEyeRatio");
2917
- }
2918
- const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2919
- if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2920
- if (!targets.defaulted.has("farEyeRatio")) {
2921
- return {
2922
- unreachable: true,
2923
- field: "farEyeRatio",
2924
- value: targets.farEyeRatio,
2925
- attainable: cluster.ratios
2926
- };
3301
+ let farEyeRatio = targets.farEyeRatio;
3302
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3303
+ if (cluster === void 0) {
3304
+ cluster = clusters.reduce(
3305
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3306
+ );
3307
+ if (!targets.defaulted.has("farEyeRatio")) {
3308
+ return {
3309
+ unreachable: true,
3310
+ field: "farEyeRatio",
3311
+ value: targets.farEyeRatio,
3312
+ attainable: cluster.ratios
3313
+ };
3314
+ }
3315
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3316
+ clamped.push("farEyeRatio");
3317
+ }
3318
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3319
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3320
+ if (!targets.defaulted.has("farEyeRatio")) {
3321
+ return {
3322
+ unreachable: true,
3323
+ field: "farEyeRatio",
3324
+ value: targets.farEyeRatio,
3325
+ attainable: cluster.ratios
3326
+ };
3327
+ }
3328
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3329
+ }
3330
+ if (!best.eye.reached) clamped.push("eyeShift");
3331
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3332
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3333
+ for (const [field, solution] of [
3334
+ ["noseShift", best.nose],
3335
+ ["mouthShift", best.mouth]
3336
+ ]) {
3337
+ if (solution.reached) continue;
3338
+ if (!targets.defaulted.has(field)) {
3339
+ return {
3340
+ unreachable: true,
3341
+ field,
3342
+ value: targets[field],
3343
+ // noseShift/mouthShift are MAGNITUDES too (see
3344
+ // solveFeatureDepths), so a negative lower bound here would
3345
+ // advertise a value the field's own contract already rules out; 0
3346
+ // is the true floor.
3347
+ attainable: [
3348
+ Math.max(
3349
+ 0,
3350
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3351
+ ),
3352
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3353
+ ]
3354
+ };
3355
+ }
3356
+ clamped.push(field);
2927
3357
  }
2928
- if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2929
- }
2930
- if (!best.eye.reached) clamped.push("eyeShift");
2931
- if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2932
- const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2933
- for (const [field, solution] of [
2934
- ["noseShift", best.nose],
2935
- ["mouthShift", best.mouth]
2936
- ]) {
2937
- if (solution.reached) continue;
2938
- if (!targets.defaulted.has(field)) {
2939
- return {
2940
- unreachable: true,
2941
- field,
2942
- value: targets[field],
2943
- // noseShift/mouthShift are MAGNITUDES too (see solveFeatureDepths),
2944
- // so a negative lower bound here would advertise a value the field's
2945
- // own contract already rules out; 0 is the true floor.
2946
- attainable: [
2947
- Math.max(
2948
- 0,
2949
- (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2950
- ),
2951
- (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2952
- ]
2953
- };
3358
+ return {
3359
+ unreachable: false,
3360
+ radius: best.radius,
3361
+ holdBase: best.holdBase,
3362
+ holdEdgeAt: best.holdEdgeAt,
3363
+ travel: best.travel,
3364
+ surface: best.surface,
3365
+ depths: {
3366
+ eye: best.eye.depth,
3367
+ nose: best.nose.depth,
3368
+ mouth: best.mouth.depth
3369
+ },
3370
+ achieved: {
3371
+ eyeShift,
3372
+ farEyeRatio: best.ratio,
3373
+ silhouetteRatio: best.renderedSilhouetteRatio
3374
+ },
3375
+ clamped,
3376
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
3377
+ };
3378
+ };
3379
+ const preferred = strandPreferredCandidates(
3380
+ pass.candidates,
3381
+ targets.farEyeRatio
3382
+ );
3383
+ if (preferred !== pass.candidates) {
3384
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3385
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3386
+ return fitted;
2954
3387
  }
2955
- clamped.push(field);
2956
3388
  }
2957
- return {
2958
- unreachable: false,
2959
- radius: best.radius,
2960
- holdBase: best.holdBase,
2961
- holdEdgeAt: best.holdEdgeAt,
2962
- travel: best.travel,
2963
- surface: best.surface,
2964
- depths: {
2965
- eye: best.eye.depth,
2966
- nose: best.nose.depth,
2967
- mouth: best.mouth.depth
2968
- },
2969
- achieved: {
2970
- eyeShift,
2971
- farEyeRatio: best.ratio,
2972
- silhouetteRatio: best.renderedSilhouetteRatio
2973
- },
2974
- clamped
2975
- };
3389
+ return fitFrom(pass.candidates, () => true);
2976
3390
  }
2977
3391
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2978
3392
  var HAIR_FRONT_DEPTH = 0.1;
3393
+ var MOUTH_TURN_TILT_DEG = 5;
3394
+ var NOSE_TURN_TILT_DEG = 6;
2979
3395
  var NOD_TRAVEL = 30;
2980
3396
  var NOD_BEND = 0.5;
2981
3397
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -3582,11 +3998,37 @@ function turnSetup(layers) {
3582
3998
  )
3583
3999
  );
3584
4000
  }
4001
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
4002
+ const mouthAnchor = {
4003
+ x: mouthAt.x,
4004
+ y: mouthAt.y,
4005
+ tiltDeg: MOUTH_TURN_TILT_DEG
4006
+ };
4007
+ const noseLayer = layers.find((l) => l.role === "nose");
4008
+ let noseAnchor;
4009
+ if (noseLayer) {
4010
+ const drawn = noseDrawnBox(noseLayer);
4011
+ const at = bboxToTransform(
4012
+ drawn,
4013
+ noseLayer.canvasW,
4014
+ noseLayer.canvasH,
4015
+ "nose"
4016
+ );
4017
+ noseAnchor = {
4018
+ x: at.x,
4019
+ y: at.y + drawn.h / 2,
4020
+ tiltDeg: NOSE_TURN_TILT_DEG
4021
+ };
4022
+ }
3585
4023
  const carriers = new Map(
3586
- members.map((m) => [
3587
- m.role,
3588
- { grid: groupGrids.get(m.group), part: m.part }
3589
- ])
4024
+ members.map((m) => {
4025
+ const grid = groupGrids.get(m.group);
4026
+ const anchor = m.group === "mouthWarp" ? mouthAnchor : m.group === "noseWarp" ? noseAnchor : void 0;
4027
+ return [
4028
+ m.role,
4029
+ anchor === void 0 ? { grid, part: m.part } : { grid, part: m.part, anchor }
4030
+ ];
4031
+ })
3590
4032
  );
3591
4033
  const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3592
4034
  let reach = 0;
@@ -3656,6 +4098,7 @@ function solveInputsOf(s) {
3656
4098
  }
3657
4099
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
3658
4100
  validateLayerInputs(layers, canvas);
4101
+ validateStrandEdges(layers, options.strandEdges);
3659
4102
  const hasHair = layers.some((l) => l.role === "hair_front");
3660
4103
  const bodyLayer = layers.find((l) => l.role === "body");
3661
4104
  const parameters = [...STANDARD_PARAMETERS];
@@ -3691,6 +4134,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3691
4134
  plateGuardRows,
3692
4135
  members,
3693
4136
  groupGrids,
4137
+ carriers,
3694
4138
  parallaxUnitY,
3695
4139
  hasNose,
3696
4140
  hasMouthOpen
@@ -3699,7 +4143,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3699
4143
  const turn = hasNose ? solveTurnModel(
3700
4144
  resolveTurnTargets(options.turnTargets),
3701
4145
  ...solveInputsOf(setup),
3702
- options.headEdges
4146
+ options.headEdges,
4147
+ options.strandEdges
3703
4148
  ) : void 0;
3704
4149
  if (turn?.unreachable) {
3705
4150
  throw new TurnTargetError(
@@ -3717,7 +4162,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3717
4162
  holdBase: turn.holdBase,
3718
4163
  depths: turn.depths,
3719
4164
  achieved: turn.achieved,
3720
- clamped: turn.clamped
4165
+ clamped: turn.clamped,
4166
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3721
4167
  });
3722
4168
  }
3723
4169
  const latticeReach = Math.max(
@@ -3790,7 +4236,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3790
4236
  }
3791
4237
  ];
3792
4238
  for (const [group, grid] of groupGrids) {
3793
- const family = turnFamily(members.find((m) => m.group === group).role);
4239
+ const { role } = members.find((m) => m.group === group);
4240
+ const family = turnFamily(role);
3794
4241
  const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
3795
4242
  deformers.push({
3796
4243
  kind: "warp",
@@ -3802,7 +4249,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3802
4249
  import_format3.StandardParameter.AngleX,
3803
4250
  import_format3.StandardParameter.AngleY,
3804
4251
  surface,
3805
- (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
4252
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4253
+ carriers.get(role).anchor
3806
4254
  )
3807
4255
  });
3808
4256
  }