@ikijs/editor 0.28.0 → 0.30.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
@@ -1652,6 +1652,8 @@ var ROW_RUN_ROLES = /* @__PURE__ */ new Set([
1652
1652
  "face",
1653
1653
  "hair_front",
1654
1654
  "hair_back",
1655
+ "hair_side_L",
1656
+ "hair_side_R",
1655
1657
  "body",
1656
1658
  "arm_L",
1657
1659
  "arm_R",
@@ -2081,8 +2083,9 @@ function deriveProfile(v) {
2081
2083
  /** The mouth's far corner rises (its near end drops) by this much: it
2082
2084
  * stays level. */
2083
2085
  mouthTiltDeg: 0,
2084
- /** The front hair (bangs and side locks) rides the face at this share of
2085
- * its translation. */
2086
+ /** The front hair (its bangs and its own side strands; a `hair_side_*` lock
2087
+ * takes it only down to its root, `hairSideField`) rides the face at this
2088
+ * share of its translation. */
2086
2089
  hairFollow: v.hairFollow,
2087
2090
  /** The head's outline at the eye row holds: where the front hair draws
2088
2091
  * it, its outer edge follows the face this much on a row where no back
@@ -2298,177 +2301,15 @@ function meshPoints(mesh, box) {
2298
2301
  return out;
2299
2302
  }
2300
2303
 
2301
- // src/auto-rig/roles.ts
2302
- var import_format3 = require("@ikijs/format");
2303
- var LIP_ROLES = ["mouth_inner", "lip_lower", "lip_upper"];
2304
- var isLipRole = (role) => LIP_ROLES.includes(role);
2305
- var LIP_INSIDE_ROLES = ["mouth_tongue", "mouth_teeth"];
2306
- var isLipInsideRole = (role) => LIP_INSIDE_ROLES.includes(role);
2307
- var SHUT_BY = new Map([
2308
- ["eye_L", import_format3.StandardParameter.EyeOpenLeft],
2309
- ["lash_lower_L", import_format3.StandardParameter.EyeOpenLeft],
2310
- ["eye_R", import_format3.StandardParameter.EyeOpenRight],
2311
- ["lash_lower_R", import_format3.StandardParameter.EyeOpenRight],
2312
- ...[...LIP_ROLES, ...LIP_INSIDE_ROLES].map((id) => [
2313
- id,
2314
- import_format3.StandardParameter.MouthOpen
2315
- ])
2316
- ]);
2317
- var ROLE_TABLE = [
2318
- { role: "hair_back", family: "hair_back" },
2319
- { role: "body", family: "body" },
2320
- { role: "arm_L", family: "arm", parts: ["elbow_L", "forearm_L", "arm_L"] },
2321
- { role: "arm_R", family: "arm", parts: ["elbow_R", "forearm_R", "arm_R"] },
2322
- { role: "face", family: "face", required: true },
2323
- { role: "blush_L", family: "face" },
2324
- { role: "blush_R", family: "face" },
2325
- { role: "nose", family: "nose" },
2326
- { role: "mouth", family: "mouth" },
2327
- { role: "mouth_open", family: "mouth" },
2328
- // The lip set, back to front: the interior, the tongue and the teeth clipped
2329
- // to it, the lower lip's skin, the upper lip and its line over both. It
2330
- // folds open instead of crossfading (`mouth.ts`), so it stands in for
2331
- // `mouth`, never beside it.
2332
- { role: "mouth_inner", family: "mouth" },
2333
- { role: "mouth_tongue", family: "mouth" },
2334
- { role: "mouth_teeth", family: "mouth" },
2335
- { role: "lip_lower", family: "mouth" },
2336
- { role: "lip_upper", family: "mouth" },
2337
- { role: "eye_L", family: "eye_L", required: true },
2338
- { role: "eye_R", family: "eye_R", required: true },
2339
- { role: "iris_L", family: "eye_L" },
2340
- { role: "iris_R", family: "eye_R" },
2341
- { role: "pupil_L", family: "eye_L" },
2342
- { role: "pupil_R", family: "eye_R" },
2343
- { role: "highlight_L", family: "eye_L" },
2344
- { role: "highlight_R", family: "eye_R" },
2345
- { role: "lash_lower_L", family: "eye_L" },
2346
- { role: "lash_lower_R", family: "eye_R" },
2347
- { role: "lash_L", family: "eye_L" },
2348
- { role: "lash_R", family: "eye_R" },
2349
- { role: "brow_L", family: "brow_L" },
2350
- { role: "brow_R", family: "brow_R" },
2351
- { role: "hair_front", family: "hair_front" },
2352
- // Drawn above the face and the hair: a raised hand passes them.
2353
- { role: "forearm_pose_L", family: "forearm_pose" },
2354
- { role: "forearm_pose_R", family: "forearm_pose" }
2355
- ];
2356
- var REQUIRED_ROLES = ROLE_TABLE.filter(
2357
- (r) => r.required
2358
- ).map((r) => r.role);
2359
- var SPEC_BY_ROLE = new Map(ROLE_TABLE.map((r, i) => [r.role, { ...r, i }]));
2360
- function roleSpec(role) {
2361
- const spec = SPEC_BY_ROLE.get(role);
2362
- if (spec === void 0) throw new Error(`auto-rig: unknown role "${role}"`);
2363
- return spec;
2364
- }
2365
- function partIdsOfRole(role) {
2366
- return roleSpec(role).parts ?? [role];
2367
- }
2368
- var ROLE_BY_PART = new Map(
2369
- ROLE_TABLE.flatMap((r) => (r.parts ?? []).map((p) => [p, r.role]))
2370
- );
2371
- function roleOfPart(partId) {
2372
- return ROLE_BY_PART.get(partId) ?? partId;
2373
- }
2374
- var ALIASES = {
2375
- eyebrow_L: "brow_L",
2376
- eyebrow_R: "brow_R",
2377
- eye_white_L: "eye_L",
2378
- eye_white_R: "eye_R"
2379
- };
2380
- function roleOf(fileName) {
2381
- const role = fileName.replace(/\.[^.]+$/, "").toLowerCase().replace(/[-\s]+/g, "_").replace(/_([lr])$/, (_, s) => `_${s.toUpperCase()}`);
2382
- return ALIASES[role] ?? role;
2383
- }
2384
- function checkArmsHaveBody(roles) {
2385
- const has = new Set(roles);
2386
- if (has.has("body")) return;
2387
- const arm = ROLE_TABLE.find((r) => r.family === "arm" && has.has(r.role));
2388
- if (arm !== void 0) {
2389
- throw new Error(
2390
- `auto-rig: ${arm.role} needs a body layer (an arm hangs from the body's shoulder)`
2391
- );
2392
- }
2393
- }
2394
- function checkMouth(roles) {
2395
- const has = new Set(roles);
2396
- const present = LIP_ROLES.filter((r) => has.has(r));
2397
- const inside = LIP_INSIDE_ROLES.filter((r) => has.has(r));
2398
- if (inside.length > 0 && present.length === 0) {
2399
- throw new Error(
2400
- `auto-rig: ${inside[0]} needs the lip set ${LIP_ROLES.join(", ")} (it is cut from the interior and clipped to it)`
2401
- );
2402
- }
2403
- if (present.length === 0) {
2404
- if (!has.has("mouth")) {
2405
- throw new Error(
2406
- `auto-rig: missing required role mouth (or the lip set ${LIP_ROLES.join(", ")})`
2407
- );
2408
- }
2409
- return;
2410
- }
2411
- if (present.length < LIP_ROLES.length) {
2412
- throw new Error(
2413
- `auto-rig: the lip set is partial: missing ${LIP_ROLES.filter((r) => !has.has(r)).join(", ")}`
2414
- );
2415
- }
2416
- const mixed = ["mouth", "mouth_open"].filter((r) => has.has(r));
2417
- if (mixed.length > 0) {
2418
- throw new Error(
2419
- `auto-rig: the lip set cannot be mixed with ${mixed.join(", ")} (the lip set replaces the mouth drawings; use one or the other)`
2420
- );
2421
- }
2422
- }
2423
- function checkPosesHaveArms(roles) {
2424
- const has = new Set(roles);
2425
- for (const side of ["L", "R"]) {
2426
- if (has.has(`forearm_pose_${side}`) && !has.has(`arm_${side}`)) {
2427
- throw new Error(
2428
- `auto-rig: forearm_pose_${side} needs an arm_${side} layer (a pose forearm hangs from its arm's elbow)`
2429
- );
2430
- }
2431
- }
2432
- }
2433
- function parseLayerRoles(fileNames) {
2434
- const seen = /* @__PURE__ */ new Map();
2435
- const out = [];
2436
- for (const fileName of fileNames) {
2437
- const role = roleOf(fileName);
2438
- if (!SPEC_BY_ROLE.has(role)) {
2439
- throw new Error(
2440
- `auto-rig: "${fileName}" names no known role (expected one of ${ROLE_TABLE.map((r) => r.role).join(", ")})`
2441
- );
2442
- }
2443
- const prior = seen.get(role);
2444
- if (prior !== void 0) {
2445
- throw new Error(
2446
- `auto-rig: role "${role}" is named twice ("${prior}" and "${fileName}")`
2447
- );
2448
- }
2449
- seen.set(role, fileName);
2450
- out.push({ role, fileName });
2451
- }
2452
- const missing = REQUIRED_ROLES.filter((r) => !seen.has(r));
2453
- if (missing.length > 0) {
2454
- throw new Error(
2455
- `auto-rig: missing required role${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`
2456
- );
2457
- }
2458
- checkMouth(seen.keys());
2459
- checkArmsHaveBody(seen.keys());
2460
- checkPosesHaveArms(seen.keys());
2461
- return out;
2462
- }
2463
-
2464
2304
  // src/auto-rig/mouth.ts
2465
2305
  var import_format5 = require("@ikijs/format");
2466
2306
 
2467
2307
  // src/auto-rig/drivers.ts
2468
- var import_format4 = require("@ikijs/format");
2308
+ var import_format3 = require("@ikijs/format");
2469
2309
  var SWAY_RANGE = 20;
2470
2310
  var SWAY_CURVE = 1.4;
2471
2311
  var HAIR_HANG = 0.35;
2312
+ var LOCK_HANG_SPAN = 1;
2472
2313
  var Z_STOPS = [-30, 0, 30];
2473
2314
  function localWarp(parameter, mesh, box, values, f) {
2474
2315
  const pts = meshPoints(mesh, box);
@@ -2543,13 +2384,13 @@ function gazeBindings(irisW, share = 1) {
2543
2384
  const gy = AMPLITUDE.gazeY * irisW * share;
2544
2385
  return [
2545
2386
  {
2546
- parameter: import_format4.StandardParameter.EyeballX,
2387
+ parameter: import_format3.StandardParameter.EyeballX,
2547
2388
  channel: "translateX",
2548
2389
  from: -round1(gx),
2549
2390
  to: round1(gx)
2550
2391
  },
2551
2392
  {
2552
- parameter: import_format4.StandardParameter.EyeballY,
2393
+ parameter: import_format3.StandardParameter.EyeballY,
2553
2394
  channel: "translateY",
2554
2395
  from: -round1(gy),
2555
2396
  to: round1(gy)
@@ -2563,8 +2404,8 @@ function browReach(hh) {
2563
2404
  return AMPLITUDE.brow * hh;
2564
2405
  }
2565
2406
  function browBindings(side, reach) {
2566
- const y = side === "L" ? import_format4.StandardParameter.BrowLeftY : import_format4.StandardParameter.BrowRightY;
2567
- const a = side === "L" ? import_format4.StandardParameter.BrowLeftAngle : import_format4.StandardParameter.BrowRightAngle;
2407
+ const y = side === "L" ? import_format3.StandardParameter.BrowLeftY : import_format3.StandardParameter.BrowRightY;
2408
+ const a = side === "L" ? import_format3.StandardParameter.BrowLeftAngle : import_format3.StandardParameter.BrowRightAngle;
2568
2409
  return [
2569
2410
  {
2570
2411
  parameter: y,
@@ -2583,7 +2424,7 @@ var mouthFrameOf = (box) => ({
2583
2424
  });
2584
2425
  function mouthForm(mesh, box, frame = mouthFrameOf(box)) {
2585
2426
  const { c, half, amp: a } = frame;
2586
- return localWarp(import_format4.StandardParameter.MouthForm, mesh, box, [-1, 0, 1], ([x], v) => {
2427
+ return localWarp(import_format3.StandardParameter.MouthForm, mesh, box, [-1, 0, 1], ([x], v) => {
2587
2428
  if (v === 0) return [0, 0];
2588
2429
  const u = (x - c) / half;
2589
2430
  const widen = v > 0 ? 0.05 : 0.03;
@@ -2593,7 +2434,7 @@ function mouthForm(mesh, box, frame = mouthFrameOf(box)) {
2593
2434
  function mouthOpenGrow(mesh, box) {
2594
2435
  const c = cx(box);
2595
2436
  return localWarp(
2596
- import_format4.StandardParameter.MouthOpen,
2437
+ import_format3.StandardParameter.MouthOpen,
2597
2438
  mesh,
2598
2439
  box,
2599
2440
  [0, 1],
@@ -2602,25 +2443,38 @@ function mouthOpenGrow(mesh, box) {
2602
2443
  }
2603
2444
  function mouthWiden(mesh, box, frame = mouthFrameOf(box)) {
2604
2445
  const c = frame.c;
2605
- return localWarp(import_format4.StandardParameter.MouthOpen, mesh, box, [0, 1], ([x], v) => [
2446
+ return localWarp(import_format3.StandardParameter.MouthOpen, mesh, box, [0, 1], ([x], v) => [
2606
2447
  v * (AMPLITUDE.mouthOpenWidth - 1) * (x - c),
2607
2448
  0
2608
2449
  ]);
2609
2450
  }
2451
+ function swayAt(b, y) {
2452
+ return Math.min(1, Math.max(0, (b.y1 - y) / bh(b))) ** SWAY_CURVE;
2453
+ }
2610
2454
  function hairSway(param, mesh, box, amp) {
2455
+ return localWarp(
2456
+ param,
2457
+ mesh,
2458
+ box,
2459
+ [-SWAY_RANGE, 0, SWAY_RANGE],
2460
+ ([, y], v) => [v / SWAY_RANGE * amp * swayAt(box, y), 0]
2461
+ );
2462
+ }
2463
+ function lockSway(param, mesh, box, amp, attach) {
2611
2464
  return localWarp(
2612
2465
  param,
2613
2466
  mesh,
2614
2467
  box,
2615
2468
  [-SWAY_RANGE, 0, SWAY_RANGE],
2616
2469
  ([, y], v) => {
2617
- const t = Math.max(0, (box.y1 - y) / bh(box));
2618
- return [v / SWAY_RANGE * amp * t ** SWAY_CURVE, 0];
2470
+ const w = attach.weight(y);
2471
+ const share = (1 - w) * swayAt(attach.box, y) + w * swayAt(box, y);
2472
+ return [v / SWAY_RANGE * amp * share, 0];
2619
2473
  }
2620
2474
  );
2621
2475
  }
2622
2476
  function tiltHang(mesh, box, pivot, weight, deg = ROLL_DEG) {
2623
- return localWarp(import_format4.StandardParameter.AngleZ, mesh, box, Z_STOPS, ([x, y], v, i) => {
2477
+ return localWarp(import_format3.StandardParameter.AngleZ, mesh, box, Z_STOPS, ([x, y], v, i) => {
2624
2478
  const g = weight(y, i);
2625
2479
  if (g === 0 || v === 0) return [0, 0];
2626
2480
  const a = -g * rollOf(v, deg);
@@ -2636,8 +2490,191 @@ function hairHangWeight(pivotY, bottom) {
2636
2490
  if (bottom >= pivotY) return () => 0;
2637
2491
  return (y) => HAIR_HANG * smoothstep(pivotY, bottom, y);
2638
2492
  }
2493
+ function lockHangWeight(pivotY, bottom, hh) {
2494
+ return hairHangWeight(pivotY, Math.min(bottom, pivotY - LOCK_HANG_SPAN * hh));
2495
+ }
2639
2496
  var round1 = (v) => roundTo(v, 0.1);
2640
2497
 
2498
+ // src/auto-rig/roles.ts
2499
+ var import_format4 = require("@ikijs/format");
2500
+ var LIP_ROLES = ["mouth_inner", "lip_lower", "lip_upper"];
2501
+ var isLipRole = (role) => LIP_ROLES.includes(role);
2502
+ var LIP_INSIDE_ROLES = ["mouth_tongue", "mouth_teeth"];
2503
+ var isLipInsideRole = (role) => LIP_INSIDE_ROLES.includes(role);
2504
+ var SHUT_BY = new Map([
2505
+ ["eye_L", import_format4.StandardParameter.EyeOpenLeft],
2506
+ ["lash_lower_L", import_format4.StandardParameter.EyeOpenLeft],
2507
+ ["eye_R", import_format4.StandardParameter.EyeOpenRight],
2508
+ ["lash_lower_R", import_format4.StandardParameter.EyeOpenRight],
2509
+ ...[...LIP_ROLES, ...LIP_INSIDE_ROLES].map((id) => [
2510
+ id,
2511
+ import_format4.StandardParameter.MouthOpen
2512
+ ])
2513
+ ]);
2514
+ var ROLE_TABLE = [
2515
+ { role: "hair_back", family: "hair_back" },
2516
+ { role: "body", family: "body" },
2517
+ { role: "arm_L", family: "arm", parts: ["elbow_L", "forearm_L", "arm_L"] },
2518
+ { role: "arm_R", family: "arm", parts: ["elbow_R", "forearm_R", "arm_R"] },
2519
+ { role: "face", family: "face", required: true },
2520
+ { role: "blush_L", family: "face" },
2521
+ { role: "blush_R", family: "face" },
2522
+ { role: "nose", family: "nose" },
2523
+ { role: "mouth", family: "mouth" },
2524
+ { role: "mouth_open", family: "mouth" },
2525
+ // The lip set, back to front: the interior, the tongue and the teeth clipped
2526
+ // to it, the lower lip's skin, the upper lip and its line over both. It
2527
+ // folds open instead of crossfading (`mouth.ts`), so it stands in for
2528
+ // `mouth`, never beside it.
2529
+ { role: "mouth_inner", family: "mouth" },
2530
+ { role: "mouth_tongue", family: "mouth" },
2531
+ { role: "mouth_teeth", family: "mouth" },
2532
+ { role: "lip_lower", family: "mouth" },
2533
+ { role: "lip_upper", family: "mouth" },
2534
+ { role: "eye_L", family: "eye_L", required: true },
2535
+ { role: "eye_R", family: "eye_R", required: true },
2536
+ { role: "iris_L", family: "eye_L" },
2537
+ { role: "iris_R", family: "eye_R" },
2538
+ { role: "pupil_L", family: "eye_L" },
2539
+ { role: "pupil_R", family: "eye_R" },
2540
+ { role: "highlight_L", family: "eye_L" },
2541
+ { role: "highlight_R", family: "eye_R" },
2542
+ { role: "lash_lower_L", family: "eye_L" },
2543
+ { role: "lash_lower_R", family: "eye_R" },
2544
+ { role: "lash_L", family: "eye_L" },
2545
+ { role: "lash_R", family: "eye_R" },
2546
+ { role: "brow_L", family: "brow_L" },
2547
+ { role: "brow_R", family: "brow_R" },
2548
+ // The front side locks: over the brows and lashes, under the bangs, which
2549
+ // hide their roots.
2550
+ { role: "hair_side_L", family: "hair_side" },
2551
+ { role: "hair_side_R", family: "hair_side" },
2552
+ { role: "hair_front", family: "hair_front" },
2553
+ // Drawn above the face and the hair: a raised hand passes them.
2554
+ { role: "forearm_pose_L", family: "forearm_pose" },
2555
+ { role: "forearm_pose_R", family: "forearm_pose" }
2556
+ ];
2557
+ var REQUIRED_ROLES = ROLE_TABLE.filter(
2558
+ (r) => r.required
2559
+ ).map((r) => r.role);
2560
+ var SPEC_BY_ROLE = new Map(ROLE_TABLE.map((r, i) => [r.role, { ...r, i }]));
2561
+ function roleSpec(role) {
2562
+ const spec = SPEC_BY_ROLE.get(role);
2563
+ if (spec === void 0) throw new Error(`auto-rig: unknown role "${role}"`);
2564
+ return spec;
2565
+ }
2566
+ function partIdsOfRole(role) {
2567
+ return roleSpec(role).parts ?? [role];
2568
+ }
2569
+ var ROLE_BY_PART = new Map(
2570
+ ROLE_TABLE.flatMap((r) => (r.parts ?? []).map((p) => [p, r.role]))
2571
+ );
2572
+ function roleOfPart(partId) {
2573
+ return ROLE_BY_PART.get(partId) ?? partId;
2574
+ }
2575
+ var ALIASES = {
2576
+ eyebrow_L: "brow_L",
2577
+ eyebrow_R: "brow_R",
2578
+ eye_white_L: "eye_L",
2579
+ eye_white_R: "eye_R"
2580
+ };
2581
+ function roleOf(fileName) {
2582
+ const role = fileName.replace(/\.[^.]+$/, "").toLowerCase().replace(/[-\s]+/g, "_").replace(/_([lr])$/, (_, s) => `_${s.toUpperCase()}`);
2583
+ return ALIASES[role] ?? role;
2584
+ }
2585
+ function checkArmsHaveBody(roles) {
2586
+ const has = new Set(roles);
2587
+ if (has.has("body")) return;
2588
+ const arm = ROLE_TABLE.find((r) => r.family === "arm" && has.has(r.role));
2589
+ if (arm !== void 0) {
2590
+ throw new Error(
2591
+ `auto-rig: ${arm.role} needs a body layer (an arm hangs from the body's shoulder)`
2592
+ );
2593
+ }
2594
+ }
2595
+ function checkSidesHaveFront(roles) {
2596
+ const has = new Set(roles);
2597
+ if (has.has("hair_front")) return;
2598
+ const side = ROLE_TABLE.find(
2599
+ (r) => r.family === "hair_side" && has.has(r.role)
2600
+ );
2601
+ if (side !== void 0) {
2602
+ throw new Error(
2603
+ `auto-rig: ${side.role} needs a hair_front layer (a side lock's root hides under the bangs)`
2604
+ );
2605
+ }
2606
+ }
2607
+ function checkMouth(roles) {
2608
+ const has = new Set(roles);
2609
+ const present = LIP_ROLES.filter((r) => has.has(r));
2610
+ const inside = LIP_INSIDE_ROLES.filter((r) => has.has(r));
2611
+ if (inside.length > 0 && present.length === 0) {
2612
+ throw new Error(
2613
+ `auto-rig: ${inside[0]} needs the lip set ${LIP_ROLES.join(", ")} (it is cut from the interior and clipped to it)`
2614
+ );
2615
+ }
2616
+ if (present.length === 0) {
2617
+ if (!has.has("mouth")) {
2618
+ throw new Error(
2619
+ `auto-rig: missing required role mouth (or the lip set ${LIP_ROLES.join(", ")})`
2620
+ );
2621
+ }
2622
+ return;
2623
+ }
2624
+ if (present.length < LIP_ROLES.length) {
2625
+ throw new Error(
2626
+ `auto-rig: the lip set is partial: missing ${LIP_ROLES.filter((r) => !has.has(r)).join(", ")}`
2627
+ );
2628
+ }
2629
+ const mixed = ["mouth", "mouth_open"].filter((r) => has.has(r));
2630
+ if (mixed.length > 0) {
2631
+ throw new Error(
2632
+ `auto-rig: the lip set cannot be mixed with ${mixed.join(", ")} (the lip set replaces the mouth drawings; use one or the other)`
2633
+ );
2634
+ }
2635
+ }
2636
+ function checkPosesHaveArms(roles) {
2637
+ const has = new Set(roles);
2638
+ for (const side of ["L", "R"]) {
2639
+ if (has.has(`forearm_pose_${side}`) && !has.has(`arm_${side}`)) {
2640
+ throw new Error(
2641
+ `auto-rig: forearm_pose_${side} needs an arm_${side} layer (a pose forearm hangs from its arm's elbow)`
2642
+ );
2643
+ }
2644
+ }
2645
+ }
2646
+ function parseLayerRoles(fileNames) {
2647
+ const seen = /* @__PURE__ */ new Map();
2648
+ const out = [];
2649
+ for (const fileName of fileNames) {
2650
+ const role = roleOf(fileName);
2651
+ if (!SPEC_BY_ROLE.has(role)) {
2652
+ throw new Error(
2653
+ `auto-rig: "${fileName}" names no known role (expected one of ${ROLE_TABLE.map((r) => r.role).join(", ")})`
2654
+ );
2655
+ }
2656
+ const prior = seen.get(role);
2657
+ if (prior !== void 0) {
2658
+ throw new Error(
2659
+ `auto-rig: role "${role}" is named twice ("${prior}" and "${fileName}")`
2660
+ );
2661
+ }
2662
+ seen.set(role, fileName);
2663
+ out.push({ role, fileName });
2664
+ }
2665
+ const missing = REQUIRED_ROLES.filter((r) => !seen.has(r));
2666
+ if (missing.length > 0) {
2667
+ throw new Error(
2668
+ `auto-rig: missing required role${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`
2669
+ );
2670
+ }
2671
+ checkMouth(seen.keys());
2672
+ checkArmsHaveBody(seen.keys());
2673
+ checkPosesHaveArms(seen.keys());
2674
+ checkSidesHaveFront(seen.keys());
2675
+ return out;
2676
+ }
2677
+
2641
2678
  // src/auto-rig/mouth.ts
2642
2679
  var UPPER_SHARE = 0.4;
2643
2680
  var CLOSED_SAG_MAX = 0.1;
@@ -3033,6 +3070,7 @@ function buildHeadFrame(layers, opts) {
3033
3070
  let paintedHalfAt = () => plateHalf;
3034
3071
  let neck;
3035
3072
  let ears;
3073
+ let headHalfAt = (y) => paintedHalfAt(y);
3036
3074
  let headMax = plateHalf;
3037
3075
  let chinY = face.y0;
3038
3076
  let cutAt = () => face.y0 - 1;
@@ -3051,6 +3089,12 @@ function buildHeadFrame(layers, opts) {
3051
3089
  const t = clamp(r - i, 0, 1);
3052
3090
  return prof[i] + (prof[j] - prof[i]) * t;
3053
3091
  };
3092
+ const readRows = (rows) => (y) => {
3093
+ const r = canvasH / 2 - y - faceLayer.bbox.y - 0.5;
3094
+ const i = clamp(Math.floor(r), 0, rows.length - 1);
3095
+ const j = Math.min(i + 1, rows.length - 1);
3096
+ return rows[i] + (rows[j] - rows[i]) * clamp(r - i, 0, 1);
3097
+ };
3054
3098
  const rawRows = raw;
3055
3099
  const earless = (chin) => {
3056
3100
  const rowOf = (y) => Math.round(canvasH / 2 - y - faceLayer.bbox.y - 0.5);
@@ -3151,6 +3195,7 @@ function buildHeadFrame(layers, opts) {
3151
3195
  canvasH / 2 - nk.topY - faceLayer.bbox.y - 0.5
3152
3196
  );
3153
3197
  const head = earless(chinY);
3198
+ headHalfAt = readRows(head);
3154
3199
  headMax = Math.max(...head);
3155
3200
  const outlineAt = (d) => {
3156
3201
  for (let r = neckRow; r >= 0; r--) {
@@ -3193,6 +3238,7 @@ function buildHeadFrame(layers, opts) {
3193
3238
  };
3194
3239
  } else {
3195
3240
  const head = earless(chinY);
3241
+ headHalfAt = readRows(head);
3196
3242
  headMax = Math.max(...head);
3197
3243
  if (ears !== void 0) {
3198
3244
  const bottomOf = (d) => {
@@ -3240,6 +3286,7 @@ function buildHeadFrame(layers, opts) {
3240
3286
  chinY,
3241
3287
  hh,
3242
3288
  paintedHalfAt,
3289
+ headHalfAt,
3243
3290
  wMax,
3244
3291
  headMax,
3245
3292
  hasProfile: profile !== null,
@@ -3257,6 +3304,96 @@ function buildHeadFrame(layers, opts) {
3257
3304
  };
3258
3305
  }
3259
3306
 
3307
+ // src/auto-rig/hair-side-room.ts
3308
+ var round3 = (v) => Math.round(v * 1e3) / 1e3;
3309
+ var signed = (v) => round3(v) + 0;
3310
+ function headEdge(ctx, land2, y, side, ax) {
3311
+ const { frame } = ctx;
3312
+ return land2("face", frame.axisX + side * frame.headHalfAt(y), y, ax);
3313
+ }
3314
+ var LOCKS = ["hair_side_L", "hair_side_R"];
3315
+ function hairSideRoom(ctx, land2, boxes) {
3316
+ const out = {};
3317
+ for (const role of LOCKS) {
3318
+ const box = boxes[role];
3319
+ if (box === void 0) continue;
3320
+ const side = (box.x0 + box.x1) / 2 < ctx.frame.axisX ? -1 : 1;
3321
+ const key = side < 0 ? "left" : "right";
3322
+ if (out[key] !== void 0) continue;
3323
+ out[key] = lockRoom(ctx, land2, boxes, role, box, side);
3324
+ }
3325
+ return out.left || out.right ? out : void 0;
3326
+ }
3327
+ function lockRoom(ctx, land2, boxes, role, box, side) {
3328
+ const { frame } = ctx;
3329
+ if (frame.hairRuns(role, box.y1 - 0.5) === void 0) {
3330
+ return { role, unavailable: "no rowRuns" };
3331
+ }
3332
+ const room = { role };
3333
+ const stops = farStops(side);
3334
+ let cover;
3335
+ for (let i = 0; i < Math.ceil(box.y1 - box.y0); i++) {
3336
+ const y2 = box.y1 - i - 0.5;
3337
+ if (y2 < frame.face.y0 || y2 > frame.face.y1) continue;
3338
+ const outer2 = outerEdge(frame.hairRuns(role, y2) ?? [], frame.axisX, side);
3339
+ if (outer2 === void 0) continue;
3340
+ const rest = side * (outer2 - frame.axisX) - frame.headHalfAt(y2);
3341
+ if (rest < 0) continue;
3342
+ for (const ax of stops) {
3343
+ const px = side * (land2(role, outer2, y2, ax) - headEdge(ctx, land2, y2, side, ax));
3344
+ if (cover === void 0 || px < cover.px)
3345
+ cover = { deg: ax, px, y: y2, rest };
3346
+ }
3347
+ }
3348
+ if (cover !== void 0) {
3349
+ room.cover = {
3350
+ deg: cover.deg,
3351
+ px: signed(cover.px),
3352
+ y: round3(cover.y),
3353
+ rest: signed(cover.rest)
3354
+ };
3355
+ }
3356
+ const eye = side < 0 ? ctx.eyeBoxes.left : ctx.eyeBoxes.right;
3357
+ const eyeRole = (side < 0 ? ctx.left : ctx.right).role.replace(
3358
+ /^iris_/,
3359
+ "eye_"
3360
+ );
3361
+ const y = cy(eye);
3362
+ const runs = frame.hairRuns(role, y) ?? [];
3363
+ const outer = outerEdge(runs, frame.axisX, side);
3364
+ if (outer === void 0) return room;
3365
+ const inner = outer - side * runReach(runs, outer, side).inward;
3366
+ const lashRole = eyeRole.replace(/^eye_/, "lash_");
3367
+ const lash = boxes[lashRole];
3368
+ let tail;
3369
+ for (const ax of stops) {
3370
+ const lock = land2(role, inner, y, ax);
3371
+ const corner = land2(eyeRole, side < 0 ? eye.x0 : eye.x1, y, ax);
3372
+ const eyeToLock = side * (lock - corner);
3373
+ if (tail !== void 0 && eyeToLock >= tail.eyeToLock) continue;
3374
+ tail = {
3375
+ deg: ax,
3376
+ eyeToLock,
3377
+ eyeToPlate: side * (headEdge(ctx, land2, y, side, ax) - corner)
3378
+ };
3379
+ if (lash !== void 0) {
3380
+ const tip = land2(lashRole, side < 0 ? lash.x0 : lash.x1, y, ax);
3381
+ tail.lashToLock = side * (lock - tip);
3382
+ }
3383
+ }
3384
+ if (tail !== void 0) {
3385
+ room.tail = {
3386
+ deg: tail.deg,
3387
+ eyeToLock: signed(tail.eyeToLock),
3388
+ ...tail.lashToLock !== void 0 && {
3389
+ lashToLock: signed(tail.lashToLock)
3390
+ },
3391
+ eyeToPlate: signed(tail.eyeToPlate)
3392
+ };
3393
+ }
3394
+ return room;
3395
+ }
3396
+
3260
3397
  // src/auto-rig/fields.ts
3261
3398
  var NOSE_TILT_DEG = 6;
3262
3399
  function nodDy(v, ay, hh) {
@@ -3458,12 +3595,12 @@ function hairFrontBends(frame, boxes) {
3458
3595
  }
3459
3596
  function hairFrontField(m) {
3460
3597
  const f = m.frame;
3461
- const back = hairField(m, m.hairBack, NOD.hairBack);
3462
3598
  const cap = { up: NOD.hairFrontTop.up, down: capSlide(m) };
3463
3599
  const eyes = [m.boxes.eye_L, m.boxes.eye_R].filter(
3464
3600
  (e) => e !== void 0
3465
3601
  );
3466
3602
  const { inner, left, right } = hairFrontBends(f, m.boxes);
3603
+ const capTop = m.hairFrontGrid?.ys.find((y) => y <= f.face.y1) ?? f.face.y1;
3467
3604
  const eyeY = eyes.length > 0 ? eyes.reduce((t, e) => t + cy(e), 0) / eyes.length : f.eyeY;
3468
3605
  const eyeH = eyes.length > 0 ? eyes.reduce((t, e) => t + (e.y1 - e.y0), 0) / eyes.length : 1;
3469
3606
  const coreAt = (x, y, s) => {
@@ -3496,22 +3633,60 @@ function hairFrontField(m) {
3496
3633
  );
3497
3634
  const followOf = (x, y, s) => followAt(x, y, s) + (ride === void 0 ? 0 : ride.lift(x, y, s));
3498
3635
  const drift = Math.abs(m.hairBack);
3499
- const crown = crownRide(m, cap, (x, y, s) => followOf(x, y, s) + drift);
3500
3636
  const boundOf = (x, y, s) => {
3501
3637
  if (ride === void 0) return followOf(x, y, s);
3502
- const b = ride.blend(y, s);
3638
+ const b = ride.blend(x, y, s);
3503
3639
  return (1 - b) * (followOf(x, y, s) + ride.cut(x, y, s)) - b * drift;
3504
3640
  };
3505
3641
  return (x, y, ax, ay) => {
3506
3642
  const s = Math.sign(ax);
3507
3643
  const a = Math.abs(ax) / 30;
3508
- const rx = s * a * boundOf(x, y, s) + a * (m.shellScale - 1) * (x - f.axisX);
3644
+ const rx = s * a * boundOf(x, Math.min(y, capTop), s) + a * (m.shellScale - 1) * (x - f.axisX);
3509
3645
  const ry = nodDy(NOD.hairFront, ay, f.hh);
3510
- const bx = back(x, y, ax, ay)[0];
3511
3646
  const by = nodDy(cap, ay, f.hh);
3512
- const w = crownBlend(m, y);
3513
- const wx = crown === void 0 ? w : w + crown(y, s);
3514
- return [bx + wx * (rx - bx), by + w * (ry - by)];
3647
+ return [rx, by + crownBlend(m, y) * (ry - by)];
3648
+ };
3649
+ }
3650
+ var SIDE_ROOT_BLEND = 0.2;
3651
+ function sideRootBlend(f, role, box) {
3652
+ let rootEnd = box.y1 - (box.y1 - box.y0) / 4;
3653
+ if (f.hairRuns(role, box.y1 - 0.5) !== void 0 && f.hairRuns("hair_front", box.y1 - 0.5) !== void 0) {
3654
+ const shows = (y) => {
3655
+ const lock = f.hairRuns(role, y);
3656
+ const front = f.hairRuns("hair_front", y);
3657
+ for (let k = 0; k < lock.length; k += 2) {
3658
+ for (let px = lock[k]; px < lock[k + 1]; px++) {
3659
+ if (!paints(front, px)) return true;
3660
+ }
3661
+ }
3662
+ return false;
3663
+ };
3664
+ const rows = Array.from(
3665
+ { length: Math.round(box.y1 - box.y0) },
3666
+ (_, i2) => i2
3667
+ );
3668
+ const i = rows.findIndex((i2) => shows(box.y1 - i2 - 0.5));
3669
+ rootEnd = i < 0 ? box.y0 : box.y1 - i;
3670
+ }
3671
+ const end = rootEnd - SIDE_ROOT_BLEND * f.hh;
3672
+ return { rootEnd, weight: (y) => smoothstep(rootEnd, end, y) };
3673
+ }
3674
+ function hairSideField(m) {
3675
+ const front = hairFrontField(m);
3676
+ const face = faceField(m);
3677
+ const locks = ["hair_side_L", "hair_side_R"].flatMap((role) => {
3678
+ const box = m.boxes[role];
3679
+ return box === void 0 ? [] : [{ box, ...sideRootBlend(m.frame, role, box) }];
3680
+ });
3681
+ return (x, y, ax, ay) => {
3682
+ const lock = locks.find((l) => x >= l.box.x0 && x <= l.box.x1);
3683
+ if (lock === void 0) return face(x, y, ax, ay);
3684
+ const t = lock.weight(y);
3685
+ if (t === 0) return front(x, y, ax, ay);
3686
+ if (t === 1) return face(x, y, ax, ay);
3687
+ const [fx, fy] = front(x, y, ax, ay);
3688
+ const [gx, gy] = face(x, y, ax, ay);
3689
+ return [fx * (1 - t) + gx * t, fy * (1 - t) + gy * t];
3515
3690
  };
3516
3691
  }
3517
3692
  function crownBlend(m, y) {
@@ -3648,17 +3823,17 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3648
3823
  sides.forEach((side, k) => {
3649
3824
  const edge = outerEdge(front, f.axisX, side);
3650
3825
  if (edge === void 0) return;
3651
- let outward = Infinity;
3826
+ let outward2 = Infinity;
3652
3827
  let inward = Infinity;
3653
3828
  for (let j = Math.floor(i - above); j < Math.ceil(i + 1 + below); j++) {
3654
3829
  const c = runReach(backRows[j - j0], edge, side);
3655
- outward = Math.min(outward, c.outward);
3830
+ outward2 = Math.min(outward2, c.outward);
3656
3831
  inward = Math.min(inward, c.inward);
3657
3832
  }
3658
3833
  offset[k][i] = side * (edge - f.axisX);
3659
- reachOut[k][i] = outward;
3834
+ reachOut[k][i] = outward2;
3660
3835
  reachIn[k][i] = inward;
3661
- if (i < capRows && runReach(backRows[i - j0], edge, side).inward > 0)
3836
+ if (i < capRows && y <= f.face.y1 && runReach(backRows[i - j0], edge, side).inward > 0)
3662
3837
  bound[k][i] = 1;
3663
3838
  });
3664
3839
  }
@@ -3668,12 +3843,12 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3668
3843
  const [i0, i1] = rowWindow(ys, r, count);
3669
3844
  sides.forEach((side, k) => {
3670
3845
  let d = Infinity;
3671
- let outward = Infinity;
3846
+ let outward2 = Infinity;
3672
3847
  let inward = Infinity;
3673
3848
  for (let i = i0; i < i1; i++) {
3674
3849
  if (Number.isNaN(offset[k][i])) continue;
3675
3850
  d = Math.min(d, offset[k][i]);
3676
- outward = Math.min(outward, reachOut[k][i]);
3851
+ outward2 = Math.min(outward2, reachOut[k][i]);
3677
3852
  inward = Math.min(inward, reachIn[k][i]);
3678
3853
  }
3679
3854
  end[r * 2 + k] = d;
@@ -3682,7 +3857,7 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3682
3857
  sides.forEach((s, j) => {
3683
3858
  const allow = Math.min(
3684
3859
  coreAt(x, ys[r], s),
3685
- m.shellScale * (s === side ? outward : inward) - drift
3860
+ m.shellScale * (s === side ? outward2 : inward) - drift
3686
3861
  );
3687
3862
  lift[(r * 2 + k) * 2 + j] = Math.max(0, allow - followAt(x, ys[r], s));
3688
3863
  });
@@ -3753,73 +3928,117 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3753
3928
  return quarter.map((q) => scale * q);
3754
3929
  };
3755
3930
  const cuts = new Float64Array(n * 2 * nc);
3756
- const blend = new Float64Array(n * 2);
3931
+ const blend = new Float64Array(n * 2 * nc);
3932
+ const motion = new Array(n * 2);
3933
+ const outward = sides.map((side) => {
3934
+ const cols = xs.map((_, c) => c).filter((c) => side * (xs[c] - f.axisX) >= 0);
3935
+ return side < 0 ? cols.reverse() : cols;
3936
+ });
3757
3937
  const wr = ys.map((y) => crownBlend(m, y));
3758
3938
  for (let r = 0; r <= last && capRows > 0; r++) {
3759
- const w = wr[r];
3760
- if (w <= 0) continue;
3939
+ if (wr[r] < 1) continue;
3761
3940
  const edges = [];
3941
+ const copied = r > 0 && wr[r - 1] < 1;
3762
3942
  const [i0, i1] = rowWindow(ys, r, capRows);
3763
3943
  for (let i = i0; i < i1; i++) {
3944
+ const y = top - i - 0.5;
3764
3945
  sides.forEach((side, k) => {
3765
3946
  if (bound[k][i] === 0) return;
3766
3947
  const x = f.axisX + side * offset[k][i];
3767
- const at = readAt(r, x, top - i - 0.5);
3768
- if (at === void 0) return;
3769
- edges.push({
3770
- side,
3771
- x,
3772
- at,
3773
- outward: reachOut[k][i],
3774
- inward: reachIn[k][i]
3775
- });
3948
+ for (const at2 of [
3949
+ readAt(r, x, y),
3950
+ copied && y > ys[r] ? readAt(r - 1, x, y) : void 0
3951
+ ]) {
3952
+ if (at2 === void 0) continue;
3953
+ edges.push({
3954
+ side,
3955
+ x,
3956
+ at: at2,
3957
+ outward: reachOut[k][i],
3958
+ inward: reachIn[k][i]
3959
+ });
3960
+ }
3776
3961
  });
3777
3962
  }
3778
3963
  if (edges.length === 0) continue;
3779
3964
  sides.forEach((s, j) => {
3780
3965
  const today = xs.map((x) => followAt(x, ys[r], s) + liftAt(x, ys[r], s));
3781
3966
  const after = today.slice();
3782
- if (w >= 1) {
3783
- for (const side of sides) {
3784
- const mine = edges.filter((e) => e.side === side);
3785
- if (mine.length === 0) continue;
3786
- const cut = capCut(r, s, side, today, mine);
3787
- for (let c = 0; c < nc; c++) after[c] -= cut[c];
3788
- }
3967
+ for (const side of sides) {
3968
+ const mine = edges.filter((e) => e.side === side);
3969
+ if (mine.length === 0) continue;
3970
+ const cut = capCut(r, s, side, today, mine);
3971
+ for (let c = 0; c < nc; c++) after[c] -= cut[c];
3789
3972
  }
3790
- let b = 0;
3973
+ const b = blend.subarray((r * 2 + j) * nc, (r * 2 + j + 1) * nc);
3791
3974
  for (const e of edges) {
3792
- const rel = w * (along(after, e.at) + drift);
3793
3975
  const hi = m.shellScale * ahead(e, s);
3794
3976
  const lo = -m.shellScale * behind(e, s);
3795
- if (rel > hi) b = Math.max(b, 1 - hi / rel);
3796
- else if (rel < lo) b = Math.max(b, 1 - lo / rel);
3977
+ const { c, t } = gridCol(xs, e.at);
3978
+ for (const [cc, weight] of [
3979
+ [c, 1 - t],
3980
+ [c + 1, t]
3981
+ ]) {
3982
+ if (weight <= 0) continue;
3983
+ const rel = after[cc] + drift;
3984
+ if (rel > hi) b[cc] = Math.max(b[cc], 1 - hi / rel);
3985
+ else if (rel < lo) b[cc] = Math.max(b[cc], 1 - lo / rel);
3986
+ }
3797
3987
  }
3798
- blend[r * 2 + j] = b;
3988
+ motion[r * 2 + j] = after.map((v) => v + drift);
3799
3989
  for (let c = 0; c < nc; c++)
3800
3990
  cuts[(r * 2 + j) * nc + c] = after[c] - today[c];
3801
3991
  });
3802
3992
  }
3803
- for (let j = 0; j < 2; j++) {
3804
- for (let r = n - 2; r >= 0; r--) {
3805
- if (r <= last || wr[r] < 1)
3806
- blend[r * 2 + j] = Math.max(blend[r * 2 + j], blend[(r + 1) * 2 + j]);
3807
- }
3993
+ for (let r = 0; r < n; r++) {
3994
+ sides.forEach((s, j) => {
3995
+ motion[r * 2 + j] ??= xs.map(
3996
+ (x) => followAt(x, ys[r], s) + liftAt(x, ys[r], s) + drift
3997
+ );
3998
+ });
3808
3999
  }
4000
+ const at = (r, j, c) => (r * 2 + j) * nc + c;
3809
4001
  let zero = last + 1;
3810
4002
  while (zero < n && ys[zero] > eyeTop) zero++;
3811
4003
  while (zero > last + 1 && zero < n && ys[zero] < irisTop - 0.5) zero--;
3812
4004
  const endY = zero < n ? ys[zero] : Math.min(Math.max(eyeTop, irisTop - 0.5), ys[n - 1] - 1);
3813
- for (let j = 0; j < 2 && last < n - 1; j++) {
3814
- for (let r = 1; r < zero; r++) {
3815
- for (let q = 0; q < r && q <= last; q++) {
3816
- if (wr[q] < 1) continue;
3817
- blend[r * 2 + j] = Math.max(
3818
- blend[r * 2 + j],
3819
- blend[q * 2 + j] * (ys[r] - endY) / (ys[q] - endY)
3820
- );
4005
+ for (let pass = 0; pass < 8; pass++) {
4006
+ const was = blend.slice();
4007
+ for (let j = 0; j < 2; j++) {
4008
+ for (let r = n - 2; r >= 0; r--) {
4009
+ if (r > last && wr[r] >= 1) continue;
4010
+ for (let c = 0; c < nc; c++)
4011
+ blend[at(r, j, c)] = Math.max(
4012
+ blend[at(r, j, c)],
4013
+ blend[at(r + 1, j, c)]
4014
+ );
3821
4015
  }
3822
4016
  }
4017
+ for (let j = 0; j < 2 && last < n - 1; j++) {
4018
+ for (let r = 1; r < zero; r++) {
4019
+ for (let q = 0; q < r && q <= last; q++) {
4020
+ if (wr[q] < 1) continue;
4021
+ const k = (ys[r] - endY) / (ys[q] - endY);
4022
+ for (let c = 0; c < nc; c++)
4023
+ blend[at(r, j, c)] = Math.max(
4024
+ blend[at(r, j, c)],
4025
+ blend[at(q, j, c)] * k
4026
+ );
4027
+ }
4028
+ }
4029
+ }
4030
+ for (let r = 0; r < n; r++) {
4031
+ sides.forEach(
4032
+ (s, j) => spreadBlend(
4033
+ blend.subarray(at(r, j, 0), at(r, j, nc)),
4034
+ motion[r * 2 + j],
4035
+ xs,
4036
+ outward,
4037
+ s
4038
+ )
4039
+ );
4040
+ }
4041
+ if (blend.every((v, i) => v === was[i])) break;
3823
4042
  }
3824
4043
  const cutRow = (r, j, x) => r > last ? 0 : along(cuts.subarray((r * 2 + j) * nc, (r * 2 + j + 1) * nc), x);
3825
4044
  return {
@@ -3832,158 +4051,47 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3832
4051
  const { r, t } = gridRow(ys, y, true);
3833
4052
  return cutRow(r, j, x) * (1 - t) + cutRow(r + 1, j, x) * t;
3834
4053
  },
3835
- blend: (y, s) => {
4054
+ blend: (x, y, s) => {
3836
4055
  if (s === 0) return 0;
3837
4056
  const j = s < 0 ? 0 : 1;
3838
4057
  const { r, t } = gridRow(ys, y, true);
3839
- return blend[r * 2 + j] * (1 - t) + blend[(r + 1) * 2 + j] * t;
4058
+ const row = (q) => along(blend.subarray(at(q, j, 0), at(q, j, nc)), x);
4059
+ return row(r) * (1 - t) + row(r + 1) * t;
3840
4060
  }
3841
4061
  };
3842
4062
  }
3843
- function crownRide(m, cap, relAt) {
3844
- const f = m.frame;
3845
- const grid = m.hairFrontGrid;
3846
- if (grid === void 0) return void 0;
3847
- const { xs, ys } = grid;
3848
- const top = ys[0];
3849
- if (f.hairRuns("hair_front", top) === void 0 || f.hairRuns("hair_back", top) === void 0) {
3850
- return void 0;
3851
- }
3852
- const n = ys.length;
3853
- const count = Math.round(top - ys[n - 1]);
3854
- const hh = f.hh;
3855
- const face = faceField(m);
3856
- const { wr, blendAt } = renderedCrownBlend(m, ys);
3857
- const backRows = /* @__PURE__ */ new Map();
3858
- const backAt = (j) => {
3859
- let runs = backRows.get(j);
3860
- if (runs === void 0) {
3861
- runs = f.hairRuns("hair_back", top - j - 0.5);
3862
- backRows.set(j, runs);
3863
- }
3864
- return runs;
3865
- };
3866
- const faceAt = (j) => {
3867
- const y = top - j - 0.5;
3868
- return f.faceRuns(y) ?? (y > f.face.y0 && y < f.face.y1 ? [f.face.x0, f.face.x1] : []);
3869
- };
3870
- const passed = (i, dys) => [
3871
- Math.floor(i - Math.max(0, ...dys)),
3872
- Math.ceil(i + 1 - Math.min(0, ...dys))
3873
- ];
3874
- const rel = new Float64Array(n * xs.length * 2).fill(NaN);
3875
- const relOf = (r, c, k) => {
3876
- const at = (r * xs.length + c) * 2 + k;
3877
- if (Number.isNaN(rel[at])) rel[at] = relAt(xs[c], ys[r], k === 0 ? -1 : 1);
3878
- return rel[at];
3879
- };
3880
- const cellR = (x, y, k) => {
3881
- const { c } = gridCol(xs, x);
3882
- const { r } = gridRow(ys, y);
3883
- return Math.max(
3884
- relOf(r, c, k),
3885
- relOf(r, c + 1, k),
3886
- relOf(r + 1, c, k),
3887
- relOf(r + 1, c + 1, k)
3888
- );
3889
- };
3890
- const someAngle = (pairs) => {
3891
- let lo = 0;
3892
- let hi = 1;
3893
- for (const [c0, c1] of pairs) {
3894
- if (c1 === 0) {
3895
- if (c0 <= 0) return false;
3896
- } else if (c1 > 0) lo = Math.max(lo, -c0 / c1);
3897
- else hi = Math.min(hi, -c0 / c1);
3898
- }
3899
- return lo < hi;
4063
+ function spreadBlend(b, a, xs, outward, s) {
4064
+ const fit = (ci, co, g) => {
4065
+ const quarter = Math.abs(xs[co] - xs[ci]) / 4;
4066
+ const today = g * (a[co] - a[ci]);
4067
+ const [lo, hi] = [
4068
+ Math.min(today, 0) - quarter,
4069
+ Math.max(today, 0) + quarter
4070
+ ];
4071
+ const ga = g * a[ci];
4072
+ if (ga === 0) return;
4073
+ const outer = g * (1 - b[co]) * a[co];
4074
+ const [b0, b1] = [1 - (outer - hi) / ga, 1 - (outer - lo) / ga];
4075
+ const least = Math.max(b[ci], Math.min(b0, b1));
4076
+ if (least <= Math.min(1, Math.max(b0, b1))) b[ci] = least;
4077
+ else if (Math.min(b0, b1) > 1) b[ci] = 1;
3900
4078
  };
3901
- const slack = [-1, 1].map(() => new Float64Array(count).fill(NaN));
3902
- const slackAt = (i, k) => {
3903
- if (!Number.isNaN(slack[k][i])) return slack[k][i];
3904
- const y = top - i - 0.5;
3905
- const w = blendAt(y);
3906
- const nod = {
3907
- up: w * NOD.hairFront.up + (1 - w) * cap.up,
3908
- down: w * NOD.hairFront.down + (1 - w) * cap.down
3909
- };
3910
- const ays = [-30, 30];
3911
- const [b0, b1] = passed(
3912
- i,
3913
- ays.map((ay) => nodDy(nod, ay, hh) - nodDy(NOD.hairBack, ay, hh))
3914
- );
3915
- const band = [];
3916
- for (let j = b0; j < b1; j++) band.push(backAt(j));
3917
- const faces = [];
3918
- const [f0, f1] = passed(
3919
- i,
3920
- ays.map((ay) => nodDy(nod, ay, hh) - face(f.axisX, y, 0, ay)[1])
3921
- );
3922
- for (let j = f0; j < f1; j++)
3923
- faces.push({ y: top - j - 0.5, runs: faceAt(j) });
3924
- const front = f.hairRuns("hair_front", y);
3925
- [-1, 1].forEach((s, side) => {
3926
- let least = Infinity;
3927
- const far = outerEdge(front, f.axisX, s);
3928
- if (far !== void 0) {
3929
- const R = cellR(far, y, side);
3930
- const c = Math.min(...band.map((b) => runReach(b, far, s).outward));
3931
- if (R > 0) least = Math.min(least, m.shellScale * c / R);
3932
- }
3933
- for (let t = s > 0 ? 0 : 1; t < front.length; t += 2) {
3934
- const x = front[t];
3935
- const R = cellR(x, y, side);
3936
- if (R <= 0) continue;
3937
- let c = Math.min(
3938
- ...band.map((b) => runReach(b, x, s > 0 ? -1 : 1).inward)
3939
- );
3940
- const sh = (m.shellScale - 1) * (x - f.axisX);
3941
- const d0 = sh + s * m.hairBack;
3942
- const d1 = d0 + s * R;
3943
- const lo = Math.min(d0, d1);
3944
- const hi = Math.max(d0, d1);
3945
- const bares = faces.some(({ y: fy, runs }) => {
3946
- for (let r = 0; r < runs.length; r += 2) {
3947
- const e0 = runs[r];
3948
- const e1 = runs[r + 1];
3949
- const de0 = face(e0, fy, 30 * s, 0)[0];
3950
- const de1 = face(e1, fy, 30 * s, 0)[0];
3951
- if (someAngle([
3952
- [e1 - x, de1 - lo],
3953
- [x - e0, hi - de0]
3954
- ])) {
3955
- return true;
3956
- }
3957
- }
3958
- return false;
3959
- });
3960
- if (bares) c = 0;
3961
- least = Math.min(least, m.shellScale * c / R);
3962
- }
3963
- slack[side][i] = least - w;
4079
+ for (const cols of outward) {
4080
+ for (let i = 1; i < cols.length; i++)
4081
+ b[cols[i]] = Math.max(b[cols[i]], b[cols[i - 1]]);
4082
+ }
4083
+ const [left, right] = outward.map((cols) => cols[0]);
4084
+ const across = left !== void 0 && right !== void 0 && left !== right;
4085
+ for (let pass = 0; pass < 2; pass++) {
4086
+ outward.forEach((cols, k) => {
4087
+ const g = (k === 0 ? -1 : 1) * s;
4088
+ for (let i = cols.length - 1; i > 0; i--) fit(cols[i - 1], cols[i], g);
3964
4089
  });
3965
- return slack[k][i];
3966
- };
3967
- const extra = [0, 1].map((k) => {
3968
- const out = wr.slice();
3969
- for (let r = 0; r < n; r++) {
3970
- if (wr[r] >= 1) continue;
3971
- const [i0, i1] = rowWindow(ys, r, count);
3972
- let least = Infinity;
3973
- for (let i = i0; i < i1; i++) least = Math.min(least, slackAt(i, k));
3974
- out[r] = wr[r] + clamp(least, 0, 1 - wr[r]);
4090
+ if (across) {
4091
+ if (b[left] <= b[right]) fit(left, right, s);
4092
+ else fit(right, left, -s);
3975
4093
  }
3976
- for (let r = n - 2; r >= 0; r--) {
3977
- if (wr[r] < 1) out[r] = Math.max(wr[r], Math.min(out[r], out[r + 1]));
3978
- }
3979
- return out.map((v, r) => v - wr[r]);
3980
- });
3981
- return (y, s) => {
3982
- if (s === 0) return 0;
3983
- const e = extra[s < 0 ? 0 : 1];
3984
- const { r, t } = gridRow(ys, y, true);
3985
- return e[r] * (1 - t) + e[r + 1] * t;
3986
- };
4094
+ }
3987
4095
  }
3988
4096
  function familyField(m, family) {
3989
4097
  switch (family) {
@@ -4001,6 +4109,8 @@ function familyField(m, family) {
4001
4109
  return mouthField(m);
4002
4110
  case "hair_front":
4003
4111
  return hairFrontField(m);
4112
+ case "hair_side":
4113
+ return hairSideField(m);
4004
4114
  case "hair_back":
4005
4115
  return hairField(m, m.hairBack, NOD.hairBack);
4006
4116
  case "body":
@@ -4157,16 +4267,16 @@ function strandOverlaps(ctx, land2, holdBase) {
4157
4267
  if (px <= 0) continue;
4158
4268
  out[key] = {
4159
4269
  deg,
4160
- px: round3(px),
4161
- hh: round3(px) / holdBase,
4162
- restPx: round3(restPx),
4270
+ px: round32(px),
4271
+ hh: round32(px) / holdBase,
4272
+ restPx: round32(restPx),
4163
4273
  // A fringe spanning the face has no side to hold the iris clear of.
4164
4274
  held: strand.runFace !== null && px <= restPx + STRAND_SLACK
4165
4275
  };
4166
4276
  }
4167
4277
  return out.left || out.right ? out : void 0;
4168
4278
  }
4169
- var round3 = (v) => Math.round(v * 1e3) / 1e3;
4279
+ var round32 = (v) => Math.round(v * 1e3) / 1e3;
4170
4280
  var STRAND_SLACK = 0.5;
4171
4281
  function bisect(f, lo, hi, it = 50) {
4172
4282
  for (let i = 0; i < it; i++) {
@@ -4381,7 +4491,7 @@ function turnReport(ctx, fit, rendered) {
4381
4491
  const plate = depth(m.face);
4382
4492
  const w = frame.paintedHalfAt(frame.eyeY);
4383
4493
  const report = {
4384
- radius: round3(w * w / (2 * Math.max(1e-3, eye - plate))),
4494
+ radius: round32(w * w / (2 * Math.max(1e-3, eye - plate))),
4385
4495
  holdBase: hb,
4386
4496
  depths: {
4387
4497
  eye: round4(eye / hb),
@@ -4397,6 +4507,8 @@ function turnReport(ctx, fit, rendered) {
4397
4507
  };
4398
4508
  const overlap = strandOverlaps(ctx, rendered, hb);
4399
4509
  if (overlap !== void 0) report.strandOverlap = overlap;
4510
+ const hairSide = hairSideRoom(ctx, rendered, m.boxes);
4511
+ if (hairSide !== void 0) report.hairSide = hairSide;
4400
4512
  return report;
4401
4513
  }
4402
4514
  var round4 = (v) => Math.round(v * 1e4) / 1e4;
@@ -4552,7 +4664,7 @@ var RIG_PLAN_FIELDS = [
4552
4664
  fitBy: null,
4553
4665
  provisional: false,
4554
4666
  migratedFrom: "outlineFollow",
4555
- what: "the front hair's outer edge's share where it draws the head's outline (0: the outline holds, though where back hair paints behind it, it may ride further, as far as that back hair reaches; at hair.follow the side locks ride whole)"
4667
+ what: "the front hair's outer edge's share where it draws the head's outline (0: the outline holds, though where back hair paints behind it, it may ride further, as far as that back hair reaches; at hair.follow the front hair's side strands ride whole)"
4556
4668
  },
4557
4669
  {
4558
4670
  path: "hair.sway",
@@ -5606,6 +5718,7 @@ function checkLayers(layers, canvas) {
5606
5718
  }
5607
5719
  checkArmsHaveBody(seen);
5608
5720
  checkPosesHaveArms(seen);
5721
+ checkSidesHaveFront(seen);
5609
5722
  }
5610
5723
  function checkHeadEdges(edges, byRole) {
5611
5724
  if (edges === void 0) return;
@@ -6330,6 +6443,10 @@ var MESH_CELLS = {
6330
6443
  face: { px: 28, min: 4, max: 24 },
6331
6444
  // The front hair eases from riding the face to holding the outline.
6332
6445
  hair_front: { px: 32, min: 3, max: 24 },
6446
+ // A side lock's rows come from `sideLockGrid`: even ones, plus the root
6447
+ // band's (`sideRootBlend`), which a long lock's coarse cells would straddle.
6448
+ hair_side_L: { px: 20, min: 3, max: 24 },
6449
+ hair_side_R: { px: 20, min: 3, max: 24 },
6333
6450
  hair_back: { px: 56, min: 3, max: 18 }
6334
6451
  };
6335
6452
  var NECK_CELLS = { px: 20, min: 3, max: 12 };
@@ -6381,7 +6498,18 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
6381
6498
  const solveFrame = buildHeadFrame(layers, frameOptions);
6382
6499
  const ctx = solveContext(solveFrame, byRole, turnOptions, hasNose);
6383
6500
  const boxes = Object.fromEntries(
6384
- ["eye_L", "eye_R", "iris_L", "iris_R", "brow_L", "brow_R"].filter(has).map((r) => [r, box(r)])
6501
+ [
6502
+ "eye_L",
6503
+ "eye_R",
6504
+ "iris_L",
6505
+ "iris_R",
6506
+ "brow_L",
6507
+ "brow_R",
6508
+ "hair_side_L",
6509
+ "hair_side_R",
6510
+ "lash_L",
6511
+ "lash_R"
6512
+ ].filter(has).map((r) => [r, box(r)])
6385
6513
  );
6386
6514
  const landmarks = {
6387
6515
  boxes,
@@ -6586,6 +6714,8 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6586
6714
  headStart = fm.headStart;
6587
6715
  } else if (spec.role === "hair_front") {
6588
6716
  mesh = columnMesh(b, frontGrid);
6717
+ } else if (spec.role === "hair_side_L" || spec.role === "hair_side_R") {
6718
+ mesh = columnMesh(b, sideLockGrid(b, frame, spec.role));
6589
6719
  } else if (lips !== void 0 && (isLipRole(spec.role) || isLipInsideRole(spec.role))) {
6590
6720
  ({ mesh, xs: knotXs } = mouthMesh(b, lips.knots));
6591
6721
  } else if (spec.family !== "body") {
@@ -6600,6 +6730,7 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6600
6730
  const side = spec.role.endsWith("_L") ? "L" : "R";
6601
6731
  const eyeOpen = side === "L" ? import_format12.StandardParameter.EyeOpenLeft : import_format12.StandardParameter.EyeOpenRight;
6602
6732
  const hh = frame.hh;
6733
+ const swayAmp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6603
6734
  switch (spec.role) {
6604
6735
  case "face":
6605
6736
  if (region?.some((r) => r === "neck")) {
@@ -6736,18 +6867,30 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6736
6867
  case "hair_front":
6737
6868
  case "hair_back":
6738
6869
  if (byRole.has("hair_front")) {
6739
- const amp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6740
6870
  if (spec.role === "hair_front") {
6741
6871
  warps.push(
6742
- hairSway(import_format12.StandardParameter.HairSwayX, mesh, b, amp),
6743
- hairSway(import_format12.StandardParameter.HairSwayZ, mesh, b, amp)
6872
+ hairSway(import_format12.StandardParameter.HairSwayX, mesh, b, swayAmp),
6873
+ hairSway(import_format12.StandardParameter.HairSwayZ, mesh, b, swayAmp)
6744
6874
  );
6745
6875
  } else {
6746
- warps.push(hairSway(import_format12.StandardParameter.HairBackSway, mesh, b, amp));
6876
+ warps.push(hairSway(import_format12.StandardParameter.HairBackSway, mesh, b, swayAmp));
6747
6877
  }
6748
6878
  }
6749
6879
  warps.push(tiltHang(mesh, b, pivot, hairHangWeight(frame.chinY, b.y0)));
6750
6880
  break;
6881
+ case "hair_side_L":
6882
+ case "hair_side_R": {
6883
+ const attach = {
6884
+ box: box("hair_front"),
6885
+ weight: sideRootBlend(frame, spec.role, b).weight
6886
+ };
6887
+ warps.push(
6888
+ lockSway(import_format12.StandardParameter.HairSwayX, mesh, b, swayAmp, attach),
6889
+ lockSway(import_format12.StandardParameter.HairSwayZ, mesh, b, swayAmp, attach),
6890
+ tiltHang(mesh, b, pivot, lockHangWeight(frame.chinY, b.y0, hh))
6891
+ );
6892
+ break;
6893
+ }
6751
6894
  }
6752
6895
  if (warps.length > 0) part.warps = warps;
6753
6896
  if (bindings.length > 0) part.bindings = bindings;
@@ -6804,9 +6947,34 @@ function hairFrontGrid(b, frame, boxes) {
6804
6947
  }
6805
6948
  return { xs, ys };
6806
6949
  }
6950
+ var BLEND_ROWS = 3;
6951
+ function sideLockGrid(b, frame, role) {
6952
+ const cells = MESH_CELLS[role];
6953
+ const px = Math.min(cells.px, meshScale(role, frame));
6954
+ const cols = cellsFor(bw(b), px, cells.min, cells.max);
6955
+ const rows = cellsFor(bh(b), px, cells.min, cells.max);
6956
+ const xs = Array.from(
6957
+ { length: cols + 1 },
6958
+ (_, c) => b.x0 + bw(b) * c / cols
6959
+ );
6960
+ const ys = Array.from(
6961
+ { length: rows + 1 },
6962
+ (_, r) => b.y1 - bh(b) * r / rows
6963
+ );
6964
+ const { rootEnd } = sideRootBlend(frame, role, b);
6965
+ const end = rootEnd - SIDE_ROOT_BLEND * frame.hh;
6966
+ for (let i = 0; i <= BLEND_ROWS + 1; i++) {
6967
+ const y = rootEnd + (end - rootEnd) * i / (BLEND_ROWS + 1);
6968
+ if (y > b.y0 + 0.25 && y < b.y1 - 0.25 && ys.every((v) => Math.abs(v - y) > 0.25))
6969
+ ys.push(y);
6970
+ }
6971
+ return { xs, ys: ys.sort((p, q) => q - p) };
6972
+ }
6807
6973
  function meshScale(role, frame) {
6808
6974
  switch (role) {
6809
6975
  case "hair_front":
6976
+ case "hair_side_L":
6977
+ case "hair_side_R":
6810
6978
  case "hair_back":
6811
6979
  return Math.max(2, (frame.shellRight - frame.shellLeft) / 9);
6812
6980
  default: