@ikijs/editor 0.29.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) {
@@ -3510,6 +3647,48 @@ function hairFrontField(m) {
3510
3647
  return [rx, by + crownBlend(m, y) * (ry - by)];
3511
3648
  };
3512
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];
3690
+ };
3691
+ }
3513
3692
  function crownBlend(m, y) {
3514
3693
  const top = m.frame.face.y1;
3515
3694
  return 1 - smoothstep(top, Math.max(top + 1, m.hairTop), y);
@@ -3930,6 +4109,8 @@ function familyField(m, family) {
3930
4109
  return mouthField(m);
3931
4110
  case "hair_front":
3932
4111
  return hairFrontField(m);
4112
+ case "hair_side":
4113
+ return hairSideField(m);
3933
4114
  case "hair_back":
3934
4115
  return hairField(m, m.hairBack, NOD.hairBack);
3935
4116
  case "body":
@@ -4086,16 +4267,16 @@ function strandOverlaps(ctx, land2, holdBase) {
4086
4267
  if (px <= 0) continue;
4087
4268
  out[key] = {
4088
4269
  deg,
4089
- px: round3(px),
4090
- hh: round3(px) / holdBase,
4091
- restPx: round3(restPx),
4270
+ px: round32(px),
4271
+ hh: round32(px) / holdBase,
4272
+ restPx: round32(restPx),
4092
4273
  // A fringe spanning the face has no side to hold the iris clear of.
4093
4274
  held: strand.runFace !== null && px <= restPx + STRAND_SLACK
4094
4275
  };
4095
4276
  }
4096
4277
  return out.left || out.right ? out : void 0;
4097
4278
  }
4098
- var round3 = (v) => Math.round(v * 1e3) / 1e3;
4279
+ var round32 = (v) => Math.round(v * 1e3) / 1e3;
4099
4280
  var STRAND_SLACK = 0.5;
4100
4281
  function bisect(f, lo, hi, it = 50) {
4101
4282
  for (let i = 0; i < it; i++) {
@@ -4310,7 +4491,7 @@ function turnReport(ctx, fit, rendered) {
4310
4491
  const plate = depth(m.face);
4311
4492
  const w = frame.paintedHalfAt(frame.eyeY);
4312
4493
  const report = {
4313
- radius: round3(w * w / (2 * Math.max(1e-3, eye - plate))),
4494
+ radius: round32(w * w / (2 * Math.max(1e-3, eye - plate))),
4314
4495
  holdBase: hb,
4315
4496
  depths: {
4316
4497
  eye: round4(eye / hb),
@@ -4326,6 +4507,8 @@ function turnReport(ctx, fit, rendered) {
4326
4507
  };
4327
4508
  const overlap = strandOverlaps(ctx, rendered, hb);
4328
4509
  if (overlap !== void 0) report.strandOverlap = overlap;
4510
+ const hairSide = hairSideRoom(ctx, rendered, m.boxes);
4511
+ if (hairSide !== void 0) report.hairSide = hairSide;
4329
4512
  return report;
4330
4513
  }
4331
4514
  var round4 = (v) => Math.round(v * 1e4) / 1e4;
@@ -4481,7 +4664,7 @@ var RIG_PLAN_FIELDS = [
4481
4664
  fitBy: null,
4482
4665
  provisional: false,
4483
4666
  migratedFrom: "outlineFollow",
4484
- 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)"
4485
4668
  },
4486
4669
  {
4487
4670
  path: "hair.sway",
@@ -5535,6 +5718,7 @@ function checkLayers(layers, canvas) {
5535
5718
  }
5536
5719
  checkArmsHaveBody(seen);
5537
5720
  checkPosesHaveArms(seen);
5721
+ checkSidesHaveFront(seen);
5538
5722
  }
5539
5723
  function checkHeadEdges(edges, byRole) {
5540
5724
  if (edges === void 0) return;
@@ -6259,6 +6443,10 @@ var MESH_CELLS = {
6259
6443
  face: { px: 28, min: 4, max: 24 },
6260
6444
  // The front hair eases from riding the face to holding the outline.
6261
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 },
6262
6450
  hair_back: { px: 56, min: 3, max: 18 }
6263
6451
  };
6264
6452
  var NECK_CELLS = { px: 20, min: 3, max: 12 };
@@ -6310,7 +6498,18 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
6310
6498
  const solveFrame = buildHeadFrame(layers, frameOptions);
6311
6499
  const ctx = solveContext(solveFrame, byRole, turnOptions, hasNose);
6312
6500
  const boxes = Object.fromEntries(
6313
- ["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)])
6314
6513
  );
6315
6514
  const landmarks = {
6316
6515
  boxes,
@@ -6515,6 +6714,8 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6515
6714
  headStart = fm.headStart;
6516
6715
  } else if (spec.role === "hair_front") {
6517
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));
6518
6719
  } else if (lips !== void 0 && (isLipRole(spec.role) || isLipInsideRole(spec.role))) {
6519
6720
  ({ mesh, xs: knotXs } = mouthMesh(b, lips.knots));
6520
6721
  } else if (spec.family !== "body") {
@@ -6529,6 +6730,7 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6529
6730
  const side = spec.role.endsWith("_L") ? "L" : "R";
6530
6731
  const eyeOpen = side === "L" ? import_format12.StandardParameter.EyeOpenLeft : import_format12.StandardParameter.EyeOpenRight;
6531
6732
  const hh = frame.hh;
6733
+ const swayAmp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6532
6734
  switch (spec.role) {
6533
6735
  case "face":
6534
6736
  if (region?.some((r) => r === "neck")) {
@@ -6665,18 +6867,30 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6665
6867
  case "hair_front":
6666
6868
  case "hair_back":
6667
6869
  if (byRole.has("hair_front")) {
6668
- const amp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6669
6870
  if (spec.role === "hair_front") {
6670
6871
  warps.push(
6671
- hairSway(import_format12.StandardParameter.HairSwayX, mesh, b, amp),
6672
- 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)
6673
6874
  );
6674
6875
  } else {
6675
- warps.push(hairSway(import_format12.StandardParameter.HairBackSway, mesh, b, amp));
6876
+ warps.push(hairSway(import_format12.StandardParameter.HairBackSway, mesh, b, swayAmp));
6676
6877
  }
6677
6878
  }
6678
6879
  warps.push(tiltHang(mesh, b, pivot, hairHangWeight(frame.chinY, b.y0)));
6679
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
+ }
6680
6894
  }
6681
6895
  if (warps.length > 0) part.warps = warps;
6682
6896
  if (bindings.length > 0) part.bindings = bindings;
@@ -6733,9 +6947,34 @@ function hairFrontGrid(b, frame, boxes) {
6733
6947
  }
6734
6948
  return { xs, ys };
6735
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
+ }
6736
6973
  function meshScale(role, frame) {
6737
6974
  switch (role) {
6738
6975
  case "hair_front":
6976
+ case "hair_side_L":
6977
+ case "hair_side_R":
6739
6978
  case "hair_back":
6740
6979
  return Math.max(2, (frame.shellRight - frame.shellLeft) / 9);
6741
6980
  default: