@ikijs/editor 0.6.0 → 0.7.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
@@ -1778,6 +1778,125 @@ function validateLayerInputs(layers, canvas) {
1778
1778
  }
1779
1779
  }
1780
1780
  }
1781
+ function validateStrandEdges(layers, strandEdges) {
1782
+ if (strandEdges === void 0) return;
1783
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1784
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1785
+ if (!isPlainObject(strandEdges)) {
1786
+ throw new Error(
1787
+ `${where} must be a plain object with optional left / right sides`
1788
+ );
1789
+ }
1790
+ for (const key of ["left", "right"]) {
1791
+ const raw = strandEdges[key];
1792
+ if (raw === void 0) continue;
1793
+ const path = `${where}.${key}`;
1794
+ if (!isPlainObject(raw)) {
1795
+ throw new Error(`${path} must be a plain object`);
1796
+ }
1797
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1798
+ const v = raw[field];
1799
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1800
+ throw new Error(
1801
+ `${path}.${field} is ${String(v)}, not a finite number`
1802
+ );
1803
+ }
1804
+ }
1805
+ const rawFace = raw.runFace;
1806
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1807
+ throw new Error(
1808
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1809
+ );
1810
+ }
1811
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1812
+ const cropOf = (role) => {
1813
+ const layer = layers.find((l) => l.role === role);
1814
+ if (layer === void 0) {
1815
+ throw new Error(
1816
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1817
+ );
1818
+ }
1819
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1820
+ return {
1821
+ role,
1822
+ x: t.x,
1823
+ left: t.x - layer.cropW / 2,
1824
+ right: t.x + layer.cropW / 2,
1825
+ bottom: t.y - layer.cropH / 2,
1826
+ top: t.y + layer.cropH / 2
1827
+ };
1828
+ };
1829
+ const hair = cropOf("hair_front");
1830
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1831
+ (a, b) => a.x - b.x
1832
+ );
1833
+ const sign = key === "left" ? -1 : 1;
1834
+ const iris = key === "left" ? lowIris : highIris;
1835
+ const other = key === "left" ? highIris : lowIris;
1836
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1837
+ for (const crop of [iris, hair]) {
1838
+ if (y < crop.bottom || y > crop.top) {
1839
+ throw fail(
1840
+ "y",
1841
+ y,
1842
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1843
+ );
1844
+ }
1845
+ }
1846
+ for (const [field, x] of [
1847
+ ["irisOuter", irisOuter],
1848
+ ["irisInner", irisInner]
1849
+ ]) {
1850
+ if (x < iris.left || x > iris.right) {
1851
+ throw fail(
1852
+ field,
1853
+ x,
1854
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1855
+ );
1856
+ }
1857
+ }
1858
+ if (sign * (irisOuter - irisInner) <= 0) {
1859
+ throw fail(
1860
+ "irisOuter",
1861
+ irisOuter,
1862
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1863
+ );
1864
+ }
1865
+ for (const [field, x] of [
1866
+ ["runOuter", runOuter],
1867
+ ["runFace", runFace]
1868
+ ]) {
1869
+ if (x !== null && (x < hair.left || x > hair.right)) {
1870
+ throw fail(
1871
+ field,
1872
+ x,
1873
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1874
+ );
1875
+ }
1876
+ }
1877
+ if (sign * (runOuter - iris.x) <= 0) {
1878
+ throw fail(
1879
+ "runOuter",
1880
+ runOuter,
1881
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1882
+ );
1883
+ }
1884
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1885
+ throw fail(
1886
+ "runOuter",
1887
+ runOuter,
1888
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1889
+ );
1890
+ }
1891
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1892
+ throw fail(
1893
+ "runFace",
1894
+ runFace,
1895
+ `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`
1896
+ );
1897
+ }
1898
+ }
1899
+ }
1781
1900
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
1782
1901
  const pts = [];
1783
1902
  for (let row = 0; row <= rows; row++) {
@@ -1934,21 +2053,26 @@ function turnSurface(spec) {
1934
2053
  )
1935
2054
  };
1936
2055
  }
1937
- function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
2056
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1938
2057
  const STOPS = [...HEAD_TURN_STOPS];
1939
2058
  const pointCount = grid.points.length / 2;
1940
2059
  const keyforms2d = [];
1941
2060
  for (const angleY of STOPS) {
1942
2061
  for (const angleX of STOPS) {
1943
2062
  const shift = shiftAt(angleX);
2063
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
1944
2064
  const offsets = [];
1945
2065
  for (let i = 0; i < pointCount; i++) {
1946
2066
  const x = grid.points[i * 2];
1947
2067
  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
- );
2068
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2069
+ if (angleX === 0) {
2070
+ offsets.push(0, nodDy);
2071
+ continue;
2072
+ }
2073
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2074
+ const turnDy = landed.y - y;
2075
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
1952
2076
  }
1953
2077
  keyforms2d.push({ offsets });
1954
2078
  }
@@ -2068,6 +2192,22 @@ function carrierLandingX(carrier, nodeLanding, px, py) {
2068
2192
  py
2069
2193
  );
2070
2194
  }
2195
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2196
+ if (anchor === void 0) {
2197
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2198
+ }
2199
+ const anchorRow = mapAt(anchor.y);
2200
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2201
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2202
+ const dy = nodeY - anchor.y;
2203
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2204
+ const cos = Math.cos(phi);
2205
+ const sin = Math.sin(phi);
2206
+ return {
2207
+ x: pivotX + cos * dx - sin * dy,
2208
+ y: anchor.y + sin * dx + cos * dy
2209
+ };
2210
+ }
2071
2211
  function rowMapsOf(surface) {
2072
2212
  const maps = /* @__PURE__ */ new Map();
2073
2213
  return (deg, nodeY) => {
@@ -2221,11 +2361,12 @@ function eyeRowOf(landmarks, fallback) {
2221
2361
  }
2222
2362
  var TURN_BISECT_STEPS = 40;
2223
2363
  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);
2364
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2365
+ const { carrier } = landmark;
2366
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2226
2367
  return carrierLandingX(
2227
- landmark.carrier,
2228
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2368
+ carrier,
2369
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2229
2370
  px,
2230
2371
  py
2231
2372
  );
@@ -2234,22 +2375,47 @@ function familyReachPx(landmarks, plateEdgeX) {
2234
2375
  const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2235
2376
  return Math.max(0, farEdge - plateEdgeX);
2236
2377
  }
2237
- function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
2378
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2379
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2380
+ }
2381
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
2238
2382
  if (landmarks.length === 0) {
2239
2383
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2240
2384
  }
2241
- const cap = familyReachPx(landmarks, plateEdgeX) / unit;
2385
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2386
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2242
2387
  const achieved = (d) => landmarks.reduce(
2243
- (sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
2388
+ (sum, l) => sum + (landmarkLandingX(
2389
+ l,
2390
+ mapAt,
2391
+ -HEAD_TURN_MAX_DEG,
2392
+ -d * unit,
2393
+ l.x,
2394
+ l.y ?? 0
2395
+ ) - l.x),
2244
2396
  0
2245
2397
  ) / landmarks.length;
2246
2398
  const atCap = achieved(cap);
2247
2399
  const atRest = achieved(0);
2248
2400
  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 };
2401
+ if (target > atRest + TURN_DEPTH_EPS) {
2402
+ return {
2403
+ reached: false,
2404
+ depth: 0,
2405
+ achieved: atRest,
2406
+ attainable,
2407
+ limit: "floor"
2408
+ };
2409
+ }
2410
+ if (target < atCap - TURN_DEPTH_EPS) {
2411
+ return {
2412
+ reached: false,
2413
+ depth: cap,
2414
+ achieved: atCap,
2415
+ attainable,
2416
+ limit: "cap"
2417
+ };
2418
+ }
2253
2419
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2254
2420
  let lo = 0;
2255
2421
  let hi = cap;
@@ -2279,7 +2445,7 @@ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, hol
2279
2445
  }
2280
2446
  return x + side * (holdEdge - holdBase);
2281
2447
  }
2282
- function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2448
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2283
2449
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2284
2450
  const top = hairFront.centerY + hairFront.cropH / 2;
2285
2451
  return renderedLandingX(
@@ -2294,7 +2460,7 @@ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2294
2460
  scaleY: 1,
2295
2461
  rotation: 0
2296
2462
  },
2297
- (vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2463
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2298
2464
  x,
2299
2465
  y
2300
2466
  );
@@ -2327,6 +2493,19 @@ function aitken(x0, x1, x2, cap) {
2327
2493
  const x = x2 - d2 * d2 / denominator;
2328
2494
  return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2329
2495
  }
2496
+ function strandDepthCap(margins, plateCap) {
2497
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2498
+ if (holds(plateCap)) return plateCap;
2499
+ if (!holds(0)) return void 0;
2500
+ let lo = 0;
2501
+ let hi = plateCap;
2502
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2503
+ const mid = (lo + hi) / 2;
2504
+ if (holds(mid)) lo = mid;
2505
+ else hi = mid;
2506
+ }
2507
+ return lo;
2508
+ }
2330
2509
  function evaluateTurnCandidate(ctx, radius) {
2331
2510
  const surfaceAt = (travel) => turnSurface({
2332
2511
  faceCenterX: ctx.faceCenterX,
@@ -2350,7 +2529,104 @@ function evaluateTurnCandidate(ctx, radius) {
2350
2529
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2351
2530
  };
2352
2531
  const unit = headTurnParallaxUnit(radius);
2532
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2533
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2353
2534
  const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2535
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2536
+ x,
2537
+ y,
2538
+ ctx.hairFrontSilhouette,
2539
+ (vx, vy) => hairFrontHoldTarget(
2540
+ vx,
2541
+ vy,
2542
+ deg,
2543
+ ctx.faceCenterX,
2544
+ plateLandingAt,
2545
+ ctx.edgeAt,
2546
+ hold.holdBase,
2547
+ hold.holdEdgeAt(deg)
2548
+ ),
2549
+ leadShift
2550
+ );
2551
+ const strandStopsOn = (hold) => {
2552
+ const rowMaps = rowMapsOf(hold.surface);
2553
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2554
+ return (ctx.strands ?? []).map((strand) => {
2555
+ const iris = {
2556
+ x: strand.carrier.part.x,
2557
+ w: strand.carrier.part.cropW,
2558
+ carrier: strand.carrier
2559
+ };
2560
+ const stops = HEAD_TURN_STOPS.filter(
2561
+ (deg) => Math.sign(deg) === strand.side
2562
+ ).map((deg) => {
2563
+ const runAt = (x) => bangsLandingAt(
2564
+ hold,
2565
+ plateLandingAt,
2566
+ deg,
2567
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2568
+ x,
2569
+ strand.y
2570
+ );
2571
+ return {
2572
+ deg,
2573
+ runOuter: runAt(strand.runOuter),
2574
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2575
+ irisAt: (depth, x) => landmarkLandingX(
2576
+ iris,
2577
+ (y) => rowMaps(deg, y),
2578
+ deg,
2579
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2580
+ x,
2581
+ strand.y
2582
+ )
2583
+ };
2584
+ });
2585
+ const { runFace } = strand;
2586
+ if (runFace === null) return { strand, stops, margins: void 0 };
2587
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2588
+ const margins = stops.map(
2589
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2590
+ );
2591
+ return { strand, stops, margins };
2592
+ });
2593
+ };
2594
+ const strandOverlapOf = (sides, depth) => {
2595
+ const overlap = {};
2596
+ for (const { strand, stops, margins } of sides) {
2597
+ const s = strand.side;
2598
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2599
+ 0,
2600
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2601
+ );
2602
+ let worst;
2603
+ for (const stop of stops) {
2604
+ const px = covered(
2605
+ stop.irisAt(depth, strand.irisOuter),
2606
+ stop.irisAt(depth, strand.irisInner),
2607
+ stop.runOuter,
2608
+ stop.runFace
2609
+ );
2610
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2611
+ worst = { deg: stop.deg, px };
2612
+ }
2613
+ }
2614
+ if (worst === void 0) continue;
2615
+ overlap[s < 0 ? "left" : "right"] = {
2616
+ deg: worst.deg,
2617
+ px: worst.px,
2618
+ hh: worst.px / ctx.hh,
2619
+ restPx: covered(
2620
+ strand.irisOuter,
2621
+ strand.irisInner,
2622
+ strand.runOuter,
2623
+ strand.runFace
2624
+ ),
2625
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2626
+ };
2627
+ }
2628
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2629
+ };
2354
2630
  const evaluateHold = (ratio, depths2) => {
2355
2631
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2356
2632
  const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
@@ -2400,21 +2676,13 @@ function evaluateTurnCandidate(ctx, radius) {
2400
2676
  if (edge !== void 0) return edge;
2401
2677
  return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2402
2678
  };
2403
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2679
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2680
+ { holdBase, holdEdgeAt },
2681
+ plateLandingAt,
2682
+ -HEAD_TURN_MAX_DEG,
2683
+ -leadTipShift,
2404
2684
  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
2685
+ ctx.eyeRowY
2418
2686
  );
2419
2687
  const landingOfRole = (role, x) => {
2420
2688
  if (role === "hair_front") return hairFrontAt(x);
@@ -2434,7 +2702,14 @@ function evaluateTurnCandidate(ctx, radius) {
2434
2702
  const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2435
2703
  return carrierLandingX(
2436
2704
  carrier,
2437
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2705
+ (nodeX, nodeY) => groupNodeLanding(
2706
+ carrier.anchor,
2707
+ mapAt,
2708
+ -HEAD_TURN_MAX_DEG,
2709
+ shift,
2710
+ nodeX,
2711
+ nodeY
2712
+ ).x,
2438
2713
  x,
2439
2714
  ctx.eyeRowY
2440
2715
  );
@@ -2575,12 +2850,25 @@ function evaluateTurnCandidate(ctx, radius) {
2575
2850
  "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2576
2851
  );
2577
2852
  }
2853
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2854
+ let strandFeasible = true;
2855
+ let maxDepth;
2856
+ if (strandSides !== void 0) {
2857
+ const margins = strandSides.map((s) => s.margins);
2858
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2859
+ margins.flatMap((m) => m),
2860
+ eyePlateCap
2861
+ ) : void 0;
2862
+ strandFeasible = cap !== void 0;
2863
+ maxDepth = cap ?? 0;
2864
+ }
2578
2865
  const eye = solveTurnDepthSigned(
2579
2866
  hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2580
2867
  ctx.landmarks.eye,
2581
2868
  unit,
2582
2869
  hold.mapAt,
2583
- ctx.plateEdgeX
2870
+ ctx.plateEdgeX,
2871
+ maxDepth
2584
2872
  );
2585
2873
  const features = solveFeatureDepths(
2586
2874
  ctx,
@@ -2601,25 +2889,19 @@ function evaluateTurnCandidate(ctx, radius) {
2601
2889
  seed = depths;
2602
2890
  depths = solved;
2603
2891
  } else {
2604
- const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
2605
2892
  depths = {
2606
- eye: aitken(
2607
- seed.eye,
2608
- depths.eye,
2609
- solved.eye,
2610
- capOf(ctx.landmarks.eye)
2611
- ),
2893
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2612
2894
  nose: aitken(
2613
2895
  seed.nose,
2614
2896
  depths.nose,
2615
2897
  solved.nose,
2616
- capOf(ctx.landmarks.nose)
2898
+ plateCapOf(ctx.landmarks.nose)
2617
2899
  ),
2618
2900
  mouth: aitken(
2619
2901
  seed.mouth,
2620
2902
  depths.mouth,
2621
2903
  solved.mouth,
2622
- capOf(ctx.landmarks.mouth)
2904
+ plateCapOf(ctx.landmarks.mouth)
2623
2905
  )
2624
2906
  };
2625
2907
  seed = void 0;
@@ -2638,7 +2920,7 @@ function evaluateTurnCandidate(ctx, radius) {
2638
2920
  if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2639
2921
  return silhouetteMiss(span);
2640
2922
  }
2641
- if (!eye.reached && !ctx.clampEyeShift) {
2923
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2642
2924
  return {
2643
2925
  blocked: "eyeShift",
2644
2926
  // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
@@ -2651,9 +2933,24 @@ function evaluateTurnCandidate(ctx, radius) {
2651
2933
  };
2652
2934
  }
2653
2935
  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;
2936
+ const scaleOf = (l) => (landmarkLandingX(
2937
+ l,
2938
+ mapAt,
2939
+ -HEAD_TURN_MAX_DEG,
2940
+ eyeShiftPx,
2941
+ l.x + l.w / 2,
2942
+ l.y ?? 0
2943
+ ) - landmarkLandingX(
2944
+ l,
2945
+ mapAt,
2946
+ -HEAD_TURN_MAX_DEG,
2947
+ eyeShiftPx,
2948
+ l.x - l.w / 2,
2949
+ l.y ?? 0
2950
+ )) / l.w;
2655
2951
  const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2656
2952
  const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
2953
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2657
2954
  return {
2658
2955
  radius,
2659
2956
  ratio: scaleOf(far) / scaleOf(near),
@@ -2668,7 +2965,9 @@ function evaluateTurnCandidate(ctx, radius) {
2668
2965
  mouth: features.mouth,
2669
2966
  silhouetteClamped,
2670
2967
  silhouetteCenterShift,
2671
- renderedSilhouetteRatio
2968
+ renderedSilhouetteRatio,
2969
+ strandFeasible,
2970
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2672
2971
  };
2673
2972
  }
2674
2973
  }
@@ -2705,6 +3004,9 @@ function sweepTurnRadii(ctx) {
2705
3004
  unsettled
2706
3005
  };
2707
3006
  }
3007
+ function runHolds(run, ratio) {
3008
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
3009
+ }
2708
3010
  function ratioClusters(candidates) {
2709
3011
  const clusters = [];
2710
3012
  let run = [];
@@ -2727,7 +3029,7 @@ function ratioClusters(candidates) {
2727
3029
  close();
2728
3030
  return clusters;
2729
3031
  }
2730
- function fitTurnRadius(ctx, candidates, target) {
3032
+ function fitTurnRadius(ctx, candidates, target, accept) {
2731
3033
  let lo = candidates[0];
2732
3034
  let hi = candidates[candidates.length - 1];
2733
3035
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2737,13 +3039,42 @@ function fitTurnRadius(ctx, candidates, target) {
2737
3039
  break;
2738
3040
  }
2739
3041
  }
3042
+ return bisectTurnRadius(
3043
+ lo,
3044
+ hi,
3045
+ target,
3046
+ (radius) => {
3047
+ const mid = evaluateTurnCandidate(ctx, radius);
3048
+ return "blocked" in mid ? void 0 : mid;
3049
+ },
3050
+ accept
3051
+ );
3052
+ }
3053
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3054
+ if (!accept(lo) || !accept(hi)) {
3055
+ throw new Error(
3056
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3057
+ );
3058
+ }
3059
+ const miss = (c) => Math.abs(c.ratio - target);
3060
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2740
3061
  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;
3062
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3063
+ if (mid === void 0) break;
3064
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2743
3065
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2744
3066
  else lo = mid;
2745
3067
  }
2746
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3068
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3069
+ return nearest;
3070
+ }
3071
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3072
+ const feasible = candidates.filter((c) => c.strandFeasible);
3073
+ if (feasible.length === candidates.length) return candidates;
3074
+ const holds = ratioClusters(feasible).some(
3075
+ (run) => runHolds(run, farEyeRatio)
3076
+ );
3077
+ return holds ? feasible : candidates;
2747
3078
  }
2748
3079
  function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2749
3080
  const solve = (measured, marks, share) => solveTurnDepthSigned(
@@ -2758,13 +3089,38 @@ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2758
3089
  mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2759
3090
  };
2760
3091
  }
2761
- function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
3092
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2762
3093
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2763
3094
  throw new TurnTargetError(
2764
3095
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2765
3096
  );
2766
3097
  }
2767
3098
  const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3099
+ let strands;
3100
+ const strandSides = ["left", "right"].filter(
3101
+ (key) => strandEdges?.[key] !== void 0
3102
+ );
3103
+ if (strandSides.length > 0) {
3104
+ if (hairFront === void 0) {
3105
+ throw new Error(
3106
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3107
+ );
3108
+ }
3109
+ 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);
3110
+ strands = strandSides.map((key) => {
3111
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3112
+ if (carrier === void 0) {
3113
+ throw new Error(
3114
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3115
+ );
3116
+ }
3117
+ return {
3118
+ ...strandEdges[key],
3119
+ side: key === "left" ? -1 : 1,
3120
+ carrier
3121
+ };
3122
+ });
3123
+ }
2768
3124
  const ctx = {
2769
3125
  landmarks,
2770
3126
  lattice,
@@ -2796,9 +3152,9 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2796
3152
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2797
3153
  hairFrontSilhouette: hairFront,
2798
3154
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2799
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3155
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3156
+ strands
2800
3157
  };
2801
- const clamped = [];
2802
3158
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2803
3159
  const pass = sweepTurnRadii(ctx);
2804
3160
  if (pass.candidates.length === 0) {
@@ -2829,92 +3185,107 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2829
3185
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2830
3186
  };
2831
3187
  }
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
3188
+ const fitFrom = (pool, accept) => {
3189
+ const clamped = [];
3190
+ const clusters = ratioClusters(pool);
3191
+ const distanceTo = (c) => Math.max(
3192
+ c.ratios[0] - targets.farEyeRatio,
3193
+ targets.farEyeRatio - c.ratios[1],
3194
+ 0
2845
3195
  );
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
- };
3196
+ let farEyeRatio = targets.farEyeRatio;
3197
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3198
+ if (cluster === void 0) {
3199
+ cluster = clusters.reduce(
3200
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3201
+ );
3202
+ if (!targets.defaulted.has("farEyeRatio")) {
3203
+ return {
3204
+ unreachable: true,
3205
+ field: "farEyeRatio",
3206
+ value: targets.farEyeRatio,
3207
+ attainable: cluster.ratios
3208
+ };
3209
+ }
3210
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3211
+ clamped.push("farEyeRatio");
3212
+ }
3213
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3214
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3215
+ if (!targets.defaulted.has("farEyeRatio")) {
3216
+ return {
3217
+ unreachable: true,
3218
+ field: "farEyeRatio",
3219
+ value: targets.farEyeRatio,
3220
+ attainable: cluster.ratios
3221
+ };
3222
+ }
3223
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3224
+ }
3225
+ if (!best.eye.reached) clamped.push("eyeShift");
3226
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3227
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3228
+ for (const [field, solution] of [
3229
+ ["noseShift", best.nose],
3230
+ ["mouthShift", best.mouth]
3231
+ ]) {
3232
+ if (solution.reached) continue;
3233
+ if (!targets.defaulted.has(field)) {
3234
+ return {
3235
+ unreachable: true,
3236
+ field,
3237
+ value: targets[field],
3238
+ // noseShift/mouthShift are MAGNITUDES too (see
3239
+ // solveFeatureDepths), so a negative lower bound here would
3240
+ // advertise a value the field's own contract already rules out; 0
3241
+ // is the true floor.
3242
+ attainable: [
3243
+ Math.max(
3244
+ 0,
3245
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3246
+ ),
3247
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3248
+ ]
3249
+ };
3250
+ }
3251
+ clamped.push(field);
2866
3252
  }
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
- };
3253
+ return {
3254
+ unreachable: false,
3255
+ radius: best.radius,
3256
+ holdBase: best.holdBase,
3257
+ holdEdgeAt: best.holdEdgeAt,
3258
+ travel: best.travel,
3259
+ surface: best.surface,
3260
+ depths: {
3261
+ eye: best.eye.depth,
3262
+ nose: best.nose.depth,
3263
+ mouth: best.mouth.depth
3264
+ },
3265
+ achieved: {
3266
+ eyeShift,
3267
+ farEyeRatio: best.ratio,
3268
+ silhouetteRatio: best.renderedSilhouetteRatio
3269
+ },
3270
+ clamped,
3271
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
3272
+ };
3273
+ };
3274
+ const preferred = strandPreferredCandidates(
3275
+ pass.candidates,
3276
+ targets.farEyeRatio
3277
+ );
3278
+ if (preferred !== pass.candidates) {
3279
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3280
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3281
+ return fitted;
2893
3282
  }
2894
- clamped.push(field);
2895
3283
  }
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
- };
3284
+ return fitFrom(pass.candidates, () => true);
2915
3285
  }
2916
3286
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2917
3287
  var HAIR_FRONT_DEPTH = 0.1;
3288
+ var MOUTH_TURN_TILT_DEG = 5;
2918
3289
  var NOD_TRAVEL = 30;
2919
3290
  var NOD_BEND = 0.5;
2920
3291
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -3521,10 +3892,16 @@ function turnSetup(layers) {
3521
3892
  )
3522
3893
  );
3523
3894
  }
3895
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
3896
+ const mouthAnchor = {
3897
+ x: mouthAt.x,
3898
+ y: mouthAt.y,
3899
+ tiltDeg: MOUTH_TURN_TILT_DEG
3900
+ };
3524
3901
  const carriers = new Map(
3525
3902
  members.map((m) => [
3526
3903
  m.role,
3527
- { grid: groupGrids.get(m.group), part: m.part }
3904
+ m.group === "mouthWarp" ? { grid: groupGrids.get(m.group), part: m.part, anchor: mouthAnchor } : { grid: groupGrids.get(m.group), part: m.part }
3528
3905
  ])
3529
3906
  );
3530
3907
  const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
@@ -3595,6 +3972,7 @@ function solveInputsOf(s) {
3595
3972
  }
3596
3973
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
3597
3974
  validateLayerInputs(layers, canvas);
3975
+ validateStrandEdges(layers, options.strandEdges);
3598
3976
  const hasHair = layers.some((l) => l.role === "hair_front");
3599
3977
  const bodyLayer = layers.find((l) => l.role === "body");
3600
3978
  const parameters = [...STANDARD_PARAMETERS];
@@ -3630,6 +4008,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3630
4008
  plateGuardRows,
3631
4009
  members,
3632
4010
  groupGrids,
4011
+ carriers,
3633
4012
  parallaxUnitY,
3634
4013
  hasNose,
3635
4014
  hasMouthOpen
@@ -3638,7 +4017,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3638
4017
  const turn = hasNose ? solveTurnModel(
3639
4018
  resolveTurnTargets(options.turnTargets),
3640
4019
  ...solveInputsOf(setup),
3641
- options.headEdges
4020
+ options.headEdges,
4021
+ options.strandEdges
3642
4022
  ) : void 0;
3643
4023
  if (turn?.unreachable) {
3644
4024
  throw new TurnTargetError(
@@ -3656,7 +4036,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3656
4036
  holdBase: turn.holdBase,
3657
4037
  depths: turn.depths,
3658
4038
  achieved: turn.achieved,
3659
- clamped: turn.clamped
4039
+ clamped: turn.clamped,
4040
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3660
4041
  });
3661
4042
  }
3662
4043
  const latticeReach = Math.max(
@@ -3729,7 +4110,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3729
4110
  }
3730
4111
  ];
3731
4112
  for (const [group, grid] of groupGrids) {
3732
- const family = turnFamily(members.find((m) => m.group === group).role);
4113
+ const { role } = members.find((m) => m.group === group);
4114
+ const family = turnFamily(role);
3733
4115
  const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
3734
4116
  deformers.push({
3735
4117
  kind: "warp",
@@ -3741,7 +4123,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3741
4123
  StandardParameter.AngleX,
3742
4124
  StandardParameter.AngleY,
3743
4125
  surface,
3744
- (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
4126
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4127
+ carriers.get(role).anchor
3745
4128
  )
3746
4129
  });
3747
4130
  }