@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.js CHANGED
@@ -1839,6 +1839,125 @@ function validateLayerInputs(layers, canvas) {
1839
1839
  }
1840
1840
  }
1841
1841
  }
1842
+ function validateStrandEdges(layers, strandEdges) {
1843
+ if (strandEdges === void 0) return;
1844
+ const where = "auto-rig: validateStrandEdges: strandEdges";
1845
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1846
+ if (!isPlainObject(strandEdges)) {
1847
+ throw new Error(
1848
+ `${where} must be a plain object with optional left / right sides`
1849
+ );
1850
+ }
1851
+ for (const key of ["left", "right"]) {
1852
+ const raw = strandEdges[key];
1853
+ if (raw === void 0) continue;
1854
+ const path = `${where}.${key}`;
1855
+ if (!isPlainObject(raw)) {
1856
+ throw new Error(`${path} must be a plain object`);
1857
+ }
1858
+ for (const field of ["y", "irisOuter", "irisInner", "runOuter"]) {
1859
+ const v = raw[field];
1860
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1861
+ throw new Error(
1862
+ `${path}.${field} is ${String(v)}, not a finite number`
1863
+ );
1864
+ }
1865
+ }
1866
+ const rawFace = raw.runFace;
1867
+ if (rawFace !== null && (typeof rawFace !== "number" || !Number.isFinite(rawFace))) {
1868
+ throw new Error(
1869
+ `${path}.runFace is ${String(rawFace)}, not a finite number or null`
1870
+ );
1871
+ }
1872
+ const { y, irisOuter, irisInner, runOuter, runFace } = raw;
1873
+ const cropOf = (role) => {
1874
+ const layer = layers.find((l) => l.role === role);
1875
+ if (layer === void 0) {
1876
+ throw new Error(
1877
+ `${path} needs a "${role}" layer to be measured on, and this layer set has none`
1878
+ );
1879
+ }
1880
+ const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
1881
+ return {
1882
+ role,
1883
+ x: t.x,
1884
+ left: t.x - layer.cropW / 2,
1885
+ right: t.x + layer.cropW / 2,
1886
+ bottom: t.y - layer.cropH / 2,
1887
+ top: t.y + layer.cropH / 2
1888
+ };
1889
+ };
1890
+ const hair = cropOf("hair_front");
1891
+ const [lowIris, highIris] = [cropOf("iris_L"), cropOf("iris_R")].sort(
1892
+ (a, b) => a.x - b.x
1893
+ );
1894
+ const sign = key === "left" ? -1 : 1;
1895
+ const iris = key === "left" ? lowIris : highIris;
1896
+ const other = key === "left" ? highIris : lowIris;
1897
+ const fail = (field, value, why) => new Error(`${path}.${field} (${value}) ${why}`);
1898
+ for (const crop of [iris, hair]) {
1899
+ if (y < crop.bottom || y > crop.top) {
1900
+ throw fail(
1901
+ "y",
1902
+ y,
1903
+ `lies outside the "${crop.role}" crop's rows (${crop.bottom}\u2026${crop.top})`
1904
+ );
1905
+ }
1906
+ }
1907
+ for (const [field, x] of [
1908
+ ["irisOuter", irisOuter],
1909
+ ["irisInner", irisInner]
1910
+ ]) {
1911
+ if (x < iris.left || x > iris.right) {
1912
+ throw fail(
1913
+ field,
1914
+ x,
1915
+ `lies outside the "${iris.role}" crop's columns (${iris.left}\u2026${iris.right})`
1916
+ );
1917
+ }
1918
+ }
1919
+ if (sign * (irisOuter - irisInner) <= 0) {
1920
+ throw fail(
1921
+ "irisOuter",
1922
+ irisOuter,
1923
+ `must lie strictly outward of strandEdges.${key}.irisInner (${irisInner})`
1924
+ );
1925
+ }
1926
+ for (const [field, x] of [
1927
+ ["runOuter", runOuter],
1928
+ ["runFace", runFace]
1929
+ ]) {
1930
+ if (x !== null && (x < hair.left || x > hair.right)) {
1931
+ throw fail(
1932
+ field,
1933
+ x,
1934
+ `lies outside the "hair_front" crop's columns (${hair.left}\u2026${hair.right})`
1935
+ );
1936
+ }
1937
+ }
1938
+ if (sign * (runOuter - iris.x) <= 0) {
1939
+ throw fail(
1940
+ "runOuter",
1941
+ runOuter,
1942
+ `must lie strictly outward of the "${iris.role}" crop's centre (${iris.x})`
1943
+ );
1944
+ }
1945
+ if (runFace !== null && sign * (runOuter - runFace) <= 0) {
1946
+ throw fail(
1947
+ "runOuter",
1948
+ runOuter,
1949
+ `must lie strictly outward of strandEdges.${key}.runFace (${runFace})`
1950
+ );
1951
+ }
1952
+ if (runFace !== null && sign * (runFace - other.x) <= 0) {
1953
+ throw fail(
1954
+ "runFace",
1955
+ runFace,
1956
+ `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`
1957
+ );
1958
+ }
1959
+ }
1960
+ }
1842
1961
  function generateGridPoints(cols, rows, minX, maxX, minY, maxY) {
1843
1962
  const pts = [];
1844
1963
  for (let row = 0; row <= rows; row++) {
@@ -1995,21 +2114,26 @@ function turnSurface(spec) {
1995
2114
  )
1996
2115
  };
1997
2116
  }
1998
- function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt) {
2117
+ function bakeTurnGroupWarp2D(grid, parameterX, parameterY, surface, shiftAt, anchor) {
1999
2118
  const STOPS = [...HEAD_TURN_STOPS];
2000
2119
  const pointCount = grid.points.length / 2;
2001
2120
  const keyforms2d = [];
2002
2121
  for (const angleY of STOPS) {
2003
2122
  for (const angleX of STOPS) {
2004
2123
  const shift = shiftAt(angleX);
2124
+ const mapAt = (nodeY) => surface.mapAt(angleX, nodeY);
2005
2125
  const offsets = [];
2006
2126
  for (let i = 0; i < pointCount; i++) {
2007
2127
  const x = grid.points[i * 2];
2008
2128
  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
- );
2129
+ const nodDy = angleY === 0 ? 0 : surface.nodBendAt(y, angleY);
2130
+ if (angleX === 0) {
2131
+ offsets.push(0, nodDy);
2132
+ continue;
2133
+ }
2134
+ const landed = groupNodeLanding(anchor, mapAt, angleX, shift, x, y);
2135
+ const turnDy = landed.y - y;
2136
+ offsets.push(landed.x - x, angleY === 0 ? turnDy : turnDy + nodDy);
2013
2137
  }
2014
2138
  keyforms2d.push({ offsets });
2015
2139
  }
@@ -2129,6 +2253,22 @@ function carrierLandingX(carrier, nodeLanding, px, py) {
2129
2253
  py
2130
2254
  );
2131
2255
  }
2256
+ function groupNodeLanding(anchor, mapAt, deg, shift, nodeX, nodeY) {
2257
+ if (anchor === void 0) {
2258
+ return { x: mapAt(nodeY).mapX(nodeX + shift), y: nodeY };
2259
+ }
2260
+ const anchorRow = mapAt(anchor.y);
2261
+ const pivotX = anchorRow.mapX(anchor.x + shift);
2262
+ const dx = mapAt(nodeY).mapX(nodeX) - anchorRow.mapX(anchor.x);
2263
+ const dy = nodeY - anchor.y;
2264
+ const phi = anchor.tiltDeg * deg / HEAD_TURN_MAX_DEG * (Math.PI / 180);
2265
+ const cos = Math.cos(phi);
2266
+ const sin = Math.sin(phi);
2267
+ return {
2268
+ x: pivotX + cos * dx - sin * dy,
2269
+ y: anchor.y + sin * dx + cos * dy
2270
+ };
2271
+ }
2132
2272
  function rowMapsOf(surface) {
2133
2273
  const maps = /* @__PURE__ */ new Map();
2134
2274
  return (deg, nodeY) => {
@@ -2282,11 +2422,12 @@ function eyeRowOf(landmarks, fallback) {
2282
2422
  }
2283
2423
  var TURN_BISECT_STEPS = 40;
2284
2424
  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);
2425
+ function landmarkLandingX(landmark, mapAt, deg, shift, px, py) {
2426
+ const { carrier } = landmark;
2427
+ if (carrier === void 0) return mapAt(py).mapX(px + shift);
2287
2428
  return carrierLandingX(
2288
- landmark.carrier,
2289
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2429
+ carrier,
2430
+ (nodeX, nodeY) => groupNodeLanding(carrier.anchor, mapAt, deg, shift, nodeX, nodeY).x,
2290
2431
  px,
2291
2432
  py
2292
2433
  );
@@ -2295,22 +2436,47 @@ function familyReachPx(landmarks, plateEdgeX) {
2295
2436
  const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
2296
2437
  return Math.max(0, farEdge - plateEdgeX);
2297
2438
  }
2298
- function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX) {
2439
+ function familyDepthCap(landmarks, plateEdgeX, unit) {
2440
+ return familyReachPx(landmarks, plateEdgeX) / unit;
2441
+ }
2442
+ function solveTurnDepthSigned(target, landmarks, unit, mapAt, plateEdgeX, maxDepth) {
2299
2443
  if (landmarks.length === 0) {
2300
2444
  throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
2301
2445
  }
2302
- const cap = familyReachPx(landmarks, plateEdgeX) / unit;
2446
+ const plateCap = familyDepthCap(landmarks, plateEdgeX, unit);
2447
+ const cap = maxDepth === void 0 ? plateCap : Math.min(plateCap, maxDepth);
2303
2448
  const achieved = (d) => landmarks.reduce(
2304
- (sum, l) => sum + (landmarkLandingX(l, mapAt, -d * unit, l.x, l.y ?? 0) - l.x),
2449
+ (sum, l) => sum + (landmarkLandingX(
2450
+ l,
2451
+ mapAt,
2452
+ -HEAD_TURN_MAX_DEG,
2453
+ -d * unit,
2454
+ l.x,
2455
+ l.y ?? 0
2456
+ ) - l.x),
2305
2457
  0
2306
2458
  ) / landmarks.length;
2307
2459
  const atCap = achieved(cap);
2308
2460
  const atRest = achieved(0);
2309
2461
  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 };
2462
+ if (target > atRest + TURN_DEPTH_EPS) {
2463
+ return {
2464
+ reached: false,
2465
+ depth: 0,
2466
+ achieved: atRest,
2467
+ attainable,
2468
+ limit: "floor"
2469
+ };
2470
+ }
2471
+ if (target < atCap - TURN_DEPTH_EPS) {
2472
+ return {
2473
+ reached: false,
2474
+ depth: cap,
2475
+ achieved: atCap,
2476
+ attainable,
2477
+ limit: "cap"
2478
+ };
2479
+ }
2314
2480
  const clampedTarget = Math.min(Math.max(target, atCap), atRest);
2315
2481
  let lo = 0;
2316
2482
  let hi = cap;
@@ -2340,7 +2506,7 @@ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, hol
2340
2506
  }
2341
2507
  return x + side * (holdEdge - holdBase);
2342
2508
  }
2343
- function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2509
+ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadShift) {
2344
2510
  const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
2345
2511
  const top = hairFront.centerY + hairFront.cropH / 2;
2346
2512
  return renderedLandingX(
@@ -2355,7 +2521,7 @@ function hairFrontLandingAt(x, y, hairFront, holdTargetAt, leadTipShift) {
2355
2521
  scaleY: 1,
2356
2522
  rotation: 0
2357
2523
  },
2358
- (vx, vy) => holdTargetAt(vx, vy) - leadTipShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2524
+ (vx, vy) => holdTargetAt(vx, vy) + leadShift * Math.pow((top - vy) / hairFront.cropH, HAIR_SWAY_CURL),
2359
2525
  x,
2360
2526
  y
2361
2527
  );
@@ -2388,6 +2554,19 @@ function aitken(x0, x1, x2, cap) {
2388
2554
  const x = x2 - d2 * d2 / denominator;
2389
2555
  return Number.isFinite(x) ? Math.min(cap, Math.max(0, x)) : x2;
2390
2556
  }
2557
+ function strandDepthCap(margins, plateCap) {
2558
+ const holds = (depth) => margins.every((m) => m(depth) >= 0);
2559
+ if (holds(plateCap)) return plateCap;
2560
+ if (!holds(0)) return void 0;
2561
+ let lo = 0;
2562
+ let hi = plateCap;
2563
+ for (let i = 0; i < TURN_BISECT_STEPS; i++) {
2564
+ const mid = (lo + hi) / 2;
2565
+ if (holds(mid)) lo = mid;
2566
+ else hi = mid;
2567
+ }
2568
+ return lo;
2569
+ }
2391
2570
  function evaluateTurnCandidate(ctx, radius) {
2392
2571
  const surfaceAt = (travel) => turnSurface({
2393
2572
  faceCenterX: ctx.faceCenterX,
@@ -2411,7 +2590,104 @@ function evaluateTurnCandidate(ctx, radius) {
2411
2590
  return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
2412
2591
  };
2413
2592
  const unit = headTurnParallaxUnit(radius);
2593
+ const plateCapOf = (marks) => familyDepthCap(marks, ctx.plateEdgeX, unit);
2594
+ const eyePlateCap = plateCapOf(ctx.landmarks.eye);
2414
2595
  const leadTipShift = ctx.hairFrontSilhouette === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
2596
+ const bangsLandingAt = (hold, plateLandingAt, deg, leadShift, x, y) => hairFrontLandingAt(
2597
+ x,
2598
+ y,
2599
+ ctx.hairFrontSilhouette,
2600
+ (vx, vy) => hairFrontHoldTarget(
2601
+ vx,
2602
+ vy,
2603
+ deg,
2604
+ ctx.faceCenterX,
2605
+ plateLandingAt,
2606
+ ctx.edgeAt,
2607
+ hold.holdBase,
2608
+ hold.holdEdgeAt(deg)
2609
+ ),
2610
+ leadShift
2611
+ );
2612
+ const strandStopsOn = (hold) => {
2613
+ const rowMaps = rowMapsOf(hold.surface);
2614
+ const plateLandingAt = plateLandingOn(ctx.plate, rowMaps);
2615
+ return (ctx.strands ?? []).map((strand) => {
2616
+ const iris = {
2617
+ x: strand.carrier.part.x,
2618
+ w: strand.carrier.part.cropW,
2619
+ carrier: strand.carrier
2620
+ };
2621
+ const stops = HEAD_TURN_STOPS.filter(
2622
+ (deg) => Math.sign(deg) === strand.side
2623
+ ).map((deg) => {
2624
+ const runAt = (x) => bangsLandingAt(
2625
+ hold,
2626
+ plateLandingAt,
2627
+ deg,
2628
+ leadTipShift * deg / HEAD_TURN_MAX_DEG,
2629
+ x,
2630
+ strand.y
2631
+ );
2632
+ return {
2633
+ deg,
2634
+ runOuter: runAt(strand.runOuter),
2635
+ runFace: strand.runFace === null ? null : runAt(strand.runFace),
2636
+ irisAt: (depth, x) => landmarkLandingX(
2637
+ iris,
2638
+ (y) => rowMaps(deg, y),
2639
+ deg,
2640
+ depth * unit * deg / HEAD_TURN_MAX_DEG,
2641
+ x,
2642
+ strand.y
2643
+ )
2644
+ };
2645
+ });
2646
+ const { runFace } = strand;
2647
+ if (runFace === null) return { strand, stops, margins: void 0 };
2648
+ const allowance = Math.min(0, strand.side * (runFace - strand.irisOuter));
2649
+ const margins = stops.map(
2650
+ (stop) => (depth) => strand.side * (stop.runFace - stop.irisAt(depth, strand.irisOuter)) - allowance
2651
+ );
2652
+ return { strand, stops, margins };
2653
+ });
2654
+ };
2655
+ const strandOverlapOf = (sides, depth) => {
2656
+ const overlap = {};
2657
+ for (const { strand, stops, margins } of sides) {
2658
+ const s = strand.side;
2659
+ const covered = (irisOuter, irisInner, runOuter, runFace) => Math.max(
2660
+ 0,
2661
+ Math.min(s * irisOuter, s * runOuter) - Math.max(s * irisInner, runFace === null ? -Infinity : s * runFace)
2662
+ );
2663
+ let worst;
2664
+ for (const stop of stops) {
2665
+ const px = covered(
2666
+ stop.irisAt(depth, strand.irisOuter),
2667
+ stop.irisAt(depth, strand.irisInner),
2668
+ stop.runOuter,
2669
+ stop.runFace
2670
+ );
2671
+ if (px > 0 && (worst === void 0 || px > worst.px)) {
2672
+ worst = { deg: stop.deg, px };
2673
+ }
2674
+ }
2675
+ if (worst === void 0) continue;
2676
+ overlap[s < 0 ? "left" : "right"] = {
2677
+ deg: worst.deg,
2678
+ px: worst.px,
2679
+ hh: worst.px / ctx.hh,
2680
+ restPx: covered(
2681
+ strand.irisOuter,
2682
+ strand.irisInner,
2683
+ strand.runOuter,
2684
+ strand.runFace
2685
+ ),
2686
+ held: margins !== void 0 && margins.every((m) => m(depth) >= 0)
2687
+ };
2688
+ }
2689
+ return overlap.left === void 0 && overlap.right === void 0 ? void 0 : overlap;
2690
+ };
2415
2691
  const evaluateHold = (ratio, depths2) => {
2416
2692
  const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
2417
2693
  const travel = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
@@ -2461,21 +2737,13 @@ function evaluateTurnCandidate(ctx, radius) {
2461
2737
  if (edge !== void 0) return edge;
2462
2738
  return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
2463
2739
  };
2464
- const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
2740
+ const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : bangsLandingAt(
2741
+ { holdBase, holdEdgeAt },
2742
+ plateLandingAt,
2743
+ -HEAD_TURN_MAX_DEG,
2744
+ -leadTipShift,
2465
2745
  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
2746
+ ctx.eyeRowY
2479
2747
  );
2480
2748
  const landingOfRole = (role, x) => {
2481
2749
  if (role === "hair_front") return hairFrontAt(x);
@@ -2495,7 +2763,14 @@ function evaluateTurnCandidate(ctx, radius) {
2495
2763
  const shift = role === "face" ? 0 : -depths2[turnFamily(role)] * unit;
2496
2764
  return carrierLandingX(
2497
2765
  carrier,
2498
- (nodeX, nodeY) => mapAt(nodeY).mapX(nodeX + shift),
2766
+ (nodeX, nodeY) => groupNodeLanding(
2767
+ carrier.anchor,
2768
+ mapAt,
2769
+ -HEAD_TURN_MAX_DEG,
2770
+ shift,
2771
+ nodeX,
2772
+ nodeY
2773
+ ).x,
2499
2774
  x,
2500
2775
  ctx.eyeRowY
2501
2776
  );
@@ -2636,12 +2911,25 @@ function evaluateTurnCandidate(ctx, radius) {
2636
2911
  "auto-rig: evaluateTurnCandidate: the hold it settled on folds"
2637
2912
  );
2638
2913
  }
2914
+ const strandSides = ctx.strands === void 0 ? void 0 : strandStopsOn(hold);
2915
+ let strandFeasible = true;
2916
+ let maxDepth;
2917
+ if (strandSides !== void 0) {
2918
+ const margins = strandSides.map((s) => s.margins);
2919
+ const cap = margins.every((m) => m !== void 0) ? strandDepthCap(
2920
+ margins.flatMap((m) => m),
2921
+ eyePlateCap
2922
+ ) : void 0;
2923
+ strandFeasible = cap !== void 0;
2924
+ maxDepth = cap ?? 0;
2925
+ }
2639
2926
  const eye = solveTurnDepthSigned(
2640
2927
  hold.silhouetteCenterShift - ctx.eyeShift * ctx.hh,
2641
2928
  ctx.landmarks.eye,
2642
2929
  unit,
2643
2930
  hold.mapAt,
2644
- ctx.plateEdgeX
2931
+ ctx.plateEdgeX,
2932
+ maxDepth
2645
2933
  );
2646
2934
  const features = solveFeatureDepths(
2647
2935
  ctx,
@@ -2662,25 +2950,19 @@ function evaluateTurnCandidate(ctx, radius) {
2662
2950
  seed = depths;
2663
2951
  depths = solved;
2664
2952
  } else {
2665
- const capOf = (marks) => familyReachPx(marks, ctx.plateEdgeX) / unit;
2666
2953
  depths = {
2667
- eye: aitken(
2668
- seed.eye,
2669
- depths.eye,
2670
- solved.eye,
2671
- capOf(ctx.landmarks.eye)
2672
- ),
2954
+ eye: aitken(seed.eye, depths.eye, solved.eye, eyePlateCap),
2673
2955
  nose: aitken(
2674
2956
  seed.nose,
2675
2957
  depths.nose,
2676
2958
  solved.nose,
2677
- capOf(ctx.landmarks.nose)
2959
+ plateCapOf(ctx.landmarks.nose)
2678
2960
  ),
2679
2961
  mouth: aitken(
2680
2962
  seed.mouth,
2681
2963
  depths.mouth,
2682
2964
  solved.mouth,
2683
- capOf(ctx.landmarks.mouth)
2965
+ plateCapOf(ctx.landmarks.mouth)
2684
2966
  )
2685
2967
  };
2686
2968
  seed = void 0;
@@ -2699,7 +2981,7 @@ function evaluateTurnCandidate(ctx, radius) {
2699
2981
  if (silhouetteClamped && !ctx.clampSilhouetteRatio) {
2700
2982
  return silhouetteMiss(span);
2701
2983
  }
2702
- if (!eye.reached && !ctx.clampEyeShift) {
2984
+ if (!eye.reached && eye.limit === "floor" && !ctx.clampEyeShift) {
2703
2985
  return {
2704
2986
  blocked: "eyeShift",
2705
2987
  // eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so
@@ -2712,9 +2994,24 @@ function evaluateTurnCandidate(ctx, radius) {
2712
2994
  };
2713
2995
  }
2714
2996
  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;
2997
+ const scaleOf = (l) => (landmarkLandingX(
2998
+ l,
2999
+ mapAt,
3000
+ -HEAD_TURN_MAX_DEG,
3001
+ eyeShiftPx,
3002
+ l.x + l.w / 2,
3003
+ l.y ?? 0
3004
+ ) - landmarkLandingX(
3005
+ l,
3006
+ mapAt,
3007
+ -HEAD_TURN_MAX_DEG,
3008
+ eyeShiftPx,
3009
+ l.x - l.w / 2,
3010
+ l.y ?? 0
3011
+ )) / l.w;
2716
3012
  const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
2717
3013
  const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
3014
+ const strandOverlap = strandSides === void 0 ? void 0 : strandOverlapOf(strandSides, eye.depth);
2718
3015
  return {
2719
3016
  radius,
2720
3017
  ratio: scaleOf(far) / scaleOf(near),
@@ -2729,7 +3026,9 @@ function evaluateTurnCandidate(ctx, radius) {
2729
3026
  mouth: features.mouth,
2730
3027
  silhouetteClamped,
2731
3028
  silhouetteCenterShift,
2732
- renderedSilhouetteRatio
3029
+ renderedSilhouetteRatio,
3030
+ strandFeasible,
3031
+ ...strandOverlap === void 0 ? {} : { strandOverlap }
2733
3032
  };
2734
3033
  }
2735
3034
  }
@@ -2766,6 +3065,9 @@ function sweepTurnRadii(ctx) {
2766
3065
  unsettled
2767
3066
  };
2768
3067
  }
3068
+ function runHolds(run, ratio) {
3069
+ return ratio >= run.ratios[0] - TURN_DEPTH_EPS && ratio <= run.ratios[1] + TURN_DEPTH_EPS;
3070
+ }
2769
3071
  function ratioClusters(candidates) {
2770
3072
  const clusters = [];
2771
3073
  let run = [];
@@ -2788,7 +3090,7 @@ function ratioClusters(candidates) {
2788
3090
  close();
2789
3091
  return clusters;
2790
3092
  }
2791
- function fitTurnRadius(ctx, candidates, target) {
3093
+ function fitTurnRadius(ctx, candidates, target, accept) {
2792
3094
  let lo = candidates[0];
2793
3095
  let hi = candidates[candidates.length - 1];
2794
3096
  for (let i = candidates.length - 2; i >= 0; i--) {
@@ -2798,13 +3100,42 @@ function fitTurnRadius(ctx, candidates, target) {
2798
3100
  break;
2799
3101
  }
2800
3102
  }
3103
+ return bisectTurnRadius(
3104
+ lo,
3105
+ hi,
3106
+ target,
3107
+ (radius) => {
3108
+ const mid = evaluateTurnCandidate(ctx, radius);
3109
+ return "blocked" in mid ? void 0 : mid;
3110
+ },
3111
+ accept
3112
+ );
3113
+ }
3114
+ function bisectTurnRadius(lo, hi, target, evaluate, accept) {
3115
+ if (!accept(lo) || !accept(hi)) {
3116
+ throw new Error(
3117
+ "auto-rig: bisectTurnRadius: a bracket end is not accepted"
3118
+ );
3119
+ }
3120
+ const miss = (c) => Math.abs(c.ratio - target);
3121
+ let nearest = miss(lo) <= miss(hi) ? lo : hi;
2801
3122
  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;
3123
+ const mid = evaluate(Math.sqrt(lo.radius * hi.radius));
3124
+ if (mid === void 0) break;
3125
+ if (accept(mid) && miss(mid) < miss(nearest)) nearest = mid;
2804
3126
  if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
2805
3127
  else lo = mid;
2806
3128
  }
2807
- return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
3129
+ if (accept(lo) && accept(hi)) return miss(lo) <= miss(hi) ? lo : hi;
3130
+ return nearest;
3131
+ }
3132
+ function strandPreferredCandidates(candidates, farEyeRatio) {
3133
+ const feasible = candidates.filter((c) => c.strandFeasible);
3134
+ if (feasible.length === candidates.length) return candidates;
3135
+ const holds = ratioClusters(feasible).some(
3136
+ (run) => runHolds(run, farEyeRatio)
3137
+ );
3138
+ return holds ? feasible : candidates;
2808
3139
  }
2809
3140
  function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2810
3141
  const solve = (measured, marks, share) => solveTurnDepthSigned(
@@ -2819,13 +3150,38 @@ function solveFeatureDepths(ctx, eyeShift, unit, mapAt, silhouetteCenterShift) {
2819
3150
  mouth: solve(ctx.mouthShift, ctx.landmarks.mouth, MOUTH_SHIFT_SHARE)
2820
3151
  };
2821
3152
  }
2822
- function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges) {
3153
+ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth, faceCenterY, nodRadius, carriers, profile, hairFront, headEdges, strandEdges) {
2823
3154
  if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
2824
3155
  throw new TurnTargetError(
2825
3156
  `auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
2826
3157
  );
2827
3158
  }
2828
3159
  const eyeRowY = eyeRowOf(landmarks, faceCenterY);
3160
+ let strands;
3161
+ const strandSides = ["left", "right"].filter(
3162
+ (key) => strandEdges?.[key] !== void 0
3163
+ );
3164
+ if (strandSides.length > 0) {
3165
+ if (hairFront === void 0) {
3166
+ throw new Error(
3167
+ "auto-rig: solveTurnModel: strandEdges was given, but there is no hair_front to land its runs on"
3168
+ );
3169
+ }
3170
+ 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);
3171
+ strands = strandSides.map((key) => {
3172
+ const carrier = irises.length === 2 ? irises[key === "left" ? 0 : 1] : void 0;
3173
+ if (carrier === void 0) {
3174
+ throw new Error(
3175
+ `auto-rig: solveTurnModel: strandEdges.${key} has no iris carrier to land`
3176
+ );
3177
+ }
3178
+ return {
3179
+ ...strandEdges[key],
3180
+ side: key === "left" ? -1 : 1,
3181
+ carrier
3182
+ };
3183
+ });
3184
+ }
2829
3185
  const ctx = {
2830
3186
  landmarks,
2831
3187
  lattice,
@@ -2857,9 +3213,9 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2857
3213
  clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
2858
3214
  hairFrontSilhouette: hairFront,
2859
3215
  // A companion to a MEASURED headHalfWidth only — see its own doc.
2860
- headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
3216
+ headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges,
3217
+ strands
2861
3218
  };
2862
- const clamped = [];
2863
3219
  const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
2864
3220
  const pass = sweepTurnRadii(ctx);
2865
3221
  if (pass.candidates.length === 0) {
@@ -2890,92 +3246,107 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
2890
3246
  attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
2891
3247
  };
2892
3248
  }
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
3249
+ const fitFrom = (pool, accept) => {
3250
+ const clamped = [];
3251
+ const clusters = ratioClusters(pool);
3252
+ const distanceTo = (c) => Math.max(
3253
+ c.ratios[0] - targets.farEyeRatio,
3254
+ targets.farEyeRatio - c.ratios[1],
3255
+ 0
2906
3256
  );
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
- };
3257
+ let farEyeRatio = targets.farEyeRatio;
3258
+ let cluster = clusters.find((c) => runHolds(c, farEyeRatio));
3259
+ if (cluster === void 0) {
3260
+ cluster = clusters.reduce(
3261
+ (a, b) => distanceTo(b) < distanceTo(a) ? b : a
3262
+ );
3263
+ if (!targets.defaulted.has("farEyeRatio")) {
3264
+ return {
3265
+ unreachable: true,
3266
+ field: "farEyeRatio",
3267
+ value: targets.farEyeRatio,
3268
+ attainable: cluster.ratios
3269
+ };
3270
+ }
3271
+ farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
3272
+ clamped.push("farEyeRatio");
3273
+ }
3274
+ const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio, accept);
3275
+ if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
3276
+ if (!targets.defaulted.has("farEyeRatio")) {
3277
+ return {
3278
+ unreachable: true,
3279
+ field: "farEyeRatio",
3280
+ value: targets.farEyeRatio,
3281
+ attainable: cluster.ratios
3282
+ };
3283
+ }
3284
+ if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
3285
+ }
3286
+ if (!best.eye.reached) clamped.push("eyeShift");
3287
+ if (best.silhouetteClamped) clamped.push("silhouetteRatio");
3288
+ const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
3289
+ for (const [field, solution] of [
3290
+ ["noseShift", best.nose],
3291
+ ["mouthShift", best.mouth]
3292
+ ]) {
3293
+ if (solution.reached) continue;
3294
+ if (!targets.defaulted.has(field)) {
3295
+ return {
3296
+ unreachable: true,
3297
+ field,
3298
+ value: targets[field],
3299
+ // noseShift/mouthShift are MAGNITUDES too (see
3300
+ // solveFeatureDepths), so a negative lower bound here would
3301
+ // advertise a value the field's own contract already rules out; 0
3302
+ // is the true floor.
3303
+ attainable: [
3304
+ Math.max(
3305
+ 0,
3306
+ (-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
3307
+ ),
3308
+ (-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
3309
+ ]
3310
+ };
3311
+ }
3312
+ clamped.push(field);
2927
3313
  }
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
- };
3314
+ return {
3315
+ unreachable: false,
3316
+ radius: best.radius,
3317
+ holdBase: best.holdBase,
3318
+ holdEdgeAt: best.holdEdgeAt,
3319
+ travel: best.travel,
3320
+ surface: best.surface,
3321
+ depths: {
3322
+ eye: best.eye.depth,
3323
+ nose: best.nose.depth,
3324
+ mouth: best.mouth.depth
3325
+ },
3326
+ achieved: {
3327
+ eyeShift,
3328
+ farEyeRatio: best.ratio,
3329
+ silhouetteRatio: best.renderedSilhouetteRatio
3330
+ },
3331
+ clamped,
3332
+ ...best.strandOverlap === void 0 ? {} : { strandOverlap: best.strandOverlap }
3333
+ };
3334
+ };
3335
+ const preferred = strandPreferredCandidates(
3336
+ pass.candidates,
3337
+ targets.farEyeRatio
3338
+ );
3339
+ if (preferred !== pass.candidates) {
3340
+ const fitted = fitFrom(preferred, (c) => c.strandFeasible);
3341
+ if (!fitted.unreachable && !fitted.clamped.includes("farEyeRatio")) {
3342
+ return fitted;
2954
3343
  }
2955
- clamped.push(field);
2956
3344
  }
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
- };
3345
+ return fitFrom(pass.candidates, () => true);
2976
3346
  }
2977
3347
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
2978
3348
  var HAIR_FRONT_DEPTH = 0.1;
3349
+ var MOUTH_TURN_TILT_DEG = 5;
2979
3350
  var NOD_TRAVEL = 30;
2980
3351
  var NOD_BEND = 0.5;
2981
3352
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -3582,10 +3953,16 @@ function turnSetup(layers) {
3582
3953
  )
3583
3954
  );
3584
3955
  }
3956
+ const mouthAt = members.find((m) => m.role === "mouth").transform;
3957
+ const mouthAnchor = {
3958
+ x: mouthAt.x,
3959
+ y: mouthAt.y,
3960
+ tiltDeg: MOUTH_TURN_TILT_DEG
3961
+ };
3585
3962
  const carriers = new Map(
3586
3963
  members.map((m) => [
3587
3964
  m.role,
3588
- { grid: groupGrids.get(m.group), part: m.part }
3965
+ m.group === "mouthWarp" ? { grid: groupGrids.get(m.group), part: m.part, anchor: mouthAnchor } : { grid: groupGrids.get(m.group), part: m.part }
3589
3966
  ])
3590
3967
  );
3591
3968
  const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
@@ -3656,6 +4033,7 @@ function solveInputsOf(s) {
3656
4033
  }
3657
4034
  function generateIkiFromLayerSet(layers, canvas, options = {}) {
3658
4035
  validateLayerInputs(layers, canvas);
4036
+ validateStrandEdges(layers, options.strandEdges);
3659
4037
  const hasHair = layers.some((l) => l.role === "hair_front");
3660
4038
  const bodyLayer = layers.find((l) => l.role === "body");
3661
4039
  const parameters = [...STANDARD_PARAMETERS];
@@ -3691,6 +4069,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3691
4069
  plateGuardRows,
3692
4070
  members,
3693
4071
  groupGrids,
4072
+ carriers,
3694
4073
  parallaxUnitY,
3695
4074
  hasNose,
3696
4075
  hasMouthOpen
@@ -3699,7 +4078,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3699
4078
  const turn = hasNose ? solveTurnModel(
3700
4079
  resolveTurnTargets(options.turnTargets),
3701
4080
  ...solveInputsOf(setup),
3702
- options.headEdges
4081
+ options.headEdges,
4082
+ options.strandEdges
3703
4083
  ) : void 0;
3704
4084
  if (turn?.unreachable) {
3705
4085
  throw new TurnTargetError(
@@ -3717,7 +4097,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3717
4097
  holdBase: turn.holdBase,
3718
4098
  depths: turn.depths,
3719
4099
  achieved: turn.achieved,
3720
- clamped: turn.clamped
4100
+ clamped: turn.clamped,
4101
+ ...turn.strandOverlap === void 0 ? {} : { strandOverlap: turn.strandOverlap }
3721
4102
  });
3722
4103
  }
3723
4104
  const latticeReach = Math.max(
@@ -3790,7 +4171,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3790
4171
  }
3791
4172
  ];
3792
4173
  for (const [group, grid] of groupGrids) {
3793
- const family = turnFamily(members.find((m) => m.group === group).role);
4174
+ const { role } = members.find((m) => m.group === group);
4175
+ const family = turnFamily(role);
3794
4176
  const depth = group === "faceWarp" ? 0 : turn?.depths[family] ?? 0;
3795
4177
  deformers.push({
3796
4178
  kind: "warp",
@@ -3802,7 +4184,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
3802
4184
  import_format3.StandardParameter.AngleX,
3803
4185
  import_format3.StandardParameter.AngleY,
3804
4186
  surface,
3805
- (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG
4187
+ (deg) => depth * parallaxUnit * deg / HEAD_TURN_MAX_DEG,
4188
+ carriers.get(role).anchor
3806
4189
  )
3807
4190
  });
3808
4191
  }