@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.mjs CHANGED
@@ -1776,6 +1776,164 @@ function validateLayerInputs(layers, canvas) {
1776
1776
  }
1777
1777
  }
1778
1778
  }
1779
+ if (layer.denseCore !== void 0) {
1780
+ const where = `auto-rig: validateLayerInputs: role "${role}" denseCore`;
1781
+ const core = layer.denseCore;
1782
+ if (typeof core !== "object" || core === null || Array.isArray(core)) {
1783
+ throw new Error(`${where} must be a plain object { x, y, w, h }`);
1784
+ }
1785
+ for (const field of ["x", "y", "w", "h"]) {
1786
+ const v = core[field];
1787
+ const size = field === "w" || field === "h";
1788
+ if (typeof v !== "number" || !Number.isFinite(v) || size && v <= 0) {
1789
+ throw new Error(
1790
+ `${where}.${field} is ${String(v)}, not a finite number${size ? " > 0" : ""}`
1791
+ );
1792
+ }
1793
+ }
1794
+ const { x, y, w, h } = layer.denseCore;
1795
+ const right = bbox.x + bbox.w;
1796
+ const bottom = bbox.y + bbox.h;
1797
+ if (x < bbox.x) {
1798
+ throw new Error(
1799
+ `${where}.x (${x}) lies left of the bbox's left edge (${bbox.x})`
1800
+ );
1801
+ }
1802
+ if (y < bbox.y) {
1803
+ throw new Error(
1804
+ `${where}.y (${y}) lies above the bbox's top edge (${bbox.y})`
1805
+ );
1806
+ }
1807
+ if (x + w > right) {
1808
+ throw new Error(
1809
+ `${where}.w (${w}) puts its right edge at ${x + w}, past the bbox's right edge (${right})`
1810
+ );
1811
+ }
1812
+ if (y + h > bottom) {
1813
+ throw new Error(
1814
+ `${where}.h (${h}) puts its bottom edge at ${y + h}, past the bbox's bottom edge (${bottom})`
1815
+ );
1816
+ }
1817
+ }
1818
+ }
1819
+ }
1820
+ function validateStrandEdges(layers, strandEdges) {
1821
+ if (strandEdges === void 0) return;
1822
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1823
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1824
+ if (!isPlainObject(strandEdges)) {
1825
+ throw new Error(
1826
+ `${where} must be a plain object with optional left / right sides`
1827
+ );
1828
+ }
1829
+ for (const key of ["left", "right"]) {
1830
+ const raw = strandEdges[key];
1831
+ if (raw === void 0) continue;
1832
+ const path = `${where}.${key}`;
1833
+ if (!isPlainObject(raw)) {
1834
+ throw new Error(`${path} must be a plain object`);
1835
+ }
1836
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1837
+ const v = raw[field];
1838
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1839
+ throw new Error(
1840
+ `${path}.${field} is ${String(v)}, not a finite number`
1841
+ );
1842
+ }
1843
+ }
1844
+ const rawFace = raw.runFace;
1845
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1846
+ throw new Error(
1847
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1848
+ );
1849
+ }
1850
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1851
+ const cropOf = (role) => {
1852
+ const layer = layers.find((l) => l.role === role);
1853
+ if (layer === void 0) {
1854
+ throw new Error(
1855
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1856
+ );
1857
+ }
1858
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1859
+ return {
1860
+ role,
1861
+ x: t.x,
1862
+ left: t.x - layer.cropW / 2,
1863
+ right: t.x + layer.cropW / 2,
1864
+ bottom: t.y - layer.cropH / 2,
1865
+ top: t.y + layer.cropH / 2
1866
+ };
1867
+ };
1868
+ const hair = cropOf("hair_front");
1869
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1870
+ (a, b) => a.x - b.x
1871
+ );
1872
+ const sign = key === "left" ? -1 : 1;
1873
+ const iris = key === "left" ? lowIris : highIris;
1874
+ const other = key === "left" ? highIris : lowIris;
1875
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1876
+ for (const crop of [iris, hair]) {
1877
+ if (y < crop.bottom || y > crop.top) {
1878
+ throw fail(
1879
+ "y",
1880
+ y,
1881
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1882
+ );
1883
+ }
1884
+ }
1885
+ for (const [field, x] of [
1886
+ ["irisOuter", irisOuter],
1887
+ ["irisInner", irisInner]
1888
+ ]) {
1889
+ if (x < iris.left || x > iris.right) {
1890
+ throw fail(
1891
+ field,
1892
+ x,
1893
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1894
+ );
1895
+ }
1896
+ }
1897
+ if (sign * (irisOuter - irisInner) <= 0) {
1898
+ throw fail(
1899
+ "irisOuter",
1900
+ irisOuter,
1901
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1902
+ );
1903
+ }
1904
+ for (const [field, x] of [
1905
+ ["runOuter", runOuter],
1906
+ ["runFace", runFace]
1907
+ ]) {
1908
+ if (x !== null && (x < hair.left || x > hair.right)) {
1909
+ throw fail(
1910
+ field,
1911
+ x,
1912
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1913
+ );
1914
+ }
1915
+ }
1916
+ if (sign * (runOuter - iris.x) <= 0) {
1917
+ throw fail(
1918
+ "runOuter",
1919
+ runOuter,
1920
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1921
+ );
1922
+ }
1923
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1924
+ throw fail(
1925
+ "runOuter",
1926
+ runOuter,
1927
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1928
+ );
1929
+ }
1930
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1931
+ throw fail(
1932
+ "runFace",
1933
+ runFace,
1934
+ `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`
1935
+ );
1936
+ }
1779
1937
  }
1780
1938
  }
1781
1939
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
@@ -1934,21 +2092,26 @@ function turnSurface(spec) {
1934
2092
  )
1935
2093
  };
1936
2094
  }
1937
- function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
2095
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1938
2096
  const STOPS = [...HEAD_TURN_STOPS];
1939
2097
  const pointCount = grid.points.length / 2;
1940
2098
  const keyforms2d = [];
1941
2099
  for (const angleY of STOPS) {
1942
2100
  for (const angleX of STOPS) {
1943
2101
  const shift = shiftAt(angleX);
2102
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
1944
2103
  const offsets = [];
1945
2104
  for (let i = 0; i < pointCount; i++) {
1946
2105
  const x = grid.points[i * 2];
1947
2106
  const y = grid.points[i * 2 + 1];
1948
- offsets.push(
1949
- angleX === 0 ? 0 : surface.mapAt(angleX, y).mapX(x + shift) - x,
1950
- angleY === 0 ? 0 : surface.nodBendAt(y, angleY)
1951
- );
2107
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2108
+ if (angleX === 0) {
2109
+ offsets.push(0, nodDy);
2110
+ continue;
2111
+ }
2112
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2113
+ const turnDy = landed.y - y;
2114
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
1952
2115
  }
1953
2116
  keyforms2d.push({ offsets });
1954
2117
  }
@@ -2068,6 +2231,22 @@ function carrierLandingX(carrier, nodeLanding, px, py) {
2068
2231
  py
2069
2232
  );
2070
2233
  }
2234
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2235
+ if (anchor === void 0) {
2236
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2237
+ }
2238
+ const anchorRow = mapAt(anchor.y);
2239
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2240
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2241
+ const dy = nodeY - anchor.y;
2242
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2243
+ const cos = Math.cos(phi);
2244
+ const sin = Math.sin(phi);
2245
+ return {
2246
+ x: pivotX + cos * dx - sin * dy,
2247
+ y: anchor.y + sin * dx + cos * dy
2248
+ };
2249
+ }
2071
2250
  function rowMapsOf(surface) {
2072
2251
  const maps = /* @__PURE__ */ new Map();
2073
2252
  return (deg, nodeY) => {
@@ -2190,12 +2369,17 @@ function resolveTurnTargets(targets = {}) {
2190
2369
  defaulted
2191
2370
  };
2192
2371
  }
2372
+ function noseDrawnBox(layer) {
2373
+ return layer.denseCore ?? layer.bbox;
2374
+ }
2193
2375
  function turnLandmarks(layers, carriers) {
2194
2376
  const markOf = (role) => {
2195
2377
  const layer = layers.find((l) => l.role === role);
2196
2378
  if (!layer) return void 0;
2197
- const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2198
- const mark = { x: t.x, y: t.y, w: layer.cropW };
2379
+ const drawn = role === "nose" ? noseDrawnBox(layer) : layer.bbox;
2380
+ const t = bboxToTransform(drawn, layer.canvasW, layer.canvasH, role);
2381
+ const w = drawn === layer.bbox ? layer.cropW : drawn.w;
2382
+ const mark = { x: t.x, y: t.y, w };
2199
2383
  const carrier = carriers?.get(role);
2200
2384
  if (carrier !== void 0) mark.carrier = carrier;
2201
2385
  return mark;
@@ -2221,11 +2405,12 @@ function eyeRowOf(landmarks, fallback) {
2221
2405
  }
2222
2406
  var TURN_BISECT_STEPS = 40;
2223
2407
  var TURN_DEPTH_EPS = 1e-6;
2224
- function landmarkLandingX(landmark, mapAt, shift, px, py) {
2225
- if (landmark.carrier === void 0) return mapAt(py).mapX(px + shift);
2408
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2409
+ const { carrier } = landmark;
2410
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2226
2411
  return carrierLandingX(
2227
- landmark.carrier,
2228
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2412
+ carrier,
2413
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2229
2414
  px,
2230
2415
  py
2231
2416
  );
@@ -2234,22 +2419,47 @@ function familyReachPx(landmarks, plateEdgeX) {
2234
2419
  const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2235
2420
  return Math.max(0, farEdge - plateEdgeX);
2236
2421
  }
2237
- function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
2422
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2423
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2424
+ }
2425
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
2238
2426
  if (landmarks.length === 0) {
2239
2427
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2240
2428
  }
2241
- const cap = familyReachPx(landmarks, plateEdgeX) / unit;
2429
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2430
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2242
2431
  const achieved = (d) => landmarks.reduce(
2243
- (sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
2432
+ (sum, l) => sum + (landmarkLandingX(
2433
+ l,
2434
+ mapAt,
2435
+ -HEAD_TURN_MAX_DEG,
2436
+ -d * unit,
2437
+ l.x,
2438
+ l.y ?? 0
2439
+ ) - l.x),
2244
2440
  0
2245
2441
  ) / landmarks.length;
2246
2442
  const atCap = achieved(cap);
2247
2443
  const atRest = achieved(0);
2248
2444
  const attainable = [atCap, atRest];
2249
- if (target > atRest + TURN_DEPTH_EPS)
2250
- return { reached: false, depth: 0, achieved: atRest, attainable };
2251
- if (target < atCap - TURN_DEPTH_EPS)
2252
- return { reached: false, depth: cap, achieved: atCap, attainable };
2445
+ if (target > atRest + TURN_DEPTH_EPS) {
2446
+ return {
2447
+ reached: false,
2448
+ depth: 0,
2449
+ achieved: atRest,
2450
+ attainable,
2451
+ limit: "floor"
2452
+ };
2453
+ }
2454
+ if (target < atCap - TURN_DEPTH_EPS) {
2455
+ return {
2456
+ reached: false,
2457
+ depth: cap,
2458
+ achieved: atCap,
2459
+ attainable,
2460
+ limit: "cap"
2461
+ };
2462
+ }
2253
2463
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2254
2464
  let lo = 0;
2255
2465
  let hi = cap;
@@ -2279,7 +2489,7 @@ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, hol
2279
2489
  }
2280
2490
  return x + side * (holdEdge - holdBase);
2281
2491
  }
2282
- function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2492
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2283
2493
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2284
2494
  const top = hairFront.centerY + hairFront.cropH / 2;
2285
2495
  return renderedLandingX(
@@ -2294,7 +2504,7 @@ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2294
2504
  scaleY: 1,
2295
2505
  rotation: 0
2296
2506
  },
2297
- (vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2507
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2298
2508
  x,
2299
2509
  y
2300
2510
  );
@@ -2327,6 +2537,19 @@ function aitken(x0, x1, x2, cap) {
2327
2537
  const x = x2 - d2 * d2 / denominator;
2328
2538
  return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2329
2539
  }
2540
+ function strandDepthCap(margins, plateCap) {
2541
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2542
+ if (holds(plateCap)) return plateCap;
2543
+ if (!holds(0)) return void 0;
2544
+ let lo = 0;
2545
+ let hi = plateCap;
2546
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2547
+ const mid = (lo + hi) / 2;
2548
+ if (holds(mid)) lo = mid;
2549
+ else hi = mid;
2550
+ }
2551
+ return lo;
2552
+ }
2330
2553
  function evaluateTurnCandidate(ctx, radius) {
2331
2554
  const surfaceAt = (travel) => turnSurface({
2332
2555
  faceCenterX: ctx.faceCenterX,
@@ -2350,7 +2573,104 @@ function evaluateTurnCandidate(ctx, radius) {
2350
2573
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2351
2574
  };
2352
2575
  const unit = headTurnParallaxUnit(radius);
2576
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2577
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2353
2578
  const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2579
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2580
+ x,
2581
+ y,
2582
+ ctx.hairFrontSilhouette,
2583
+ (vx, vy) => hairFrontHoldTarget(
2584
+ vx,
2585
+ vy,
2586
+ deg,
2587
+ ctx.faceCenterX,
2588
+ plateLandingAt,
2589
+ ctx.edgeAt,
2590
+ hold.holdBase,
2591
+ hold.holdEdgeAt(deg)
2592
+ ),
2593
+ leadShift
2594
+ );
2595
+ const strandStopsOn = (hold) => {
2596
+ const rowMaps = rowMapsOf(hold.surface);
2597
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2598
+ return (ctx.strands ?? []).map((strand) => {
2599
+ const iris = {
2600
+ x: strand.carrier.part.x,
2601
+ w: strand.carrier.part.cropW,
2602
+ carrier: strand.carrier
2603
+ };
2604
+ const stops = HEAD_TURN_STOPS.filter(
2605
+ (deg) => Math.sign(deg) === strand.side
2606
+ ).map((deg) => {
2607
+ const runAt = (x) => bangsLandingAt(
2608
+ hold,
2609
+ plateLandingAt,
2610
+ deg,
2611
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2612
+ x,
2613
+ strand.y
2614
+ );
2615
+ return {
2616
+ deg,
2617
+ runOuter: runAt(strand.runOuter),
2618
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2619
+ irisAt: (depth, x) => landmarkLandingX(
2620
+ iris,
2621
+ (y) => rowMaps(deg, y),
2622
+ deg,
2623
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2624
+ x,
2625
+ strand.y
2626
+ )
2627
+ };
2628
+ });
2629
+ const { runFace } = strand;
2630
+ if (runFace === null) return { strand, stops, margins: void 0 };
2631
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2632
+ const margins = stops.map(
2633
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2634
+ );
2635
+ return { strand, stops, margins };
2636
+ });
2637
+ };
2638
+ const strandOverlapOf = (sides, depth) => {
2639
+ const overlap = {};
2640
+ for (const { strand, stops, margins } of sides) {
2641
+ const s = strand.side;
2642
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2643
+ 0,
2644
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2645
+ );
2646
+ let worst;
2647
+ for (const stop of stops) {
2648
+ const px = covered(
2649
+ stop.irisAt(depth, strand.irisOuter),
2650
+ stop.irisAt(depth, strand.irisInner),
2651
+ stop.runOuter,
2652
+ stop.runFace
2653
+ );
2654
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2655
+ worst = { deg: stop.deg, px };
2656
+ }
2657
+ }
2658
+ if (worst === void 0) continue;
2659
+ overlap[s < 0 ? "left" : "right"] = {
2660
+ deg: worst.deg,
2661
+ px: worst.px,
2662
+ hh: worst.px / ctx.hh,
2663
+ restPx: covered(
2664
+ strand.irisOuter,
2665
+ strand.irisInner,
2666
+ strand.runOuter,
2667
+ strand.runFace
2668
+ ),
2669
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2670
+ };
2671
+ }
2672
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2673
+ };
2354
2674
  const evaluateHold = (ratio, depths2) => {
2355
2675
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2356
2676
  const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
@@ -2400,21 +2720,13 @@ function evaluateTurnCandidate(ctx, radius) {
2400
2720
  if (edge !== void 0) return edge;
2401
2721
  return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2402
2722
  };
2403
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2723
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2724
+ { holdBase, holdEdgeAt },
2725
+ plateLandingAt,
2726
+ -HEAD_TURN_MAX_DEG,
2727
+ -leadTipShift,
2404
2728
  x,
2405
- ctx.eyeRowY,
2406
- ctx.hairFrontSilhouette,
2407
- (vx, vy) => hairFrontHoldTarget(
2408
- vx,
2409
- vy,
2410
- -HEAD_TURN_MAX_DEG,
2411
- ctx.faceCenterX,
2412
- plateLandingAt,
2413
- ctx.edgeAt,
2414
- holdBase,
2415
- holdEdgeAt(-HEAD_TURN_MAX_DEG)
2416
- ),
2417
- leadTipShift
2729
+ ctx.eyeRowY
2418
2730
  );
2419
2731
  const landingOfRole = (role, x) => {
2420
2732
  if (role === "hair_front") return hairFrontAt(x);
@@ -2434,7 +2746,14 @@ function evaluateTurnCandidate(ctx, radius) {
2434
2746
  const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2435
2747
  return carrierLandingX(
2436
2748
  carrier,
2437
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2749
+ (nodeX, nodeY) => groupNodeLanding(
2750
+ carrier.anchor,
2751
+ mapAt,
2752
+ -HEAD_TURN_MAX_DEG,
2753
+ shift,
2754
+ nodeX,
2755
+ nodeY
2756
+ ).x,
2438
2757
  x,
2439
2758
  ctx.eyeRowY
2440
2759
  );
@@ -2575,12 +2894,25 @@ function evaluateTurnCandidate(ctx, radius) {
2575
2894
  "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2576
2895
  );
2577
2896
  }
2897
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2898
+ let strandFeasible = true;
2899
+ let maxDepth;
2900
+ if (strandSides !== void 0) {
2901
+ const margins = strandSides.map((s) => s.margins);
2902
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2903
+ margins.flatMap((m) => m),
2904
+ eyePlateCap
2905
+ ) : void 0;
2906
+ strandFeasible = cap !== void 0;
2907
+ maxDepth = cap ?? 0;
2908
+ }
2578
2909
  const eye = solveTurnDepthSigned(
2579
2910
  hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2580
2911
  ctx.landmarks.eye,
2581
2912
  unit,
2582
2913
  hold.mapAt,
2583
- ctx.plateEdgeX
2914
+ ctx.plateEdgeX,
2915
+ maxDepth
2584
2916
  );
2585
2917
  const features = solveFeatureDepths(
2586
2918
  ctx,
@@ -2601,25 +2933,19 @@ function evaluateTurnCandidate(ctx, radius) {
2601
2933
  seed = depths;
2602
2934
  depths = solved;
2603
2935
  } else {
2604
- const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
2605
2936
  depths = {
2606
- eye: aitken(
2607
- seed.eye,
2608
- depths.eye,
2609
- solved.eye,
2610
- capOf(ctx.landmarks.eye)
2611
- ),
2937
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2612
2938
  nose: aitken(
2613
2939
  seed.nose,
2614
2940
  depths.nose,
2615
2941
  solved.nose,
2616
- capOf(ctx.landmarks.nose)
2942
+ plateCapOf(ctx.landmarks.nose)
2617
2943
  ),
2618
2944
  mouth: aitken(
2619
2945
  seed.mouth,
2620
2946
  depths.mouth,
2621
2947
  solved.mouth,
2622
- capOf(ctx.landmarks.mouth)
2948
+ plateCapOf(ctx.landmarks.mouth)
2623
2949
  )
2624
2950
  };
2625
2951
  seed = void 0;
@@ -2638,7 +2964,7 @@ function evaluateTurnCandidate(ctx, radius) {
2638
2964
  if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2639
2965
  return silhouetteMiss(span);
2640
2966
  }
2641
- if (!eye.reached && !ctx.clampEyeShift) {
2967
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2642
2968
  return {
2643
2969
  blocked: "eyeShift",
2644
2970
  // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
@@ -2651,9 +2977,24 @@ function evaluateTurnCandidate(ctx, radius) {
2651
2977
  };
2652
2978
  }
2653
2979
  const eyeShiftPx = -eye.depth * unit;
2654
- 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;
2980
+ const scaleOf = (l) => (landmarkLandingX(
2981
+ l,
2982
+ mapAt,
2983
+ -HEAD_TURN_MAX_DEG,
2984
+ eyeShiftPx,
2985
+ l.x + l.w / 2,
2986
+ l.y ?? 0
2987
+ ) - landmarkLandingX(
2988
+ l,
2989
+ mapAt,
2990
+ -HEAD_TURN_MAX_DEG,
2991
+ eyeShiftPx,
2992
+ l.x - l.w / 2,
2993
+ l.y ?? 0
2994
+ )) / l.w;
2655
2995
  const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2656
2996
  const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2997
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2657
2998
  return {
2658
2999
  radius,
2659
3000
  ratio: scaleOf(far) / scaleOf(near),
@@ -2668,7 +3009,9 @@ function evaluateTurnCandidate(ctx, radius) {
2668
3009
  mouth: features.mouth,
2669
3010
  silhouetteClamped,
2670
3011
  silhouetteCenterShift,
2671
- renderedSilhouetteRatio
3012
+ renderedSilhouetteRatio,
3013
+ strandFeasible,
3014
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2672
3015
  };
2673
3016
  }
2674
3017
  }
@@ -2705,6 +3048,9 @@ function sweepTurnRadii(ctx) {
2705
3048
  unsettled
2706
3049
  };
2707
3050
  }
3051
+ function runHolds(run, ratio) {
3052
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
3053
+ }
2708
3054
  function ratioClusters(candidates) {
2709
3055
  const clusters = [];
2710
3056
  let run = [];
@@ -2727,7 +3073,7 @@ function ratioClusters(candidates) {
2727
3073
  close();
2728
3074
  return clusters;
2729
3075
  }
2730
- function fitTurnRadius(ctx, candidates, target) {
3076
+ function fitTurnRadius(ctx, candidates, target, accept) {
2731
3077
  let lo = candidates[0];
2732
3078
  let hi = candidates[candidates.length - 1];
2733
3079
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2737,13 +3083,42 @@ function fitTurnRadius(ctx, candidates, target) {
2737
3083
  break;
2738
3084
  }
2739
3085
  }
3086
+ return bisectTurnRadius(
3087
+ lo,
3088
+ hi,
3089
+ target,
3090
+ (radius) => {
3091
+ const mid = evaluateTurnCandidate(ctx, radius);
3092
+ return "blocked" in mid ? void 0 : mid;
3093
+ },
3094
+ accept
3095
+ );
3096
+ }
3097
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3098
+ if (!accept(lo) || !accept(hi)) {
3099
+ throw new Error(
3100
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3101
+ );
3102
+ }
3103
+ const miss = (c) => Math.abs(c.ratio - target);
3104
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2740
3105
  for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2741
- const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
2742
- if ("blocked" in mid) break;
3106
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3107
+ if (mid === void 0) break;
3108
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2743
3109
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2744
3110
  else lo = mid;
2745
3111
  }
2746
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3112
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3113
+ return nearest;
3114
+ }
3115
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3116
+ const feasible = candidates.filter((c) => c.strandFeasible);
3117
+ if (feasible.length === candidates.length) return candidates;
3118
+ const holds = ratioClusters(feasible).some(
3119
+ (run) => runHolds(run, farEyeRatio)
3120
+ );
3121
+ return holds ? feasible : candidates;
2747
3122
  }
2748
3123
  function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2749
3124
  const solve = (measured, marks, share) => solveTurnDepthSigned(
@@ -2758,13 +3133,38 @@ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2758
3133
  mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2759
3134
  };
2760
3135
  }
2761
- function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
3136
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2762
3137
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2763
3138
  throw new TurnTargetError(
2764
3139
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2765
3140
  );
2766
3141
  }
2767
3142
  const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3143
+ let strands;
3144
+ const strandSides = ["left", "right"].filter(
3145
+ (key) => strandEdges?.[key] !== void 0
3146
+ );
3147
+ if (strandSides.length > 0) {
3148
+ if (hairFront === void 0) {
3149
+ throw new Error(
3150
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3151
+ );
3152
+ }
3153
+ 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);
3154
+ strands = strandSides.map((key) => {
3155
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3156
+ if (carrier === void 0) {
3157
+ throw new Error(
3158
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3159
+ );
3160
+ }
3161
+ return {
3162
+ ...strandEdges[key],
3163
+ side: key === "left" ? -1 : 1,
3164
+ carrier
3165
+ };
3166
+ });
3167
+ }
2768
3168
  const ctx = {
2769
3169
  landmarks,
2770
3170
  lattice,
@@ -2796,9 +3196,9 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2796
3196
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2797
3197
  hairFrontSilhouette: hairFront,
2798
3198
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2799
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3199
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3200
+ strands
2800
3201
  };
2801
- const clamped = [];
2802
3202
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2803
3203
  const pass = sweepTurnRadii(ctx);
2804
3204
  if (pass.candidates.length === 0) {
@@ -2829,92 +3229,108 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2829
3229
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2830
3230
  };
2831
3231
  }
2832
- const clusters = ratioClusters(pass.candidates);
2833
- const distanceTo = (c) => Math.max(
2834
- c.ratios[0] - targets.farEyeRatio,
2835
- targets.farEyeRatio - c.ratios[1],
2836
- 0
2837
- );
2838
- let farEyeRatio = targets.farEyeRatio;
2839
- let cluster = clusters.find(
2840
- (c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
2841
- );
2842
- if (cluster === void 0) {
2843
- cluster = clusters.reduce(
2844
- (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3232
+ const fitFrom = (pool, accept) => {
3233
+ const clamped = [];
3234
+ const clusters = ratioClusters(pool);
3235
+ const distanceTo = (c) => Math.max(
3236
+ c.ratios[0] - targets.farEyeRatio,
3237
+ targets.farEyeRatio - c.ratios[1],
3238
+ 0
2845
3239
  );
2846
- if (!targets.defaulted.has("farEyeRatio")) {
2847
- return {
2848
- unreachable: true,
2849
- field: "farEyeRatio",
2850
- value: targets.farEyeRatio,
2851
- attainable: cluster.ratios
2852
- };
2853
- }
2854
- farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
2855
- clamped.push("farEyeRatio");
2856
- }
2857
- const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
2858
- if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
2859
- if (!targets.defaulted.has("farEyeRatio")) {
2860
- return {
2861
- unreachable: true,
2862
- field: "farEyeRatio",
2863
- value: targets.farEyeRatio,
2864
- attainable: cluster.ratios
2865
- };
3240
+ let farEyeRatio = targets.farEyeRatio;
3241
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3242
+ if (cluster === void 0) {
3243
+ cluster = clusters.reduce(
3244
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3245
+ );
3246
+ if (!targets.defaulted.has("farEyeRatio")) {
3247
+ return {
3248
+ unreachable: true,
3249
+ field: "farEyeRatio",
3250
+ value: targets.farEyeRatio,
3251
+ attainable: cluster.ratios
3252
+ };
3253
+ }
3254
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3255
+ clamped.push("farEyeRatio");
3256
+ }
3257
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3258
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3259
+ if (!targets.defaulted.has("farEyeRatio")) {
3260
+ return {
3261
+ unreachable: true,
3262
+ field: "farEyeRatio",
3263
+ value: targets.farEyeRatio,
3264
+ attainable: cluster.ratios
3265
+ };
3266
+ }
3267
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3268
+ }
3269
+ if (!best.eye.reached) clamped.push("eyeShift");
3270
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3271
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3272
+ for (const [field, solution] of [
3273
+ ["noseShift", best.nose],
3274
+ ["mouthShift", best.mouth]
3275
+ ]) {
3276
+ if (solution.reached) continue;
3277
+ if (!targets.defaulted.has(field)) {
3278
+ return {
3279
+ unreachable: true,
3280
+ field,
3281
+ value: targets[field],
3282
+ // noseShift/mouthShift are MAGNITUDES too (see
3283
+ // solveFeatureDepths), so a negative lower bound here would
3284
+ // advertise a value the field's own contract already rules out; 0
3285
+ // is the true floor.
3286
+ attainable: [
3287
+ Math.max(
3288
+ 0,
3289
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3290
+ ),
3291
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3292
+ ]
3293
+ };
3294
+ }
3295
+ clamped.push(field);
2866
3296
  }
2867
- if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
2868
- }
2869
- if (!best.eye.reached) clamped.push("eyeShift");
2870
- if (best.silhouetteClamped) clamped.push("silhouetteRatio");
2871
- const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
2872
- for (const [field, solution] of [
2873
- ["noseShift", best.nose],
2874
- ["mouthShift", best.mouth]
2875
- ]) {
2876
- if (solution.reached) continue;
2877
- if (!targets.defaulted.has(field)) {
2878
- return {
2879
- unreachable: true,
2880
- field,
2881
- value: targets[field],
2882
- // noseShift/mouthShift are MAGNITUDES too (see solveFeatureDepths),
2883
- // so a negative lower bound here would advertise a value the field's
2884
- // own contract already rules out; 0 is the true floor.
2885
- attainable: [
2886
- Math.max(
2887
- 0,
2888
- (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
2889
- ),
2890
- (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
2891
- ]
2892
- };
3297
+ return {
3298
+ unreachable: false,
3299
+ radius: best.radius,
3300
+ holdBase: best.holdBase,
3301
+ holdEdgeAt: best.holdEdgeAt,
3302
+ travel: best.travel,
3303
+ surface: best.surface,
3304
+ depths: {
3305
+ eye: best.eye.depth,
3306
+ nose: best.nose.depth,
3307
+ mouth: best.mouth.depth
3308
+ },
3309
+ achieved: {
3310
+ eyeShift,
3311
+ farEyeRatio: best.ratio,
3312
+ silhouetteRatio: best.renderedSilhouetteRatio
3313
+ },
3314
+ clamped,
3315
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
3316
+ };
3317
+ };
3318
+ const preferred = strandPreferredCandidates(
3319
+ pass.candidates,
3320
+ targets.farEyeRatio
3321
+ );
3322
+ if (preferred !== pass.candidates) {
3323
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3324
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3325
+ return fitted;
2893
3326
  }
2894
- clamped.push(field);
2895
3327
  }
2896
- return {
2897
- unreachable: false,
2898
- radius: best.radius,
2899
- holdBase: best.holdBase,
2900
- holdEdgeAt: best.holdEdgeAt,
2901
- travel: best.travel,
2902
- surface: best.surface,
2903
- depths: {
2904
- eye: best.eye.depth,
2905
- nose: best.nose.depth,
2906
- mouth: best.mouth.depth
2907
- },
2908
- achieved: {
2909
- eyeShift,
2910
- farEyeRatio: best.ratio,
2911
- silhouetteRatio: best.renderedSilhouetteRatio
2912
- },
2913
- clamped
2914
- };
3328
+ return fitFrom(pass.candidates, () => true);
2915
3329
  }
2916
3330
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2917
3331
  var HAIR_FRONT_DEPTH = 0.1;
3332
+ var MOUTH_TURN_TILT_DEG = 5;
3333
+ var NOSE_TURN_TILT_DEG = 6;
2918
3334
  var NOD_TRAVEL = 30;
2919
3335
  var NOD_BEND = 0.5;
2920
3336
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -3521,11 +3937,37 @@ function turnSetup(layers) {
3521
3937
  )
3522
3938
  );
3523
3939
  }
3940
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
3941
+ const mouthAnchor = {
3942
+ x: mouthAt.x,
3943
+ y: mouthAt.y,
3944
+ tiltDeg: MOUTH_TURN_TILT_DEG
3945
+ };
3946
+ const noseLayer = layers.find((l) => l.role === "nose");
3947
+ let noseAnchor;
3948
+ if (noseLayer) {
3949
+ const drawn = noseDrawnBox(noseLayer);
3950
+ const at = bboxToTransform(
3951
+ drawn,
3952
+ noseLayer.canvasW,
3953
+ noseLayer.canvasH,
3954
+ "nose"
3955
+ );
3956
+ noseAnchor = {
3957
+ x: at.x,
3958
+ y: at.y + drawn.h / 2,
3959
+ tiltDeg: NOSE_TURN_TILT_DEG
3960
+ };
3961
+ }
3524
3962
  const carriers = new Map(
3525
- members.map((m) => [
3526
- m.role,
3527
- { grid: groupGrids.get(m.group), part: m.part }
3528
- ])
3963
+ members.map((m) => {
3964
+ const grid = groupGrids.get(m.group);
3965
+ const anchor = m.group === "mouthWarp" ? mouthAnchor : m.group === "noseWarp" ? noseAnchor : void 0;
3966
+ return [
3967
+ m.role,
3968
+ anchor === void 0 ? { grid, part: m.part } : { grid, part: m.part, anchor }
3969
+ ];
3970
+ })
3529
3971
  );
3530
3972
  const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3531
3973
  let reach = 0;
@@ -3595,6 +4037,7 @@ function solveInputsOf(s) {
3595
4037
  }
3596
4038
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
3597
4039
  validateLayerInputs(layers, canvas);
4040
+ validateStrandEdges(layers, options.strandEdges);
3598
4041
  const hasHair = layers.some((l) => l.role === "hair_front");
3599
4042
  const bodyLayer = layers.find((l) => l.role === "body");
3600
4043
  const parameters = [...STANDARD_PARAMETERS];
@@ -3630,6 +4073,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3630
4073
  plateGuardRows,
3631
4074
  members,
3632
4075
  groupGrids,
4076
+ carriers,
3633
4077
  parallaxUnitY,
3634
4078
  hasNose,
3635
4079
  hasMouthOpen
@@ -3638,7 +4082,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3638
4082
  const turn = hasNose ? solveTurnModel(
3639
4083
  resolveTurnTargets(options.turnTargets),
3640
4084
  ...solveInputsOf(setup),
3641
- options.headEdges
4085
+ options.headEdges,
4086
+ options.strandEdges
3642
4087
  ) : void 0;
3643
4088
  if (turn?.unreachable) {
3644
4089
  throw new TurnTargetError(
@@ -3656,7 +4101,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3656
4101
  holdBase: turn.holdBase,
3657
4102
  depths: turn.depths,
3658
4103
  achieved: turn.achieved,
3659
- clamped: turn.clamped
4104
+ clamped: turn.clamped,
4105
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3660
4106
  });
3661
4107
  }
3662
4108
  const latticeReach = Math.max(
@@ -3729,7 +4175,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3729
4175
  }
3730
4176
  ];
3731
4177
  for (const [group, grid] of groupGrids) {
3732
- const family = turnFamily(members.find((m) => m.group === group).role);
4178
+ const { role } = members.find((m) => m.group === group);
4179
+ const family = turnFamily(role);
3733
4180
  const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
3734
4181
  deformers.push({
3735
4182
  kind: "warp",
@@ -3741,7 +4188,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3741
4188
  StandardParameter.AngleX,
3742
4189
  StandardParameter.AngleY,
3743
4190
  surface,
3744
- (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
4191
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4192
+ carriers.get(role).anchor
3745
4193
  )
3746
4194
  });
3747
4195
  }