@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.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) {
@@ -3425,6 +3562,48 @@ function hairFrontField(m) {
3425
3562
  return [rx, by + crownBlend(m, y) * (ry - by)];
3426
3563
  };
3427
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];
3605
+ };
3606
+ }
3428
3607
  function crownBlend(m, y) {
3429
3608
  const top = m.frame.face.y1;
3430
3609
  return 1 - smoothstep(top, Math.max(top + 1, m.hairTop), y);
@@ -3845,6 +4024,8 @@ function familyField(m, family) {
3845
4024
  return mouthField(m);
3846
4025
  case "hair_front":
3847
4026
  return hairFrontField(m);
4027
+ case "hair_side":
4028
+ return hairSideField(m);
3848
4029
  case "hair_back":
3849
4030
  return hairField(m, m.hairBack, NOD.hairBack);
3850
4031
  case "body":
@@ -4001,16 +4182,16 @@ function strandOverlaps(ctx, land2, holdBase) {
4001
4182
  if (px <= 0) continue;
4002
4183
  out[key] = {
4003
4184
  deg,
4004
- px: round3(px),
4005
- hh: round3(px) / holdBase,
4006
- restPx: round3(restPx),
4185
+ px: round32(px),
4186
+ hh: round32(px) / holdBase,
4187
+ restPx: round32(restPx),
4007
4188
  // A fringe spanning the face has no side to hold the iris clear of.
4008
4189
  held: strand.runFace !== null && px <= restPx + STRAND_SLACK
4009
4190
  };
4010
4191
  }
4011
4192
  return out.left || out.right ? out : void 0;
4012
4193
  }
4013
- var round3 = (v) => Math.round(v * 1e3) / 1e3;
4194
+ var round32 = (v) => Math.round(v * 1e3) / 1e3;
4014
4195
  var STRAND_SLACK = 0.5;
4015
4196
  function bisect(f, lo, hi, it = 50) {
4016
4197
  for (let i = 0; i < it; i++) {
@@ -4225,7 +4406,7 @@ function turnReport(ctx, fit, rendered) {
4225
4406
  const plate = depth(m.face);
4226
4407
  const w = frame.paintedHalfAt(frame.eyeY);
4227
4408
  const report = {
4228
- radius: round3(w * w / (2 * Math.max(1e-3, eye - plate))),
4409
+ radius: round32(w * w / (2 * Math.max(1e-3, eye - plate))),
4229
4410
  holdBase: hb,
4230
4411
  depths: {
4231
4412
  eye: round4(eye / hb),
@@ -4241,6 +4422,8 @@ function turnReport(ctx, fit, rendered) {
4241
4422
  };
4242
4423
  const overlap = strandOverlaps(ctx, rendered, hb);
4243
4424
  if (overlap !== void 0) report.strandOverlap = overlap;
4425
+ const hairSide = hairSideRoom(ctx, rendered, m.boxes);
4426
+ if (hairSide !== void 0) report.hairSide = hairSide;
4244
4427
  return report;
4245
4428
  }
4246
4429
  var round4 = (v) => Math.round(v * 1e4) / 1e4;
@@ -4396,7 +4579,7 @@ var RIG_PLAN_FIELDS = [
4396
4579
  fitBy: null,
4397
4580
  provisional: false,
4398
4581
  migratedFrom: "outlineFollow",
4399
- 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)"
4400
4583
  },
4401
4584
  {
4402
4585
  path: "hair.sway",
@@ -5462,6 +5645,7 @@ function checkLayers(layers, canvas) {
5462
5645
  }
5463
5646
  checkArmsHaveBody(seen);
5464
5647
  checkPosesHaveArms(seen);
5648
+ checkSidesHaveFront(seen);
5465
5649
  }
5466
5650
  function checkHeadEdges(edges, byRole) {
5467
5651
  if (edges === void 0) return;
@@ -6188,6 +6372,10 @@ var MESH_CELLS = {
6188
6372
  face: { px: 28, min: 4, max: 24 },
6189
6373
  // The front hair eases from riding the face to holding the outline.
6190
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 },
6191
6379
  hair_back: { px: 56, min: 3, max: 18 }
6192
6380
  };
6193
6381
  var NECK_CELLS = { px: 20, min: 3, max: 12 };
@@ -6239,7 +6427,18 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
6239
6427
  const solveFrame = buildHeadFrame(layers, frameOptions);
6240
6428
  const ctx = solveContext(solveFrame, byRole, turnOptions, hasNose);
6241
6429
  const boxes = Object.fromEntries(
6242
- ["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)])
6243
6442
  );
6244
6443
  const landmarks = {
6245
6444
  boxes,
@@ -6444,6 +6643,8 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6444
6643
  headStart = fm.headStart;
6445
6644
  } else if (spec.role === "hair_front") {
6446
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));
6447
6648
  } else if (lips !== void 0 && (isLipRole(spec.role) || isLipInsideRole(spec.role))) {
6448
6649
  ({ mesh, xs: knotXs } = mouthMesh(b, lips.knots));
6449
6650
  } else if (spec.family !== "body") {
@@ -6458,6 +6659,7 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6458
6659
  const side = spec.role.endsWith("_L") ? "L" : "R";
6459
6660
  const eyeOpen = side === "L" ? P10.EyeOpenLeft : P10.EyeOpenRight;
6460
6661
  const hh = frame.hh;
6662
+ const swayAmp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6461
6663
  switch (spec.role) {
6462
6664
  case "face":
6463
6665
  if (region?.some((r) => r === "neck")) {
@@ -6594,18 +6796,30 @@ function buildPart(spec, b, order, frame, byRole, plan, frontGrid, lips) {
6594
6796
  case "hair_front":
6595
6797
  case "hair_back":
6596
6798
  if (byRole.has("hair_front")) {
6597
- const amp = plan.hair.sway * plan.profile.AMPLITUDE.sway * hh;
6598
6799
  if (spec.role === "hair_front") {
6599
6800
  warps.push(
6600
- hairSway(P10.HairSwayX, mesh, b, amp),
6601
- hairSway(P10.HairSwayZ, mesh, b, amp)
6801
+ hairSway(P10.HairSwayX, mesh, b, swayAmp),
6802
+ hairSway(P10.HairSwayZ, mesh, b, swayAmp)
6602
6803
  );
6603
6804
  } else {
6604
- warps.push(hairSway(P10.HairBackSway, mesh, b, amp));
6805
+ warps.push(hairSway(P10.HairBackSway, mesh, b, swayAmp));
6605
6806
  }
6606
6807
  }
6607
6808
  warps.push(tiltHang(mesh, b, pivot, hairHangWeight(frame.chinY, b.y0)));
6608
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
+ }
6609
6823
  }
6610
6824
  if (warps.length > 0) part.warps = warps;
6611
6825
  if (bindings.length > 0) part.bindings = bindings;
@@ -6662,9 +6876,34 @@ function hairFrontGrid(b, frame, boxes) {
6662
6876
  }
6663
6877
  return { xs, ys };
6664
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
+ }
6665
6902
  function meshScale(role, frame) {
6666
6903
  switch (role) {
6667
6904
  case "hair_front":
6905
+ case "hair_side_L":
6906
+ case "hair_side_R":
6668
6907
  case "hair_back":
6669
6908
  return Math.max(2, (frame.shellRight - frame.shellLeft) / 9);
6670
6909
  default: