@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.mjs CHANGED
@@ -1563,6 +1563,8 @@ var ROW_RUN_ROLES = /* @__PURE__ */ new Set([
1563
1563
  "face",
1564
1564
  "hair_front",
1565
1565
  "hair_back",
1566
+ "hair_side_L",
1567
+ "hair_side_R",
1566
1568
  "body",
1567
1569
  "arm_L",
1568
1570
  "arm_R",
@@ -1992,8 +1994,9 @@ function deriveProfile(v) {
1992
1994
  /** The mouth's far corner rises (its near end drops) by this much: it
1993
1995
  * stays level. */
1994
1996
  mouthTiltDeg: 0,
1995
- /** The front hair (bangs and side locks) rides the face at this share of
1996
- * its translation. */
1997
+ /** The front hair (its bangs and its own side strands; a `hair_side_*` lock
1998
+ * takes it only down to its root, `hairSideField`) rides the face at this
1999
+ * share of its translation. */
1997
2000
  hairFollow: v.hairFollow,
1998
2001
  /** The head's outline at the eye row holds: where the front hair draws
1999
2002
  * it, its outer edge follows the face this much on a row where no back
@@ -2209,169 +2212,6 @@ function meshPoints(mesh, box) {
2209
2212
  return out;
2210
2213
  }
2211
2214
 
2212
- // src/auto-rig/roles.ts
2213
- import { StandardParameter as P } from "@ikijs/format";
2214
- var LIP_ROLES = ["mouth_inner", "lip_lower", "lip_upper"];
2215
- var isLipRole = (role) => LIP_ROLES.includes(role);
2216
- var LIP_INSIDE_ROLES = ["mouth_tongue", "mouth_teeth"];
2217
- var isLipInsideRole = (role) => LIP_INSIDE_ROLES.includes(role);
2218
- var SHUT_BY = new Map([
2219
- ["eye_L", P.EyeOpenLeft],
2220
- ["lash_lower_L", P.EyeOpenLeft],
2221
- ["eye_R", P.EyeOpenRight],
2222
- ["lash_lower_R", P.EyeOpenRight],
2223
- ...[...LIP_ROLES, ...LIP_INSIDE_ROLES].map((id) => [
2224
- id,
2225
- P.MouthOpen
2226
- ])
2227
- ]);
2228
- var ROLE_TABLE = [
2229
- { role: "hair_back", family: "hair_back" },
2230
- { role: "body", family: "body" },
2231
- { role: "arm_L", family: "arm", parts: ["elbow_L", "forearm_L", "arm_L"] },
2232
- { role: "arm_R", family: "arm", parts: ["elbow_R", "forearm_R", "arm_R"] },
2233
- { role: "face", family: "face", required: true },
2234
- { role: "blush_L", family: "face" },
2235
- { role: "blush_R", family: "face" },
2236
- { role: "nose", family: "nose" },
2237
- { role: "mouth", family: "mouth" },
2238
- { role: "mouth_open", family: "mouth" },
2239
- // The lip set, back to front: the interior, the tongue and the teeth clipped
2240
- // to it, the lower lip's skin, the upper lip and its line over both. It
2241
- // folds open instead of crossfading (`mouth.ts`), so it stands in for
2242
- // `mouth`, never beside it.
2243
- { role: "mouth_inner", family: "mouth" },
2244
- { role: "mouth_tongue", family: "mouth" },
2245
- { role: "mouth_teeth", family: "mouth" },
2246
- { role: "lip_lower", family: "mouth" },
2247
- { role: "lip_upper", family: "mouth" },
2248
- { role: "eye_L", family: "eye_L", required: true },
2249
- { role: "eye_R", family: "eye_R", required: true },
2250
- { role: "iris_L", family: "eye_L" },
2251
- { role: "iris_R", family: "eye_R" },
2252
- { role: "pupil_L", family: "eye_L" },
2253
- { role: "pupil_R", family: "eye_R" },
2254
- { role: "highlight_L", family: "eye_L" },
2255
- { role: "highlight_R", family: "eye_R" },
2256
- { role: "lash_lower_L", family: "eye_L" },
2257
- { role: "lash_lower_R", family: "eye_R" },
2258
- { role: "lash_L", family: "eye_L" },
2259
- { role: "lash_R", family: "eye_R" },
2260
- { role: "brow_L", family: "brow_L" },
2261
- { role: "brow_R", family: "brow_R" },
2262
- { role: "hair_front", family: "hair_front" },
2263
- // Drawn above the face and the hair: a raised hand passes them.
2264
- { role: "forearm_pose_L", family: "forearm_pose" },
2265
- { role: "forearm_pose_R", family: "forearm_pose" }
2266
- ];
2267
- var REQUIRED_ROLES = ROLE_TABLE.filter(
2268
- (r) => r.required
2269
- ).map((r) => r.role);
2270
- var SPEC_BY_ROLE = new Map(ROLE_TABLE.map((r, i) => [r.role, { ...r, i }]));
2271
- function roleSpec(role) {
2272
- const spec = SPEC_BY_ROLE.get(role);
2273
- if (spec === void 0) throw new Error(`auto-rig: unknown role "${role}"`);
2274
- return spec;
2275
- }
2276
- function partIdsOfRole(role) {
2277
- return roleSpec(role).parts ?? [role];
2278
- }
2279
- var ROLE_BY_PART = new Map(
2280
- ROLE_TABLE.flatMap((r) => (r.parts ?? []).map((p) => [p, r.role]))
2281
- );
2282
- function roleOfPart(partId) {
2283
- return ROLE_BY_PART.get(partId) ?? partId;
2284
- }
2285
- var ALIASES = {
2286
- eyebrow_L: "brow_L",
2287
- eyebrow_R: "brow_R",
2288
- eye_white_L: "eye_L",
2289
- eye_white_R: "eye_R"
2290
- };
2291
- function roleOf(fileName) {
2292
- const role = fileName.replace(/\.[^.]+$/, "").toLowerCase().replace(/[-\s]+/g, "_").replace(/_([lr])$/, (_, s) => `_${s.toUpperCase()}`);
2293
- return ALIASES[role] ?? role;
2294
- }
2295
- function checkArmsHaveBody(roles) {
2296
- const has = new Set(roles);
2297
- if (has.has("body")) return;
2298
- const arm = ROLE_TABLE.find((r) => r.family === "arm" && has.has(r.role));
2299
- if (arm !== void 0) {
2300
- throw new Error(
2301
- `auto-rig: ${arm.role} needs a body layer (an arm hangs from the body's shoulder)`
2302
- );
2303
- }
2304
- }
2305
- function checkMouth(roles) {
2306
- const has = new Set(roles);
2307
- const present = LIP_ROLES.filter((r) => has.has(r));
2308
- const inside = LIP_INSIDE_ROLES.filter((r) => has.has(r));
2309
- if (inside.length > 0 && present.length === 0) {
2310
- throw new Error(
2311
- `auto-rig: ${inside[0]} needs the lip set ${LIP_ROLES.join(", ")} (it is cut from the interior and clipped to it)`
2312
- );
2313
- }
2314
- if (present.length === 0) {
2315
- if (!has.has("mouth")) {
2316
- throw new Error(
2317
- `auto-rig: missing required role mouth (or the lip set ${LIP_ROLES.join(", ")})`
2318
- );
2319
- }
2320
- return;
2321
- }
2322
- if (present.length < LIP_ROLES.length) {
2323
- throw new Error(
2324
- `auto-rig: the lip set is partial: missing ${LIP_ROLES.filter((r) => !has.has(r)).join(", ")}`
2325
- );
2326
- }
2327
- const mixed = ["mouth", "mouth_open"].filter((r) => has.has(r));
2328
- if (mixed.length > 0) {
2329
- throw new Error(
2330
- `auto-rig: the lip set cannot be mixed with ${mixed.join(", ")} (the lip set replaces the mouth drawings; use one or the other)`
2331
- );
2332
- }
2333
- }
2334
- function checkPosesHaveArms(roles) {
2335
- const has = new Set(roles);
2336
- for (const side of ["L", "R"]) {
2337
- if (has.has(`forearm_pose_${side}`) && !has.has(`arm_${side}`)) {
2338
- throw new Error(
2339
- `auto-rig: forearm_pose_${side} needs an arm_${side} layer (a pose forearm hangs from its arm's elbow)`
2340
- );
2341
- }
2342
- }
2343
- }
2344
- function parseLayerRoles(fileNames) {
2345
- const seen = /* @__PURE__ */ new Map();
2346
- const out = [];
2347
- for (const fileName of fileNames) {
2348
- const role = roleOf(fileName);
2349
- if (!SPEC_BY_ROLE.has(role)) {
2350
- throw new Error(
2351
- `auto-rig: "${fileName}" names no known role (expected one of ${ROLE_TABLE.map((r) => r.role).join(", ")})`
2352
- );
2353
- }
2354
- const prior = seen.get(role);
2355
- if (prior !== void 0) {
2356
- throw new Error(
2357
- `auto-rig: role "${role}" is named twice ("${prior}" and "${fileName}")`
2358
- );
2359
- }
2360
- seen.set(role, fileName);
2361
- out.push({ role, fileName });
2362
- }
2363
- const missing = REQUIRED_ROLES.filter((r) => !seen.has(r));
2364
- if (missing.length > 0) {
2365
- throw new Error(
2366
- `auto-rig: missing required role${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`
2367
- );
2368
- }
2369
- checkMouth(seen.keys());
2370
- checkArmsHaveBody(seen.keys());
2371
- checkPosesHaveArms(seen.keys());
2372
- return out;
2373
- }
2374
-
2375
2215
  // src/auto-rig/mouth.ts
2376
2216
  import {
2377
2217
  StandardParameter as P3
@@ -2379,11 +2219,12 @@ import {
2379
2219
 
2380
2220
  // src/auto-rig/drivers.ts
2381
2221
  import {
2382
- StandardParameter as P2
2222
+ StandardParameter as P
2383
2223
  } from "@ikijs/format";
2384
2224
  var SWAY_RANGE = 20;
2385
2225
  var SWAY_CURVE = 1.4;
2386
2226
  var HAIR_HANG = 0.35;
2227
+ var LOCK_HANG_SPAN = 1;
2387
2228
  var Z_STOPS = [-30, 0, 30];
2388
2229
  function localWarp(parameter, mesh, box, values, f) {
2389
2230
  const pts = meshPoints(mesh, box);
@@ -2458,13 +2299,13 @@ function gazeBindings(irisW, share = 1) {
2458
2299
  const gy = AMPLITUDE.gazeY * irisW * share;
2459
2300
  return [
2460
2301
  {
2461
- parameter: P2.EyeballX,
2302
+ parameter: P.EyeballX,
2462
2303
  channel: "translateX",
2463
2304
  from: -round1(gx),
2464
2305
  to: round1(gx)
2465
2306
  },
2466
2307
  {
2467
- parameter: P2.EyeballY,
2308
+ parameter: P.EyeballY,
2468
2309
  channel: "translateY",
2469
2310
  from: -round1(gy),
2470
2311
  to: round1(gy)
@@ -2478,8 +2319,8 @@ function browReach(hh) {
2478
2319
  return AMPLITUDE.brow * hh;
2479
2320
  }
2480
2321
  function browBindings(side, reach) {
2481
- const y = side === "L" ? P2.BrowLeftY : P2.BrowRightY;
2482
- const a = side === "L" ? P2.BrowLeftAngle : P2.BrowRightAngle;
2322
+ const y = side === "L" ? P.BrowLeftY : P.BrowRightY;
2323
+ const a = side === "L" ? P.BrowLeftAngle : P.BrowRightAngle;
2483
2324
  return [
2484
2325
  {
2485
2326
  parameter: y,
@@ -2498,7 +2339,7 @@ var mouthFrameOf = (box) => ({
2498
2339
  });
2499
2340
  function mouthForm(mesh, box, frame = mouthFrameOf(box)) {
2500
2341
  const { c, half, amp: a } = frame;
2501
- return localWarp(P2.MouthForm, mesh, box, [-1, 0, 1], ([x], v) => {
2342
+ return localWarp(P.MouthForm, mesh, box, [-1, 0, 1], ([x], v) => {
2502
2343
  if (v === 0) return [0, 0];
2503
2344
  const u = (x - c) / half;
2504
2345
  const widen = v > 0 ? 0.05 : 0.03;
@@ -2508,7 +2349,7 @@ function mouthForm(mesh, box, frame = mouthFrameOf(box)) {
2508
2349
  function mouthOpenGrow(mesh, box) {
2509
2350
  const c = cx(box);
2510
2351
  return localWarp(
2511
- P2.MouthOpen,
2352
+ P.MouthOpen,
2512
2353
  mesh,
2513
2354
  box,
2514
2355
  [0, 1],
@@ -2517,25 +2358,38 @@ function mouthOpenGrow(mesh, box) {
2517
2358
  }
2518
2359
  function mouthWiden(mesh, box, frame = mouthFrameOf(box)) {
2519
2360
  const c = frame.c;
2520
- return localWarp(P2.MouthOpen, mesh, box, [0, 1], ([x], v) => [
2361
+ return localWarp(P.MouthOpen, mesh, box, [0, 1], ([x], v) => [
2521
2362
  v * (AMPLITUDE.mouthOpenWidth - 1) * (x - c),
2522
2363
  0
2523
2364
  ]);
2524
2365
  }
2366
+ function swayAt(b, y) {
2367
+ return Math.min(1, Math.max(0, (b.y1 - y) / bh(b))) ** SWAY_CURVE;
2368
+ }
2525
2369
  function hairSway(param, mesh, box, amp) {
2370
+ return localWarp(
2371
+ param,
2372
+ mesh,
2373
+ box,
2374
+ [-SWAY_RANGE, 0, SWAY_RANGE],
2375
+ ([, y], v) => [v / SWAY_RANGE * amp * swayAt(box, y), 0]
2376
+ );
2377
+ }
2378
+ function lockSway(param, mesh, box, amp, attach) {
2526
2379
  return localWarp(
2527
2380
  param,
2528
2381
  mesh,
2529
2382
  box,
2530
2383
  [-SWAY_RANGE, 0, SWAY_RANGE],
2531
2384
  ([, y], v) => {
2532
- const t = Math.max(0, (box.y1 - y) / bh(box));
2533
- return [v / SWAY_RANGE * amp * t ** SWAY_CURVE, 0];
2385
+ const w = attach.weight(y);
2386
+ const share = (1 - w) * swayAt(attach.box, y) + w * swayAt(box, y);
2387
+ return [v / SWAY_RANGE * amp * share, 0];
2534
2388
  }
2535
2389
  );
2536
2390
  }
2537
2391
  function tiltHang(mesh, box, pivot, weight, deg = ROLL_DEG) {
2538
- return localWarp(P2.AngleZ, mesh, box, Z_STOPS, ([x, y], v, i) => {
2392
+ return localWarp(P.AngleZ, mesh, box, Z_STOPS, ([x, y], v, i) => {
2539
2393
  const g = weight(y, i);
2540
2394
  if (g === 0 || v === 0) return [0, 0];
2541
2395
  const a = -g * rollOf(v, deg);
@@ -2551,8 +2405,191 @@ function hairHangWeight(pivotY, bottom) {
2551
2405
  if (bottom >= pivotY) return () => 0;
2552
2406
  return (y) => HAIR_HANG * smoothstep(pivotY, bottom, y);
2553
2407
  }
2408
+ function lockHangWeight(pivotY, bottom, hh) {
2409
+ return hairHangWeight(pivotY, Math.min(bottom, pivotY - LOCK_HANG_SPAN * hh));
2410
+ }
2554
2411
  var round1 = (v) => roundTo(v, 0.1);
2555
2412
 
2413
+ // src/auto-rig/roles.ts
2414
+ import { StandardParameter as P2 } from "@ikijs/format";
2415
+ var LIP_ROLES = ["mouth_inner", "lip_lower", "lip_upper"];
2416
+ var isLipRole = (role) => LIP_ROLES.includes(role);
2417
+ var LIP_INSIDE_ROLES = ["mouth_tongue", "mouth_teeth"];
2418
+ var isLipInsideRole = (role) => LIP_INSIDE_ROLES.includes(role);
2419
+ var SHUT_BY = new Map([
2420
+ ["eye_L", P2.EyeOpenLeft],
2421
+ ["lash_lower_L", P2.EyeOpenLeft],
2422
+ ["eye_R", P2.EyeOpenRight],
2423
+ ["lash_lower_R", P2.EyeOpenRight],
2424
+ ...[...LIP_ROLES, ...LIP_INSIDE_ROLES].map((id) => [
2425
+ id,
2426
+ P2.MouthOpen
2427
+ ])
2428
+ ]);
2429
+ var ROLE_TABLE = [
2430
+ { role: "hair_back", family: "hair_back" },
2431
+ { role: "body", family: "body" },
2432
+ { role: "arm_L", family: "arm", parts: ["elbow_L", "forearm_L", "arm_L"] },
2433
+ { role: "arm_R", family: "arm", parts: ["elbow_R", "forearm_R", "arm_R"] },
2434
+ { role: "face", family: "face", required: true },
2435
+ { role: "blush_L", family: "face" },
2436
+ { role: "blush_R", family: "face" },
2437
+ { role: "nose", family: "nose" },
2438
+ { role: "mouth", family: "mouth" },
2439
+ { role: "mouth_open", family: "mouth" },
2440
+ // The lip set, back to front: the interior, the tongue and the teeth clipped
2441
+ // to it, the lower lip's skin, the upper lip and its line over both. It
2442
+ // folds open instead of crossfading (`mouth.ts`), so it stands in for
2443
+ // `mouth`, never beside it.
2444
+ { role: "mouth_inner", family: "mouth" },
2445
+ { role: "mouth_tongue", family: "mouth" },
2446
+ { role: "mouth_teeth", family: "mouth" },
2447
+ { role: "lip_lower", family: "mouth" },
2448
+ { role: "lip_upper", family: "mouth" },
2449
+ { role: "eye_L", family: "eye_L", required: true },
2450
+ { role: "eye_R", family: "eye_R", required: true },
2451
+ { role: "iris_L", family: "eye_L" },
2452
+ { role: "iris_R", family: "eye_R" },
2453
+ { role: "pupil_L", family: "eye_L" },
2454
+ { role: "pupil_R", family: "eye_R" },
2455
+ { role: "highlight_L", family: "eye_L" },
2456
+ { role: "highlight_R", family: "eye_R" },
2457
+ { role: "lash_lower_L", family: "eye_L" },
2458
+ { role: "lash_lower_R", family: "eye_R" },
2459
+ { role: "lash_L", family: "eye_L" },
2460
+ { role: "lash_R", family: "eye_R" },
2461
+ { role: "brow_L", family: "brow_L" },
2462
+ { role: "brow_R", family: "brow_R" },
2463
+ // The front side locks: over the brows and lashes, under the bangs, which
2464
+ // hide their roots.
2465
+ { role: "hair_side_L", family: "hair_side" },
2466
+ { role: "hair_side_R", family: "hair_side" },
2467
+ { role: "hair_front", family: "hair_front" },
2468
+ // Drawn above the face and the hair: a raised hand passes them.
2469
+ { role: "forearm_pose_L", family: "forearm_pose" },
2470
+ { role: "forearm_pose_R", family: "forearm_pose" }
2471
+ ];
2472
+ var REQUIRED_ROLES = ROLE_TABLE.filter(
2473
+ (r) => r.required
2474
+ ).map((r) => r.role);
2475
+ var SPEC_BY_ROLE = new Map(ROLE_TABLE.map((r, i) => [r.role, { ...r, i }]));
2476
+ function roleSpec(role) {
2477
+ const spec = SPEC_BY_ROLE.get(role);
2478
+ if (spec === void 0) throw new Error(`auto-rig: unknown role "${role}"`);
2479
+ return spec;
2480
+ }
2481
+ function partIdsOfRole(role) {
2482
+ return roleSpec(role).parts ?? [role];
2483
+ }
2484
+ var ROLE_BY_PART = new Map(
2485
+ ROLE_TABLE.flatMap((r) => (r.parts ?? []).map((p) => [p, r.role]))
2486
+ );
2487
+ function roleOfPart(partId) {
2488
+ return ROLE_BY_PART.get(partId) ?? partId;
2489
+ }
2490
+ var ALIASES = {
2491
+ eyebrow_L: "brow_L",
2492
+ eyebrow_R: "brow_R",
2493
+ eye_white_L: "eye_L",
2494
+ eye_white_R: "eye_R"
2495
+ };
2496
+ function roleOf(fileName) {
2497
+ const role = fileName.replace(/\.[^.]+$/, "").toLowerCase().replace(/[-\s]+/g, "_").replace(/_([lr])$/, (_, s) => `_${s.toUpperCase()}`);
2498
+ return ALIASES[role] ?? role;
2499
+ }
2500
+ function checkArmsHaveBody(roles) {
2501
+ const has = new Set(roles);
2502
+ if (has.has("body")) return;
2503
+ const arm = ROLE_TABLE.find((r) => r.family === "arm" && has.has(r.role));
2504
+ if (arm !== void 0) {
2505
+ throw new Error(
2506
+ `auto-rig: ${arm.role} needs a body layer (an arm hangs from the body's shoulder)`
2507
+ );
2508
+ }
2509
+ }
2510
+ function checkSidesHaveFront(roles) {
2511
+ const has = new Set(roles);
2512
+ if (has.has("hair_front")) return;
2513
+ const side = ROLE_TABLE.find(
2514
+ (r) => r.family === "hair_side" && has.has(r.role)
2515
+ );
2516
+ if (side !== void 0) {
2517
+ throw new Error(
2518
+ `auto-rig: ${side.role} needs a hair_front layer (a side lock's root hides under the bangs)`
2519
+ );
2520
+ }
2521
+ }
2522
+ function checkMouth(roles) {
2523
+ const has = new Set(roles);
2524
+ const present = LIP_ROLES.filter((r) => has.has(r));
2525
+ const inside = LIP_INSIDE_ROLES.filter((r) => has.has(r));
2526
+ if (inside.length > 0 && present.length === 0) {
2527
+ throw new Error(
2528
+ `auto-rig: ${inside[0]} needs the lip set ${LIP_ROLES.join(", ")} (it is cut from the interior and clipped to it)`
2529
+ );
2530
+ }
2531
+ if (present.length === 0) {
2532
+ if (!has.has("mouth")) {
2533
+ throw new Error(
2534
+ `auto-rig: missing required role mouth (or the lip set ${LIP_ROLES.join(", ")})`
2535
+ );
2536
+ }
2537
+ return;
2538
+ }
2539
+ if (present.length < LIP_ROLES.length) {
2540
+ throw new Error(
2541
+ `auto-rig: the lip set is partial: missing ${LIP_ROLES.filter((r) => !has.has(r)).join(", ")}`
2542
+ );
2543
+ }
2544
+ const mixed = ["mouth", "mouth_open"].filter((r) => has.has(r));
2545
+ if (mixed.length > 0) {
2546
+ throw new Error(
2547
+ `auto-rig: the lip set cannot be mixed with ${mixed.join(", ")} (the lip set replaces the mouth drawings; use one or the other)`
2548
+ );
2549
+ }
2550
+ }
2551
+ function checkPosesHaveArms(roles) {
2552
+ const has = new Set(roles);
2553
+ for (const side of ["L", "R"]) {
2554
+ if (has.has(`forearm_pose_${side}`) && !has.has(`arm_${side}`)) {
2555
+ throw new Error(
2556
+ `auto-rig: forearm_pose_${side} needs an arm_${side} layer (a pose forearm hangs from its arm's elbow)`
2557
+ );
2558
+ }
2559
+ }
2560
+ }
2561
+ function parseLayerRoles(fileNames) {
2562
+ const seen = /* @__PURE__ */ new Map();
2563
+ const out = [];
2564
+ for (const fileName of fileNames) {
2565
+ const role = roleOf(fileName);
2566
+ if (!SPEC_BY_ROLE.has(role)) {
2567
+ throw new Error(
2568
+ `auto-rig: "${fileName}" names no known role (expected one of ${ROLE_TABLE.map((r) => r.role).join(", ")})`
2569
+ );
2570
+ }
2571
+ const prior = seen.get(role);
2572
+ if (prior !== void 0) {
2573
+ throw new Error(
2574
+ `auto-rig: role "${role}" is named twice ("${prior}" and "${fileName}")`
2575
+ );
2576
+ }
2577
+ seen.set(role, fileName);
2578
+ out.push({ role, fileName });
2579
+ }
2580
+ const missing = REQUIRED_ROLES.filter((r) => !seen.has(r));
2581
+ if (missing.length > 0) {
2582
+ throw new Error(
2583
+ `auto-rig: missing required role${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`
2584
+ );
2585
+ }
2586
+ checkMouth(seen.keys());
2587
+ checkArmsHaveBody(seen.keys());
2588
+ checkPosesHaveArms(seen.keys());
2589
+ checkSidesHaveFront(seen.keys());
2590
+ return out;
2591
+ }
2592
+
2556
2593
  // src/auto-rig/mouth.ts
2557
2594
  var UPPER_SHARE = 0.4;
2558
2595
  var CLOSED_SAG_MAX = 0.1;
@@ -2948,6 +2985,7 @@ function buildHeadFrame(layers, opts) {
2948
2985
  let paintedHalfAt = () => plateHalf;
2949
2986
  let neck;
2950
2987
  let ears;
2988
+ let headHalfAt = (y) => paintedHalfAt(y);
2951
2989
  let headMax = plateHalf;
2952
2990
  let chinY = face.y0;
2953
2991
  let cutAt = () => face.y0 - 1;
@@ -2966,6 +3004,12 @@ function buildHeadFrame(layers, opts) {
2966
3004
  const t = clamp(r - i, 0, 1);
2967
3005
  return prof[i] + (prof[j] - prof[i]) * t;
2968
3006
  };
3007
+ const readRows = (rows) => (y) => {
3008
+ const r = canvasH / 2 - y - faceLayer.bbox.y - 0.5;
3009
+ const i = clamp(Math.floor(r), 0, rows.length - 1);
3010
+ const j = Math.min(i + 1, rows.length - 1);
3011
+ return rows[i] + (rows[j] - rows[i]) * clamp(r - i, 0, 1);
3012
+ };
2969
3013
  const rawRows = raw;
2970
3014
  const earless = (chin) => {
2971
3015
  const rowOf = (y) => Math.round(canvasH / 2 - y - faceLayer.bbox.y - 0.5);
@@ -3066,6 +3110,7 @@ function buildHeadFrame(layers, opts) {
3066
3110
  canvasH / 2 - nk.topY - faceLayer.bbox.y - 0.5
3067
3111
  );
3068
3112
  const head = earless(chinY);
3113
+ headHalfAt = readRows(head);
3069
3114
  headMax = Math.max(...head);
3070
3115
  const outlineAt = (d) => {
3071
3116
  for (let r = neckRow; r >= 0; r--) {
@@ -3108,6 +3153,7 @@ function buildHeadFrame(layers, opts) {
3108
3153
  };
3109
3154
  } else {
3110
3155
  const head = earless(chinY);
3156
+ headHalfAt = readRows(head);
3111
3157
  headMax = Math.max(...head);
3112
3158
  if (ears !== void 0) {
3113
3159
  const bottomOf = (d) => {
@@ -3155,6 +3201,7 @@ function buildHeadFrame(layers, opts) {
3155
3201
  chinY,
3156
3202
  hh,
3157
3203
  paintedHalfAt,
3204
+ headHalfAt,
3158
3205
  wMax,
3159
3206
  headMax,
3160
3207
  hasProfile: profile !== null,
@@ -3172,6 +3219,96 @@ function buildHeadFrame(layers, opts) {
3172
3219
  };
3173
3220
  }
3174
3221
 
3222
+ // src/auto-rig/hair-side-room.ts
3223
+ var round3 = (v) => Math.round(v * 1e3) / 1e3;
3224
+ var signed = (v) => round3(v) + 0;
3225
+ function headEdge(ctx, land2, y, side, ax) {
3226
+ const { frame } = ctx;
3227
+ return land2("face", frame.axisX + side * frame.headHalfAt(y), y, ax);
3228
+ }
3229
+ var LOCKS = ["hair_side_L", "hair_side_R"];
3230
+ function hairSideRoom(ctx, land2, boxes) {
3231
+ const out = {};
3232
+ for (const role of LOCKS) {
3233
+ const box = boxes[role];
3234
+ if (box === void 0) continue;
3235
+ const side = (box.x0 + box.x1) / 2 < ctx.frame.axisX ? -1 : 1;
3236
+ const key = side < 0 ? "left" : "right";
3237
+ if (out[key] !== void 0) continue;
3238
+ out[key] = lockRoom(ctx, land2, boxes, role, box, side);
3239
+ }
3240
+ return out.left || out.right ? out : void 0;
3241
+ }
3242
+ function lockRoom(ctx, land2, boxes, role, box, side) {
3243
+ const { frame } = ctx;
3244
+ if (frame.hairRuns(role, box.y1 - 0.5) === void 0) {
3245
+ return { role, unavailable: "no rowRuns" };
3246
+ }
3247
+ const room = { role };
3248
+ const stops = farStops(side);
3249
+ let cover;
3250
+ for (let i = 0; i < Math.ceil(box.y1 - box.y0); i++) {
3251
+ const y2 = box.y1 - i - 0.5;
3252
+ if (y2 < frame.face.y0 || y2 > frame.face.y1) continue;
3253
+ const outer2 = outerEdge(frame.hairRuns(role, y2) ?? [], frame.axisX, side);
3254
+ if (outer2 === void 0) continue;
3255
+ const rest = side * (outer2 - frame.axisX) - frame.headHalfAt(y2);
3256
+ if (rest < 0) continue;
3257
+ for (const ax of stops) {
3258
+ const px = side * (land2(role, outer2, y2, ax) - headEdge(ctx, land2, y2, side, ax));
3259
+ if (cover === void 0 || px < cover.px)
3260
+ cover = { deg: ax, px, y: y2, rest };
3261
+ }
3262
+ }
3263
+ if (cover !== void 0) {
3264
+ room.cover = {
3265
+ deg: cover.deg,
3266
+ px: signed(cover.px),
3267
+ y: round3(cover.y),
3268
+ rest: signed(cover.rest)
3269
+ };
3270
+ }
3271
+ const eye = side < 0 ? ctx.eyeBoxes.left : ctx.eyeBoxes.right;
3272
+ const eyeRole = (side < 0 ? ctx.left : ctx.right).role.replace(
3273
+ /^iris_/,
3274
+ "eye_"
3275
+ );
3276
+ const y = cy(eye);
3277
+ const runs = frame.hairRuns(role, y) ?? [];
3278
+ const outer = outerEdge(runs, frame.axisX, side);
3279
+ if (outer === void 0) return room;
3280
+ const inner = outer - side * runReach(runs, outer, side).inward;
3281
+ const lashRole = eyeRole.replace(/^eye_/, "lash_");
3282
+ const lash = boxes[lashRole];
3283
+ let tail;
3284
+ for (const ax of stops) {
3285
+ const lock = land2(role, inner, y, ax);
3286
+ const corner = land2(eyeRole, side < 0 ? eye.x0 : eye.x1, y, ax);
3287
+ const eyeToLock = side * (lock - corner);
3288
+ if (tail !== void 0 && eyeToLock >= tail.eyeToLock) continue;
3289
+ tail = {
3290
+ deg: ax,
3291
+ eyeToLock,
3292
+ eyeToPlate: side * (headEdge(ctx, land2, y, side, ax) - corner)
3293
+ };
3294
+ if (lash !== void 0) {
3295
+ const tip = land2(lashRole, side < 0 ? lash.x0 : lash.x1, y, ax);
3296
+ tail.lashToLock = side * (lock - tip);
3297
+ }
3298
+ }
3299
+ if (tail !== void 0) {
3300
+ room.tail = {
3301
+ deg: tail.deg,
3302
+ eyeToLock: signed(tail.eyeToLock),
3303
+ ...tail.lashToLock !== void 0 && {
3304
+ lashToLock: signed(tail.lashToLock)
3305
+ },
3306
+ eyeToPlate: signed(tail.eyeToPlate)
3307
+ };
3308
+ }
3309
+ return room;
3310
+ }
3311
+
3175
3312
  // src/auto-rig/fields.ts
3176
3313
  var NOSE_TILT_DEG = 6;
3177
3314
  function nodDy(v, ay, hh) {
@@ -3373,12 +3510,12 @@ function hairFrontBends(frame, boxes) {
3373
3510
  }
3374
3511
  function hairFrontField(m) {
3375
3512
  const f = m.frame;
3376
- const back = hairField(m, m.hairBack, NOD.hairBack);
3377
3513
  const cap = { up: NOD.hairFrontTop.up, down: capSlide(m) };
3378
3514
  const eyes = [m.boxes.eye_L, m.boxes.eye_R].filter(
3379
3515
  (e) => e !== void 0
3380
3516
  );
3381
3517
  const { inner, left, right } = hairFrontBends(f, m.boxes);
3518
+ const capTop = m.hairFrontGrid?.ys.find((y) => y <= f.face.y1) ?? f.face.y1;
3382
3519
  const eyeY = eyes.length > 0 ? eyes.reduce((t, e) => t + cy(e), 0) / eyes.length : f.eyeY;
3383
3520
  const eyeH = eyes.length > 0 ? eyes.reduce((t, e) => t + (e.y1 - e.y0), 0) / eyes.length : 1;
3384
3521
  const coreAt = (x, y, s) => {
@@ -3411,22 +3548,60 @@ function hairFrontField(m) {
3411
3548
  );
3412
3549
  const followOf = (x, y, s) => followAt(x, y, s) + (ride === void 0 ? 0 : ride.lift(x, y, s));
3413
3550
  const drift = Math.abs(m.hairBack);
3414
- const crown = crownRide(m, cap, (x, y, s) => followOf(x, y, s) + drift);
3415
3551
  const boundOf = (x, y, s) => {
3416
3552
  if (ride === void 0) return followOf(x, y, s);
3417
- const b = ride.blend(y, s);
3553
+ const b = ride.blend(x, y, s);
3418
3554
  return (1 - b) * (followOf(x, y, s) + ride.cut(x, y, s)) - b * drift;
3419
3555
  };
3420
3556
  return (x, y, ax, ay) => {
3421
3557
  const s = Math.sign(ax);
3422
3558
  const a = Math.abs(ax) / 30;
3423
- const rx = s * a * boundOf(x, y, s) + a * (m.shellScale - 1) * (x - f.axisX);
3559
+ const rx = s * a * boundOf(x, Math.min(y, capTop), s) + a * (m.shellScale - 1) * (x - f.axisX);
3424
3560
  const ry = nodDy(NOD.hairFront, ay, f.hh);
3425
- const bx = back(x, y, ax, ay)[0];
3426
3561
  const by = nodDy(cap, ay, f.hh);
3427
- const w = crownBlend(m, y);
3428
- const wx = crown === void 0 ? w : w + crown(y, s);
3429
- return [bx + wx * (rx - bx), by + w * (ry - by)];
3562
+ return [rx, by + crownBlend(m, y) * (ry - by)];
3563
+ };
3564
+ }
3565
+ var SIDE_ROOT_BLEND = 0.2;
3566
+ function sideRootBlend(f, role, box) {
3567
+ let rootEnd = box.y1 - (box.y1 - box.y0) / 4;
3568
+ if (f.hairRuns(role, box.y1 - 0.5) !== void 0 && f.hairRuns("hair_front", box.y1 - 0.5) !== void 0) {
3569
+ const shows = (y) => {
3570
+ const lock = f.hairRuns(role, y);
3571
+ const front = f.hairRuns("hair_front", y);
3572
+ for (let k = 0; k < lock.length; k += 2) {
3573
+ for (let px = lock[k]; px < lock[k + 1]; px++) {
3574
+ if (!paints(front, px)) return true;
3575
+ }
3576
+ }
3577
+ return false;
3578
+ };
3579
+ const rows = Array.from(
3580
+ { length: Math.round(box.y1 - box.y0) },
3581
+ (_, i2) => i2
3582
+ );
3583
+ const i = rows.findIndex((i2) => shows(box.y1 - i2 - 0.5));
3584
+ rootEnd = i < 0 ? box.y0 : box.y1 - i;
3585
+ }
3586
+ const end = rootEnd - SIDE_ROOT_BLEND * f.hh;
3587
+ return { rootEnd, weight: (y) => smoothstep(rootEnd, end, y) };
3588
+ }
3589
+ function hairSideField(m) {
3590
+ const front = hairFrontField(m);
3591
+ const face = faceField(m);
3592
+ const locks = ["hair_side_L", "hair_side_R"].flatMap((role) => {
3593
+ const box = m.boxes[role];
3594
+ return box === void 0 ? [] : [{ box, ...sideRootBlend(m.frame, role, box) }];
3595
+ });
3596
+ return (x, y, ax, ay) => {
3597
+ const lock = locks.find((l) => x >= l.box.x0 && x <= l.box.x1);
3598
+ if (lock === void 0) return face(x, y, ax, ay);
3599
+ const t = lock.weight(y);
3600
+ if (t === 0) return front(x, y, ax, ay);
3601
+ if (t === 1) return face(x, y, ax, ay);
3602
+ const [fx, fy] = front(x, y, ax, ay);
3603
+ const [gx, gy] = face(x, y, ax, ay);
3604
+ return [fx * (1 - t) + gx * t, fy * (1 - t) + gy * t];
3430
3605
  };
3431
3606
  }
3432
3607
  function crownBlend(m, y) {
@@ -3563,17 +3738,17 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3563
3738
  sides.forEach((side, k) => {
3564
3739
  const edge = outerEdge(front, f.axisX, side);
3565
3740
  if (edge === void 0) return;
3566
- let outward = Infinity;
3741
+ let outward2 = Infinity;
3567
3742
  let inward = Infinity;
3568
3743
  for (let j = Math.floor(i - above); j < Math.ceil(i + 1 + below); j++) {
3569
3744
  const c = runReach(backRows[j - j0], edge, side);
3570
- outward = Math.min(outward, c.outward);
3745
+ outward2 = Math.min(outward2, c.outward);
3571
3746
  inward = Math.min(inward, c.inward);
3572
3747
  }
3573
3748
  offset[k][i] = side * (edge - f.axisX);
3574
- reachOut[k][i] = outward;
3749
+ reachOut[k][i] = outward2;
3575
3750
  reachIn[k][i] = inward;
3576
- if (i < capRows && runReach(backRows[i - j0], edge, side).inward > 0)
3751
+ if (i < capRows && y <= f.face.y1 && runReach(backRows[i - j0], edge, side).inward > 0)
3577
3752
  bound[k][i] = 1;
3578
3753
  });
3579
3754
  }
@@ -3583,12 +3758,12 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3583
3758
  const [i0, i1] = rowWindow(ys, r, count);
3584
3759
  sides.forEach((side, k) => {
3585
3760
  let d = Infinity;
3586
- let outward = Infinity;
3761
+ let outward2 = Infinity;
3587
3762
  let inward = Infinity;
3588
3763
  for (let i = i0; i < i1; i++) {
3589
3764
  if (Number.isNaN(offset[k][i])) continue;
3590
3765
  d = Math.min(d, offset[k][i]);
3591
- outward = Math.min(outward, reachOut[k][i]);
3766
+ outward2 = Math.min(outward2, reachOut[k][i]);
3592
3767
  inward = Math.min(inward, reachIn[k][i]);
3593
3768
  }
3594
3769
  end[r * 2 + k] = d;
@@ -3597,7 +3772,7 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3597
3772
  sides.forEach((s, j) => {
3598
3773
  const allow = Math.min(
3599
3774
  coreAt(x, ys[r], s),
3600
- m.shellScale * (s === side ? outward : inward) - drift
3775
+ m.shellScale * (s === side ? outward2 : inward) - drift
3601
3776
  );
3602
3777
  lift[(r * 2 + k) * 2 + j] = Math.max(0, allow - followAt(x, ys[r], s));
3603
3778
  });
@@ -3668,73 +3843,117 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3668
3843
  return quarter.map((q) => scale * q);
3669
3844
  };
3670
3845
  const cuts = new Float64Array(n * 2 * nc);
3671
- const blend = new Float64Array(n * 2);
3846
+ const blend = new Float64Array(n * 2 * nc);
3847
+ const motion = new Array(n * 2);
3848
+ const outward = sides.map((side) => {
3849
+ const cols = xs.map((_, c) => c).filter((c) => side * (xs[c] - f.axisX) >= 0);
3850
+ return side < 0 ? cols.reverse() : cols;
3851
+ });
3672
3852
  const wr = ys.map((y) => crownBlend(m, y));
3673
3853
  for (let r = 0; r <= last && capRows > 0; r++) {
3674
- const w = wr[r];
3675
- if (w <= 0) continue;
3854
+ if (wr[r] < 1) continue;
3676
3855
  const edges = [];
3856
+ const copied = r > 0 && wr[r - 1] < 1;
3677
3857
  const [i0, i1] = rowWindow(ys, r, capRows);
3678
3858
  for (let i = i0; i < i1; i++) {
3859
+ const y = top - i - 0.5;
3679
3860
  sides.forEach((side, k) => {
3680
3861
  if (bound[k][i] === 0) return;
3681
3862
  const x = f.axisX + side * offset[k][i];
3682
- const at = readAt(r, x, top - i - 0.5);
3683
- if (at === void 0) return;
3684
- edges.push({
3685
- side,
3686
- x,
3687
- at,
3688
- outward: reachOut[k][i],
3689
- inward: reachIn[k][i]
3690
- });
3863
+ for (const at2 of [
3864
+ readAt(r, x, y),
3865
+ copied && y > ys[r] ? readAt(r - 1, x, y) : void 0
3866
+ ]) {
3867
+ if (at2 === void 0) continue;
3868
+ edges.push({
3869
+ side,
3870
+ x,
3871
+ at: at2,
3872
+ outward: reachOut[k][i],
3873
+ inward: reachIn[k][i]
3874
+ });
3875
+ }
3691
3876
  });
3692
3877
  }
3693
3878
  if (edges.length === 0) continue;
3694
3879
  sides.forEach((s, j) => {
3695
3880
  const today = xs.map((x) => followAt(x, ys[r], s) + liftAt(x, ys[r], s));
3696
3881
  const after = today.slice();
3697
- if (w >= 1) {
3698
- for (const side of sides) {
3699
- const mine = edges.filter((e) => e.side === side);
3700
- if (mine.length === 0) continue;
3701
- const cut = capCut(r, s, side, today, mine);
3702
- for (let c = 0; c < nc; c++) after[c] -= cut[c];
3703
- }
3882
+ for (const side of sides) {
3883
+ const mine = edges.filter((e) => e.side === side);
3884
+ if (mine.length === 0) continue;
3885
+ const cut = capCut(r, s, side, today, mine);
3886
+ for (let c = 0; c < nc; c++) after[c] -= cut[c];
3704
3887
  }
3705
- let b = 0;
3888
+ const b = blend.subarray((r * 2 + j) * nc, (r * 2 + j + 1) * nc);
3706
3889
  for (const e of edges) {
3707
- const rel = w * (along(after, e.at) + drift);
3708
3890
  const hi = m.shellScale * ahead(e, s);
3709
3891
  const lo = -m.shellScale * behind(e, s);
3710
- if (rel > hi) b = Math.max(b, 1 - hi / rel);
3711
- else if (rel < lo) b = Math.max(b, 1 - lo / rel);
3892
+ const { c, t } = gridCol(xs, e.at);
3893
+ for (const [cc, weight] of [
3894
+ [c, 1 - t],
3895
+ [c + 1, t]
3896
+ ]) {
3897
+ if (weight <= 0) continue;
3898
+ const rel = after[cc] + drift;
3899
+ if (rel > hi) b[cc] = Math.max(b[cc], 1 - hi / rel);
3900
+ else if (rel < lo) b[cc] = Math.max(b[cc], 1 - lo / rel);
3901
+ }
3712
3902
  }
3713
- blend[r * 2 + j] = b;
3903
+ motion[r * 2 + j] = after.map((v) => v + drift);
3714
3904
  for (let c = 0; c < nc; c++)
3715
3905
  cuts[(r * 2 + j) * nc + c] = after[c] - today[c];
3716
3906
  });
3717
3907
  }
3718
- for (let j = 0; j < 2; j++) {
3719
- for (let r = n - 2; r >= 0; r--) {
3720
- if (r <= last || wr[r] < 1)
3721
- blend[r * 2 + j] = Math.max(blend[r * 2 + j], blend[(r + 1) * 2 + j]);
3722
- }
3908
+ for (let r = 0; r < n; r++) {
3909
+ sides.forEach((s, j) => {
3910
+ motion[r * 2 + j] ??= xs.map(
3911
+ (x) => followAt(x, ys[r], s) + liftAt(x, ys[r], s) + drift
3912
+ );
3913
+ });
3723
3914
  }
3915
+ const at = (r, j, c) => (r * 2 + j) * nc + c;
3724
3916
  let zero = last + 1;
3725
3917
  while (zero < n && ys[zero] > eyeTop) zero++;
3726
3918
  while (zero > last + 1 && zero < n && ys[zero] < irisTop - 0.5) zero--;
3727
3919
  const endY = zero < n ? ys[zero] : Math.min(Math.max(eyeTop, irisTop - 0.5), ys[n - 1] - 1);
3728
- for (let j = 0; j < 2 && last < n - 1; j++) {
3729
- for (let r = 1; r < zero; r++) {
3730
- for (let q = 0; q < r && q <= last; q++) {
3731
- if (wr[q] < 1) continue;
3732
- blend[r * 2 + j] = Math.max(
3733
- blend[r * 2 + j],
3734
- blend[q * 2 + j] * (ys[r] - endY) / (ys[q] - endY)
3735
- );
3920
+ for (let pass = 0; pass < 8; pass++) {
3921
+ const was = blend.slice();
3922
+ for (let j = 0; j < 2; j++) {
3923
+ for (let r = n - 2; r >= 0; r--) {
3924
+ if (r > last && wr[r] >= 1) continue;
3925
+ for (let c = 0; c < nc; c++)
3926
+ blend[at(r, j, c)] = Math.max(
3927
+ blend[at(r, j, c)],
3928
+ blend[at(r + 1, j, c)]
3929
+ );
3736
3930
  }
3737
3931
  }
3932
+ for (let j = 0; j < 2 && last < n - 1; j++) {
3933
+ for (let r = 1; r < zero; r++) {
3934
+ for (let q = 0; q < r && q <= last; q++) {
3935
+ if (wr[q] < 1) continue;
3936
+ const k = (ys[r] - endY) / (ys[q] - endY);
3937
+ for (let c = 0; c < nc; c++)
3938
+ blend[at(r, j, c)] = Math.max(
3939
+ blend[at(r, j, c)],
3940
+ blend[at(q, j, c)] * k
3941
+ );
3942
+ }
3943
+ }
3944
+ }
3945
+ for (let r = 0; r < n; r++) {
3946
+ sides.forEach(
3947
+ (s, j) => spreadBlend(
3948
+ blend.subarray(at(r, j, 0), at(r, j, nc)),
3949
+ motion[r * 2 + j],
3950
+ xs,
3951
+ outward,
3952
+ s
3953
+ )
3954
+ );
3955
+ }
3956
+ if (blend.every((v, i) => v === was[i])) break;
3738
3957
  }
3739
3958
  const cutRow = (r, j, x) => r > last ? 0 : along(cuts.subarray((r * 2 + j) * nc, (r * 2 + j + 1) * nc), x);
3740
3959
  return {
@@ -3747,158 +3966,47 @@ function outerRide(m, inner, coreAt, followAt, capLimit, eyeTop, irisTop) {
3747
3966
  const { r, t } = gridRow(ys, y, true);
3748
3967
  return cutRow(r, j, x) * (1 - t) + cutRow(r + 1, j, x) * t;
3749
3968
  },
3750
- blend: (y, s) => {
3969
+ blend: (x, y, s) => {
3751
3970
  if (s === 0) return 0;
3752
3971
  const j = s < 0 ? 0 : 1;
3753
3972
  const { r, t } = gridRow(ys, y, true);
3754
- return blend[r * 2 + j] * (1 - t) + blend[(r + 1) * 2 + j] * t;
3973
+ const row = (q) => along(blend.subarray(at(q, j, 0), at(q, j, nc)), x);
3974
+ return row(r) * (1 - t) + row(r + 1) * t;
3755
3975
  }
3756
3976
  };
3757
3977
  }
3758
- function crownRide(m, cap, relAt) {
3759
- const f = m.frame;
3760
- const grid = m.hairFrontGrid;
3761
- if (grid === void 0) return void 0;
3762
- const { xs, ys } = grid;
3763
- const top = ys[0];
3764
- if (f.hairRuns("hair_front", top) === void 0 || f.hairRuns("hair_back", top) === void 0) {
3765
- return void 0;
3766
- }
3767
- const n = ys.length;
3768
- const count = Math.round(top - ys[n - 1]);
3769
- const hh = f.hh;
3770
- const face = faceField(m);
3771
- const { wr, blendAt } = renderedCrownBlend(m, ys);
3772
- const backRows = /* @__PURE__ */ new Map();
3773
- const backAt = (j) => {
3774
- let runs = backRows.get(j);
3775
- if (runs === void 0) {
3776
- runs = f.hairRuns("hair_back", top - j - 0.5);
3777
- backRows.set(j, runs);
3778
- }
3779
- return runs;
3780
- };
3781
- const faceAt = (j) => {
3782
- const y = top - j - 0.5;
3783
- return f.faceRuns(y) ?? (y > f.face.y0 && y < f.face.y1 ? [f.face.x0, f.face.x1] : []);
3784
- };
3785
- const passed = (i, dys) => [
3786
- Math.floor(i - Math.max(0, ...dys)),
3787
- Math.ceil(i + 1 - Math.min(0, ...dys))
3788
- ];
3789
- const rel = new Float64Array(n * xs.length * 2).fill(NaN);
3790
- const relOf = (r, c, k) => {
3791
- const at = (r * xs.length + c) * 2 + k;
3792
- if (Number.isNaN(rel[at])) rel[at] = relAt(xs[c], ys[r], k === 0 ? -1 : 1);
3793
- return rel[at];
3794
- };
3795
- const cellR = (x, y, k) => {
3796
- const { c } = gridCol(xs, x);
3797
- const { r } = gridRow(ys, y);
3798
- return Math.max(
3799
- relOf(r, c, k),
3800
- relOf(r, c + 1, k),
3801
- relOf(r + 1, c, k),
3802
- relOf(r + 1, c + 1, k)
3803
- );
3804
- };
3805
- const someAngle = (pairs) => {
3806
- let lo = 0;
3807
- let hi = 1;
3808
- for (const [c0, c1] of pairs) {
3809
- if (c1 === 0) {
3810
- if (c0 <= 0) return false;
3811
- } else if (c1 > 0) lo = Math.max(lo, -c0 / c1);
3812
- else hi = Math.min(hi, -c0 / c1);
3813
- }
3814
- return lo < hi;
3978
+ function spreadBlend(b, a, xs, outward, s) {
3979
+ const fit = (ci, co, g) => {
3980
+ const quarter = Math.abs(xs[co] - xs[ci]) / 4;
3981
+ const today = g * (a[co] - a[ci]);
3982
+ const [lo, hi] = [
3983
+ Math.min(today, 0) - quarter,
3984
+ Math.max(today, 0) + quarter
3985
+ ];
3986
+ const ga = g * a[ci];
3987
+ if (ga === 0) return;
3988
+ const outer = g * (1 - b[co]) * a[co];
3989
+ const [b0, b1] = [1 - (outer - hi) / ga, 1 - (outer - lo) / ga];
3990
+ const least = Math.max(b[ci], Math.min(b0, b1));
3991
+ if (least <= Math.min(1, Math.max(b0, b1))) b[ci] = least;
3992
+ else if (Math.min(b0, b1) > 1) b[ci] = 1;
3815
3993
  };
3816
- const slack = [-1, 1].map(() => new Float64Array(count).fill(NaN));
3817
- const slackAt = (i, k) => {
3818
- if (!Number.isNaN(slack[k][i])) return slack[k][i];
3819
- const y = top - i - 0.5;
3820
- const w = blendAt(y);
3821
- const nod = {
3822
- up: w * NOD.hairFront.up + (1 - w) * cap.up,
3823
- down: w * NOD.hairFront.down + (1 - w) * cap.down
3824
- };
3825
- const ays = [-30, 30];
3826
- const [b0, b1] = passed(
3827
- i,
3828
- ays.map((ay) => nodDy(nod, ay, hh) - nodDy(NOD.hairBack, ay, hh))
3829
- );
3830
- const band = [];
3831
- for (let j = b0; j < b1; j++) band.push(backAt(j));
3832
- const faces = [];
3833
- const [f0, f1] = passed(
3834
- i,
3835
- ays.map((ay) => nodDy(nod, ay, hh) - face(f.axisX, y, 0, ay)[1])
3836
- );
3837
- for (let j = f0; j < f1; j++)
3838
- faces.push({ y: top - j - 0.5, runs: faceAt(j) });
3839
- const front = f.hairRuns("hair_front", y);
3840
- [-1, 1].forEach((s, side) => {
3841
- let least = Infinity;
3842
- const far = outerEdge(front, f.axisX, s);
3843
- if (far !== void 0) {
3844
- const R = cellR(far, y, side);
3845
- const c = Math.min(...band.map((b) => runReach(b, far, s).outward));
3846
- if (R > 0) least = Math.min(least, m.shellScale * c / R);
3847
- }
3848
- for (let t = s > 0 ? 0 : 1; t < front.length; t += 2) {
3849
- const x = front[t];
3850
- const R = cellR(x, y, side);
3851
- if (R <= 0) continue;
3852
- let c = Math.min(
3853
- ...band.map((b) => runReach(b, x, s > 0 ? -1 : 1).inward)
3854
- );
3855
- const sh = (m.shellScale - 1) * (x - f.axisX);
3856
- const d0 = sh + s * m.hairBack;
3857
- const d1 = d0 + s * R;
3858
- const lo = Math.min(d0, d1);
3859
- const hi = Math.max(d0, d1);
3860
- const bares = faces.some(({ y: fy, runs }) => {
3861
- for (let r = 0; r < runs.length; r += 2) {
3862
- const e0 = runs[r];
3863
- const e1 = runs[r + 1];
3864
- const de0 = face(e0, fy, 30 * s, 0)[0];
3865
- const de1 = face(e1, fy, 30 * s, 0)[0];
3866
- if (someAngle([
3867
- [e1 - x, de1 - lo],
3868
- [x - e0, hi - de0]
3869
- ])) {
3870
- return true;
3871
- }
3872
- }
3873
- return false;
3874
- });
3875
- if (bares) c = 0;
3876
- least = Math.min(least, m.shellScale * c / R);
3877
- }
3878
- slack[side][i] = least - w;
3994
+ for (const cols of outward) {
3995
+ for (let i = 1; i < cols.length; i++)
3996
+ b[cols[i]] = Math.max(b[cols[i]], b[cols[i - 1]]);
3997
+ }
3998
+ const [left, right] = outward.map((cols) => cols[0]);
3999
+ const across = left !== void 0 && right !== void 0 && left !== right;
4000
+ for (let pass = 0; pass < 2; pass++) {
4001
+ outward.forEach((cols, k) => {
4002
+ const g = (k === 0 ? -1 : 1) * s;
4003
+ for (let i = cols.length - 1; i > 0; i--) fit(cols[i - 1], cols[i], g);
3879
4004
  });
3880
- return slack[k][i];
3881
- };
3882
- const extra = [0, 1].map((k) => {
3883
- const out = wr.slice();
3884
- for (let r = 0; r < n; r++) {
3885
- if (wr[r] >= 1) continue;
3886
- const [i0, i1] = rowWindow(ys, r, count);
3887
- let least = Infinity;
3888
- for (let i = i0; i < i1; i++) least = Math.min(least, slackAt(i, k));
3889
- out[r] = wr[r] + clamp(least, 0, 1 - wr[r]);
4005
+ if (across) {
4006
+ if (b[left] <= b[right]) fit(left, right, s);
4007
+ else fit(right, left, -s);
3890
4008
  }
3891
- for (let r = n - 2; r >= 0; r--) {
3892
- if (wr[r] < 1) out[r] = Math.max(wr[r], Math.min(out[r], out[r + 1]));
3893
- }
3894
- return out.map((v, r) => v - wr[r]);
3895
- });
3896
- return (y, s) => {
3897
- if (s === 0) return 0;
3898
- const e = extra[s < 0 ? 0 : 1];
3899
- const { r, t } = gridRow(ys, y, true);
3900
- return e[r] * (1 - t) + e[r + 1] * t;
3901
- };
4009
+ }
3902
4010
  }
3903
4011
  function familyField(m, family) {
3904
4012
  switch (family) {
@@ -3916,6 +4024,8 @@ function familyField(m, family) {
3916
4024
  return mouthField(m);
3917
4025
  case "hair_front":
3918
4026
  return hairFrontField(m);
4027
+ case "hair_side":
4028
+ return hairSideField(m);
3919
4029
  case "hair_back":
3920
4030
  return hairField(m, m.hairBack, NOD.hairBack);
3921
4031
  case "body":
@@ -4072,16 +4182,16 @@ function strandOverlaps(ctx, land2, holdBase) {
4072
4182
  if (px <= 0) continue;
4073
4183
  out[key] = {
4074
4184
  deg,
4075
- px: round3(px),
4076
- hh: round3(px) / holdBase,
4077
- restPx: round3(restPx),
4185
+ px: round32(px),
4186
+ hh: round32(px) / holdBase,
4187
+ restPx: round32(restPx),
4078
4188
  // A fringe spanning the face has no side to hold the iris clear of.
4079
4189
  held: strand.runFace !== null && px <= restPx + STRAND_SLACK
4080
4190
  };
4081
4191
  }
4082
4192
  return out.left || out.right ? out : void 0;
4083
4193
  }
4084
- var round3 = (v) => Math.round(v * 1e3) / 1e3;
4194
+ var round32 = (v) => Math.round(v * 1e3) / 1e3;
4085
4195
  var STRAND_SLACK = 0.5;
4086
4196
  function bisect(f, lo, hi, it = 50) {
4087
4197
  for (let i = 0; i < it; i++) {
@@ -4296,7 +4406,7 @@ function turnReport(ctx, fit, rendered) {
4296
4406
  const plate = depth(m.face);
4297
4407
  const w = frame.paintedHalfAt(frame.eyeY);
4298
4408
  const report = {
4299
- radius: round3(w * w / (2 * Math.max(1e-3, eye - plate))),
4409
+ radius: round32(w * w / (2 * Math.max(1e-3, eye - plate))),
4300
4410
  holdBase: hb,
4301
4411
  depths: {
4302
4412
  eye: round4(eye / hb),
@@ -4312,6 +4422,8 @@ function turnReport(ctx, fit, rendered) {
4312
4422
  };
4313
4423
  const overlap = strandOverlaps(ctx, rendered, hb);
4314
4424
  if (overlap !== void 0) report.strandOverlap = overlap;
4425
+ const hairSide = hairSideRoom(ctx, rendered, m.boxes);
4426
+ if (hairSide !== void 0) report.hairSide = hairSide;
4315
4427
  return report;
4316
4428
  }
4317
4429
  var round4 = (v) => Math.round(v * 1e4) / 1e4;
@@ -4467,7 +4579,7 @@ var RIG_PLAN_FIELDS = [
4467
4579
  fitBy: null,
4468
4580
  provisional: false,
4469
4581
  migratedFrom: "outlineFollow",
4470
- 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)"
4582
+ 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)"
4471
4583
  },
4472
4584
  {
4473
4585
  path: "hair.sway",
@@ -5533,6 +5645,7 @@ function checkLayers(layers, canvas) {
5533
5645
  }
5534
5646
  checkArmsHaveBody(seen);
5535
5647
  checkPosesHaveArms(seen);
5648
+ checkSidesHaveFront(seen);
5536
5649
  }
5537
5650
  function checkHeadEdges(edges, byRole) {
5538
5651
  if (edges === void 0) return;
@@ -6259,6 +6372,10 @@ var MESH_CELLS = {
6259
6372
  face: { px: 28, min: 4, max: 24 },
6260
6373
  // The front hair eases from riding the face to holding the outline.
6261
6374
  hair_front: { px: 32, min: 3, max: 24 },
6375
+ // A side lock's rows come from `sideLockGrid`: even ones, plus the root
6376
+ // band's (`sideRootBlend`), which a long lock's coarse cells would straddle.
6377
+ hair_side_L: { px: 20, min: 3, max: 24 },
6378
+ hair_side_R: { px: 20, min: 3, max: 24 },
6262
6379
  hair_back: { px: 56, min: 3, max: 18 }
6263
6380
  };
6264
6381
  var NECK_CELLS = { px: 20, min: 3, max: 12 };
@@ -6310,7 +6427,18 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
6310
6427
  const solveFrame = buildHeadFrame(layers, frameOptions);
6311
6428
  const ctx = solveContext(solveFrame, byRole, turnOptions, hasNose);
6312
6429
  const boxes = Object.fromEntries(
6313
- ["eye_L", "eye_R", "iris_L", "iris_R", "brow_L", "brow_R"].filter(has).map((r) => [r, box(r)])
6430
+ [
6431
+ "eye_L",
6432
+ "eye_R",
6433
+ "iris_L",
6434
+ "iris_R",
6435
+ "brow_L",
6436
+ "brow_R",
6437
+ "hair_side_L",
6438
+ "hair_side_R",
6439
+ "lash_L",
6440
+ "lash_R"
6441
+ ].filter(has).map((r) => [r, box(r)])
6314
6442
  );
6315
6443
  const landmarks = {
6316
6444
  boxes,
@@ -6515,6 +6643,8 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6515
6643
  headStart = fm.headStart;
6516
6644
  } else if (spec.role === "hair_front") {
6517
6645
  mesh = columnMesh(b, frontGrid);
6646
+ } else if (spec.role === "hair_side_L" || spec.role === "hair_side_R") {
6647
+ mesh = columnMesh(b, sideLockGrid(b, frame, spec.role));
6518
6648
  } else if (lips !== void 0 && (isLipRole(spec.role) || isLipInsideRole(spec.role))) {
6519
6649
  ({ mesh, xs: knotXs } = mouthMesh(b, lips.knots));
6520
6650
  } else if (spec.family !== "body") {
@@ -6529,6 +6659,7 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6529
6659
  const side = spec.role.endsWith("_L") ? "L" : "R";
6530
6660
  const eyeOpen = side === "L" ? P10.EyeOpenLeft : P10.EyeOpenRight;
6531
6661
  const hh = frame.hh;
6662
+ const swayAmp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6532
6663
  switch (spec.role) {
6533
6664
  case "face":
6534
6665
  if (region?.some((r) => r === "neck")) {
@@ -6665,18 +6796,30 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6665
6796
  case "hair_front":
6666
6797
  case "hair_back":
6667
6798
  if (byRole.has("hair_front")) {
6668
- const amp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6669
6799
  if (spec.role === "hair_front") {
6670
6800
  warps.push(
6671
- hairSway(P10.HairSwayX, mesh, b, amp),
6672
- hairSway(P10.HairSwayZ, mesh, b, amp)
6801
+ hairSway(P10.HairSwayX, mesh, b, swayAmp),
6802
+ hairSway(P10.HairSwayZ, mesh, b, swayAmp)
6673
6803
  );
6674
6804
  } else {
6675
- warps.push(hairSway(P10.HairBackSway, mesh, b, amp));
6805
+ warps.push(hairSway(P10.HairBackSway, mesh, b, swayAmp));
6676
6806
  }
6677
6807
  }
6678
6808
  warps.push(tiltHang(mesh, b, pivot, hairHangWeight(frame.chinY, b.y0)));
6679
6809
  break;
6810
+ case "hair_side_L":
6811
+ case "hair_side_R": {
6812
+ const attach = {
6813
+ box: box("hair_front"),
6814
+ weight: sideRootBlend(frame, spec.role, b).weight
6815
+ };
6816
+ warps.push(
6817
+ lockSway(P10.HairSwayX, mesh, b, swayAmp, attach),
6818
+ lockSway(P10.HairSwayZ, mesh, b, swayAmp, attach),
6819
+ tiltHang(mesh, b, pivot, lockHangWeight(frame.chinY, b.y0, hh))
6820
+ );
6821
+ break;
6822
+ }
6680
6823
  }
6681
6824
  if (warps.length > 0) part.warps = warps;
6682
6825
  if (bindings.length > 0) part.bindings = bindings;
@@ -6733,9 +6876,34 @@ function hairFrontGrid(b, frame, boxes) {
6733
6876
  }
6734
6877
  return { xs, ys };
6735
6878
  }
6879
+ var BLEND_ROWS = 3;
6880
+ function sideLockGrid(b, frame, role) {
6881
+ const cells = MESH_CELLS[role];
6882
+ const px = Math.min(cells.px, meshScale(role, frame));
6883
+ const cols = cellsFor(bw(b), px, cells.min, cells.max);
6884
+ const rows = cellsFor(bh(b), px, cells.min, cells.max);
6885
+ const xs = Array.from(
6886
+ { length: cols + 1 },
6887
+ (_, c) => b.x0 + bw(b) * c / cols
6888
+ );
6889
+ const ys = Array.from(
6890
+ { length: rows + 1 },
6891
+ (_, r) => b.y1 - bh(b) * r / rows
6892
+ );
6893
+ const { rootEnd } = sideRootBlend(frame, role, b);
6894
+ const end = rootEnd - SIDE_ROOT_BLEND * frame.hh;
6895
+ for (let i = 0; i <= BLEND_ROWS + 1; i++) {
6896
+ const y = rootEnd + (end - rootEnd) * i / (BLEND_ROWS + 1);
6897
+ if (y > b.y0 + 0.25 && y < b.y1 - 0.25 && ys.every((v) => Math.abs(v - y) > 0.25))
6898
+ ys.push(y);
6899
+ }
6900
+ return { xs, ys: ys.sort((p, q) => q - p) };
6901
+ }
6736
6902
  function meshScale(role, frame) {
6737
6903
  switch (role) {
6738
6904
  case "hair_front":
6905
+ case "hair_side_L":
6906
+ case "hair_side_R":
6739
6907
  case "hair_back":
6740
6908
  return Math.max(2, (frame.shellRight - frame.shellLeft) / 9);
6741
6909
  default: