@rig-c/parts 1.31.0 → 1.33.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/src/turn.ts CHANGED
@@ -29,6 +29,7 @@
29
29
  * one piece. Pure: no file, no clock.
30
30
  */
31
31
  import { computeWorldTransforms, toBoneLocalVector, toWorld, type BoneTransform } from './coords.ts';
32
+ import { type Problem, refuseIfAny } from './errors.ts';
32
33
  import { pyRound } from './round.ts';
33
34
 
34
35
  /**
@@ -165,10 +166,29 @@ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDefo
165
166
  return out;
166
167
  }
167
168
 
168
- /** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, and optional face surface deform overrides. */
169
+ /**
170
+ * Issue #472: the rated band of a single face plate — yaw `±15°`, pitch `±12°`.
171
+ * Beyond this band on one plate, the foreshortened far eye runs out of cheek to
172
+ * sit on; going further takes an alternate face set (`assemble.alternates`),
173
+ * whose swap threshold is this band edge.
174
+ */
175
+ export const FACE_RATED_YAW_BAND = 15;
176
+ export const FACE_RATED_PITCH_BAND = 12;
177
+ export const FACE_RATED_BAND = { yaw: FACE_RATED_YAW_BAND, pitch: FACE_RATED_PITCH_BAND } as const;
178
+
179
+ /**
180
+ * Issue #477: allowed `[min, max]` range (in rig px) for an authored ear
181
+ * `derive.depth` in `turn` or `tilt`. Ears sit at or behind the head pivot
182
+ * (`depth <= 0`, where `-14` moves the ears slightly against the face turn); a
183
+ * depth outside `[-50, 0]` is refused by `CONFIG_TURN_EAR_DEPTH_RANGE`.
184
+ */
185
+ export const EAR_DEPTH_RANGE: readonly [number, number] = [-50, 0];
186
+
187
+ /** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, optional rated band, and optional face surface deform overrides. */
169
188
  export interface TurnSpec {
170
189
  pivot: number;
171
190
  depths: Record<string, number>;
191
+ band?: number | { yaw?: number; pitch?: number };
172
192
  deform?: Partial<FaceDeformFractions>;
173
193
  }
174
194
 
@@ -291,6 +311,13 @@ export interface FaceSurfaceDeformOptions {
291
311
  opaqueHeight?: number;
292
312
  /** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
293
313
  overrides?: Readonly<Partial<FaceDeformFractions>> | null;
314
+ /**
315
+ * Issue #472: true when `assemble.alternates` declares an alternate face set
316
+ * for the overflow beyond `±15°` yaw / `±12°` pitch. When false or absent,
317
+ * any `yaw` / `pitch` slider constraints on the rig are preceded by clamping
318
+ * transform constraints holding the dial bones to `[-15, 15]` and `[-12, 12]`.
319
+ */
320
+ hasAlternateFaceSet?: boolean;
294
321
  }
295
322
 
296
323
  interface DeformWeight {
@@ -328,7 +355,7 @@ interface DeriveSpec {
328
355
 
329
356
  interface SliderKey {
330
357
  t: number;
331
- v?: number[];
358
+ v?: number[] | Record<string, number[]>;
332
359
  derive?: DeriveSpec;
333
360
  }
334
361
 
@@ -390,14 +417,16 @@ function buildFaceGrid(width: number, height: number): { points: Array<[number,
390
417
  /**
391
418
  * Promote the `face` region on `head` to a 5×5 grid mesh and populate the
392
419
  * `tilt` and `turn` slider animations with the Set B ×2.5 face and hair mesh
393
- * deform timelines and ear/topknot tracks (issue #436). When `motion` holds
394
- * neither `tilt` nor `turn` with slider `derive` keys, neither `rig` nor
395
- * `motion` is touched.
420
+ * deform timelines, feature field-sampled tracks (#468) and ear/topknot tracks
421
+ * (issue #436). When `motion` holds neither `tilt` nor `turn` with slider
422
+ * `derive` keys, neither `rig` nor `motion` is touched.
396
423
  */
397
424
  export function applyFaceSurfaceDeform(
398
425
  rig: {
399
426
  bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
427
+ slots?: Array<{ name: string; bone: string }>;
400
428
  skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
429
+ constraints?: Array<Record<string, unknown>>;
401
430
  invariants?: Record<string, unknown>;
402
431
  },
403
432
  motion: {
@@ -413,6 +442,44 @@ export function applyFaceSurfaceDeform(
413
442
  (rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
414
443
  (rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
415
444
  if (!hasDerive) return;
445
+
446
+ // Issue #477: refuse an out-of-range ear derive.depth by name before touching rig or motion.
447
+ const earProblems: Problem[] = [];
448
+ const [earLo, earHi] = EAR_DEPTH_RANGE;
449
+ for (const [animName, anim] of [
450
+ ['tilt', rawTilt],
451
+ ['turn', rawTurn],
452
+ ] as const) {
453
+ if (anim === undefined) continue;
454
+ anim.tracks.forEach((tr, ti) => {
455
+ const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
456
+ if (!isEarTrack) return;
457
+ tr.keys.forEach((k, ki) => {
458
+ const d = k.derive?.depth;
459
+ if (typeof d === 'number') {
460
+ if (d < earLo || d > earHi) {
461
+ earProblems.push({
462
+ code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
463
+ object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
464
+ detail: `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
465
+ });
466
+ }
467
+ } else if (d !== undefined && typeof d === 'object') {
468
+ for (const [member, val] of Object.entries(d)) {
469
+ if (typeof val === 'number' && (val < earLo || val > earHi)) {
470
+ earProblems.push({
471
+ code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
472
+ object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
473
+ detail: `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
474
+ });
475
+ }
476
+ }
477
+ }
478
+ });
479
+ });
480
+ }
481
+ refuseIfAny(earProblems);
482
+
416
483
  const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
417
484
  const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
418
485
  if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
@@ -421,10 +488,63 @@ export function applyFaceSurfaceDeform(
421
488
  const isOpts =
422
489
  options !== undefined &&
423
490
  options !== null &&
424
- ('imageWidth' in options || 'imageHeight' in options || 'opaqueWidth' in options || 'opaqueHeight' in options || 'overrides' in options);
491
+ ('imageWidth' in options ||
492
+ 'imageHeight' in options ||
493
+ 'opaqueWidth' in options ||
494
+ 'opaqueHeight' in options ||
495
+ 'overrides' in options ||
496
+ 'hasAlternateFaceSet' in options);
425
497
  const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
426
498
  const f = resolveFaceDeformFractions(opts.overrides);
427
499
 
500
+ // Issue #472: when no alternate face set is declared, clamp dial bones to the single-plate rated band (±15° yaw, ±12° pitch).
501
+ if (!opts.hasAlternateFaceSet && Array.isArray(rig.constraints)) {
502
+ const nextConstraints: Array<Record<string, unknown>> = [];
503
+ const existingNames = new Set(rig.constraints.map((c) => c.name));
504
+ const makeClampConstraint = (name: string, bone: string, prop: unknown, band: number): Record<string, unknown> => {
505
+ const useX = prop === 'x' || prop === 'translatex';
506
+ const key = useX ? 'x' : 'rotate';
507
+ const mixKey = useX ? 'mixX' : 'mixRotate';
508
+ return {
509
+ name,
510
+ type: 'transform',
511
+ source: bone,
512
+ bones: [bone],
513
+ localSource: true,
514
+ localTarget: true,
515
+ clamp: true,
516
+ properties: {
517
+ [key]: {
518
+ offset: -band,
519
+ to: {
520
+ [key]: {
521
+ offset: -band,
522
+ max: band,
523
+ scale: 1,
524
+ },
525
+ },
526
+ },
527
+ },
528
+ [mixKey]: 1,
529
+ };
530
+ };
531
+ for (const c of rig.constraints) {
532
+ if (c.type === 'slider' && typeof c.bone === 'string') {
533
+ const isYaw = c.name === 'yaw' || c.animation === 'turn';
534
+ const isPitch = c.name === 'pitch' || c.animation === 'tilt';
535
+ if (isYaw && !existingNames.has('yaw_clamp')) {
536
+ existingNames.add('yaw_clamp');
537
+ nextConstraints.push(makeClampConstraint('yaw_clamp', c.bone, c.property, FACE_RATED_YAW_BAND));
538
+ } else if (isPitch && !existingNames.has('pitch_clamp')) {
539
+ existingNames.add('pitch_clamp');
540
+ nextConstraints.push(makeClampConstraint('pitch_clamp', c.bone, c.property, FACE_RATED_PITCH_BAND));
541
+ }
542
+ }
543
+ nextConstraints.push(c);
544
+ }
545
+ rig.constraints = nextConstraints;
546
+ }
547
+
428
548
  const faceSlot = rig.skins.default.face;
429
549
  if (faceSlot === undefined || faceSlot.face === undefined) return;
430
550
  const faceAtt = faceSlot.face;
@@ -434,13 +554,15 @@ export function applyFaceSurfaceDeform(
434
554
  let faceRegX = -1;
435
555
  let faceRegY = 49;
436
556
 
557
+ const faceBone = rig.slots?.find((s) => s.name === 'face')?.bone ?? (rig.bones.some((b) => b.name === 'turn_face_head') ? 'turn_face_head' : 'head');
558
+
437
559
  if (!('type' in faceAtt)) {
438
560
  faceRegX = faceAtt.x;
439
561
  faceRegY = faceAtt.y;
440
562
  const grid = buildFaceGrid(faceW, faceH);
441
563
  const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
442
564
  {
443
- bone: 'head',
565
+ bone: faceBone,
444
566
  x: pyRound(faceRegX - faceW / 2 + px, 6),
445
567
  y: pyRound(faceRegY + faceH / 2 - py, 6),
446
568
  weight: 1,
@@ -531,8 +653,31 @@ export function applyFaceSurfaceDeform(
531
653
  };
532
654
 
533
655
  const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
656
+ const referencedGroups = new Set<string>();
657
+ for (const anim of [tiltAnim, turnAnim]) {
658
+ for (const tr of anim?.tracks ?? []) {
659
+ if (tr.group !== undefined) referencedGroups.add(tr.group);
660
+ }
661
+ }
662
+ const defaultGroups: Readonly<Record<string, readonly string[]>> = {
663
+ features: ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'pupil_r', 'pupil_l', 'eyelash_r', 'eyelash_l', 'eyebrow_r', 'eyebrow_l', 'nose', 'mouth'],
664
+ ears: ['ear_r', 'ear_l'],
665
+ bangs: ['bang_r0', 'bang_c0', 'bang_l0'].map((b) => (boneMap.has(`${b}_ctl`) ? `${b}_ctl` : b)),
666
+ topknot: ['bun_ctl', 'hairpin', 'headwear'],
667
+ };
668
+ for (const g of referencedGroups) {
669
+ if (motion.groups?.[g] === undefined && defaultGroups[g] !== undefined) {
670
+ const members = defaultGroups[g].filter((b) => boneMap.has(b));
671
+ if (members.length > 0) {
672
+ motion.groups ??= {};
673
+ motion.groups[g] = members;
674
+ }
675
+ }
676
+ }
677
+
534
678
  const evalDeriveKey = (boneName: string, k: SliderKey): number => {
535
679
  if (Array.isArray(k.v)) return k.v[0] ?? 0;
680
+ if (k.v !== undefined && typeof k.v === 'object') return k.v[boneName]?.[0] ?? 0;
536
681
  const d = k.derive;
537
682
  if (d === undefined) return 0;
538
683
  const b = boneMap.get(boneName);
@@ -546,17 +691,18 @@ export function applyFaceSurfaceDeform(
546
691
  };
547
692
 
548
693
  const mapParent: Readonly<Record<string, string>> = {
549
- bang_r0: 'bang_r0_ctl',
550
- bang_r1: 'bang_r0_ctl',
551
- bang_c0: 'bang_c0_ctl',
552
- bang_c1: 'bang_c0_ctl',
553
- bang_l0: 'bang_l0_ctl',
554
- bang_l1: 'bang_l0_ctl',
555
- bun: 'bun_ctl',
694
+ bang_r0: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
695
+ bang_r1: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
696
+ bang_c0: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
697
+ bang_c1: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
698
+ bang_l0: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
699
+ bang_l1: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
700
+ bun: boneMap.has('bun_ctl') ? 'bun_ctl' : 'bun',
556
701
  };
557
702
 
558
703
  const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
559
- const driver = mapParent[boneName] ?? boneName;
704
+ const baseBone = boneName.replace(/^turn_[^_]+_/, '');
705
+ const driver = mapParent[baseBone] ?? baseBone;
560
706
  let dx = 0;
561
707
  let dy = 0;
562
708
  for (const tr of tracks) {
@@ -598,16 +744,37 @@ export function applyFaceSurfaceDeform(
598
744
  const hyEar = Y0 - (16 / 128) * H;
599
745
  const earRx = boneMap.get('ear_r')?.x ?? -51;
600
746
  const earLx = boneMap.get('ear_l')?.x ?? 47.5;
747
+ const featRx = (24 / 106) * W;
748
+ const featRy = (12 / 128) * H;
749
+ const featMembers = motion.groups?.features ?? [];
750
+
751
+ const minDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, fallback: number): number => {
752
+ if (typeof depth === 'number') return depth > 0 ? depth : fallback;
753
+ if (depth === undefined) return fallback;
754
+ const vals = Object.values(depth).filter((v): v is number => typeof v === 'number' && v > 0);
755
+ return vals.length > 0 ? Math.min(...vals) : fallback;
756
+ };
757
+
758
+ const memberDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, boneName: string, zRef: number): number => {
759
+ if (typeof depth === 'number') return depth;
760
+ return depth?.[boneName] ?? zRef;
761
+ };
601
762
 
602
763
  if (tiltAnim !== undefined) {
603
764
  const origTiltTracks = tiltAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
604
- const tiltKeys: Array<{ t: number; deg: number }> = [
605
- { t: 0, deg: -25 },
606
- { t: 0.75, deg: -10 },
607
- { t: 1.25, deg: 0 },
608
- { t: 2.0, deg: 15 },
609
- { t: 2.5, deg: 25 },
610
- ];
765
+ const refTiltTrack = origTiltTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTiltTracks[0];
766
+ const isLegacyTilt =
767
+ tiltAnim.duration === 2.5 &&
768
+ Math.abs(refTiltTrack?.keys[0]?.derive?.degrees ?? 0) === 25;
769
+ const tiltKeys: Array<{ t: number; deg: number }> = isLegacyTilt
770
+ ? [
771
+ { t: 0, deg: -25 },
772
+ { t: 0.75, deg: -10 },
773
+ { t: 1.25, deg: 0 },
774
+ { t: 2.0, deg: 15 },
775
+ { t: 2.5, deg: 25 },
776
+ ]
777
+ : (refTiltTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
611
778
  const tiltDeform: DeformTimelineEntry[] = [
612
779
  {
613
780
  slot: 'face',
@@ -657,6 +824,31 @@ export function applyFaceSurfaceDeform(
657
824
  });
658
825
  }
659
826
  for (const tr of tiltAnim.tracks) {
827
+ if (tr.group === 'features' && (tr.property === 'translatey' || tr.property === 'scaley') && featMembers.length > 0) {
828
+ const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 34);
829
+ tr.keys = tr.keys.map((k) => {
830
+ const deg = k.derive?.degrees ?? 0;
831
+ if (deg === 0) return { t: k.t, v: tr.property === 'scaley' ? [1] : [0] };
832
+ const vMap: Record<string, number[]> = {};
833
+ for (const bname of featMembers) {
834
+ const b = boneMap.get(bname);
835
+ const bx = b?.x ?? 0;
836
+ const by = b?.y ?? Y0;
837
+ const z = memberDepthOf(k.derive?.depth, bname, zRef);
838
+ const dFrac = zRef > 0 ? z / zRef : 1;
839
+ const uy = Math.max(-1, Math.min(1, (by - Y0) / (H / 2)));
840
+ const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - uy * uy));
841
+ const [, dyD] = evalPitchFaceSurface(bx, by - featRy, effDeg, W, H, X0, Y0, f);
842
+ const [, dyU] = evalPitchFaceSurface(bx, by + featRy, effDeg, W, H, X0, Y0, f);
843
+ if (tr.property === 'translatey') {
844
+ vMap[bname] = [pyRound(0.5 * (dyD + dyU), 4)];
845
+ } else {
846
+ vMap[bname] = [pyRound(Math.max(0.2, 1 + (dyU - dyD) / (2 * featRy)), 4)];
847
+ }
848
+ }
849
+ return { t: k.t, v: vMap };
850
+ });
851
+ }
660
852
  if (tr.group === 'ears' && tr.property === 'translatey') {
661
853
  tr.keys = tr.keys.map((k) => {
662
854
  const deg = k.derive?.degrees ?? 0;
@@ -674,7 +866,7 @@ export function applyFaceSurfaceDeform(
674
866
  });
675
867
  }
676
868
  }
677
- const refTiltKeys = origTiltTracks[0]?.keys ?? [];
869
+ const refTiltKeys = refTiltTrack?.keys ?? [];
678
870
  for (const [earBone, earHx] of [
679
871
  ['ear_r', earRx],
680
872
  ['ear_l', earLx],
@@ -696,13 +888,19 @@ export function applyFaceSurfaceDeform(
696
888
 
697
889
  if (turnAnim !== undefined) {
698
890
  const origTurnTracks = turnAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
699
- const turnKeys: Array<{ t: number; deg: number }> = [
700
- { t: 0, deg: -30 },
701
- { t: 0.75, deg: -15 },
702
- { t: 1.5, deg: 0 },
703
- { t: 2.25, deg: 15 },
704
- { t: 3.0, deg: 30 },
705
- ];
891
+ const refTurnTrack = origTurnTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTurnTracks[0];
892
+ const isLegacyTurn =
893
+ turnAnim.duration === 3.0 &&
894
+ Math.abs(refTurnTrack?.keys[0]?.derive?.degrees ?? 0) === 30;
895
+ const turnKeys: Array<{ t: number; deg: number }> = isLegacyTurn
896
+ ? [
897
+ { t: 0, deg: -30 },
898
+ { t: 0.75, deg: -15 },
899
+ { t: 1.5, deg: 0 },
900
+ { t: 2.25, deg: 15 },
901
+ { t: 3.0, deg: 30 },
902
+ ]
903
+ : (refTurnTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
706
904
  const turnDeform: DeformTimelineEntry[] = [
707
905
  {
708
906
  slot: 'face',
@@ -753,25 +951,84 @@ export function applyFaceSurfaceDeform(
753
951
  ),
754
952
  });
755
953
  }
756
- const refTurnKeys = origTurnTracks[0]?.keys ?? [];
954
+ const refTurnKeys = refTurnTrack?.keys ?? [];
955
+ const earMembers = motion.groups?.ears ?? ['ear_r', 'ear_l'].filter((b) => boneMap.has(b));
956
+ const hasAuthoredEarTurnTx = turnAnim.tracks.some(
957
+ (tr) => tr.property === 'translatex' && (tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l'),
958
+ );
757
959
  turnAnim.tracks = turnAnim.tracks.filter(
758
- (tr) => !(tr.group === 'ears' && tr.property === 'translatex') && !(tr.group === 'topknot' && tr.property === 'translatex'),
960
+ (tr) => !(tr.group === 'topknot' && tr.property === 'translatex'),
759
961
  );
760
- for (const [earBone, earHx] of [
761
- ['ear_r', earRx],
762
- ['ear_l', earLx],
763
- ] as const) {
764
- if (!boneMap.has(earBone)) continue;
765
- turnAnim.tracks.push({
766
- bone: earBone,
767
- property: 'translatex',
768
- keys: refTurnKeys.map((k) => {
962
+ for (const tr of turnAnim.tracks) {
963
+ if (tr.group === 'features' && (tr.property === 'translatex' || tr.property === 'scalex') && featMembers.length > 0) {
964
+ const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 36);
965
+ tr.keys = tr.keys.map((k) => {
769
966
  const deg = k.derive?.degrees ?? 0;
967
+ if (deg === 0) return { t: k.t, v: tr.property === 'scalex' ? [1] : [0] };
968
+ const vMap: Record<string, number[]> = {};
969
+ const s = Math.sign(deg);
970
+ for (const bname of featMembers) {
971
+ const b = boneMap.get(bname);
972
+ const bx = b?.x ?? X0;
973
+ const by = b?.y ?? Y0;
974
+ const z = memberDepthOf(k.derive?.depth, bname, zRef);
975
+ const dFrac = zRef > 0 ? z / zRef : 1;
976
+ const ux = Math.max(-1, Math.min(1, (bx - X0) / (W / 2)));
977
+ const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - ux * ux));
978
+ const [dxL] = evalYawFaceSurface(bx - featRx, by, effDeg, W, H, X0, Y0, f);
979
+ const [dxR] = evalYawFaceSurface(bx + featRx, by, effDeg, W, H, X0, Y0, f);
980
+ const rawDelta = (dxR - dxL) / (2 * featRx);
981
+ const isTrailing = s * (bx - X0) > featRx * 0.5;
982
+ const sx = isTrailing ? 1 + Math.abs(rawDelta) : Math.max(0.2, 1 + rawDelta);
983
+ const dx = isTrailing
984
+ ? (s > 0 ? dxR - featRx * (sx - 1) : dxL + featRx * (sx - 1))
985
+ : 0.5 * (dxL + dxR);
986
+ if (tr.property === 'translatex') {
987
+ vMap[bname] = [pyRound(dx, 4)];
988
+ } else {
989
+ vMap[bname] = [pyRound(sx, 4)];
990
+ }
991
+ }
992
+ return { t: k.t, v: vMap };
993
+ });
994
+ } else if (tr.group === 'ears' && tr.property === 'translatex' && earMembers.length > 0) {
995
+ tr.keys = tr.keys.map((k) => {
996
+ if (k.derive === undefined) return k;
997
+ const deg = k.derive.degrees;
770
998
  if (deg === 0) return { t: k.t, v: [0] };
771
- const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
772
- return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
773
- }),
774
- });
999
+ const vMap: Record<string, number[]> = {};
1000
+ for (const bname of earMembers) {
1001
+ vMap[bname] = [evalDeriveKey(bname, k)];
1002
+ }
1003
+ return { t: k.t, v: vMap };
1004
+ });
1005
+ } else if ((tr.bone === 'ear_r' || tr.bone === 'ear_l') && tr.property === 'translatex') {
1006
+ const bname = tr.bone;
1007
+ tr.keys = tr.keys.map((k) => {
1008
+ if (k.derive === undefined) return k;
1009
+ const deg = k.derive.degrees;
1010
+ if (deg === 0) return { t: k.t, v: [0] };
1011
+ return { t: k.t, v: [evalDeriveKey(bname, k)] };
1012
+ });
1013
+ }
1014
+ }
1015
+ if (!hasAuthoredEarTurnTx) {
1016
+ for (const [earBone, earHx] of [
1017
+ ['ear_r', earRx],
1018
+ ['ear_l', earLx],
1019
+ ] as const) {
1020
+ if (!boneMap.has(earBone)) continue;
1021
+ turnAnim.tracks.push({
1022
+ bone: earBone,
1023
+ property: 'translatex',
1024
+ keys: refTurnKeys.map((k) => {
1025
+ const deg = k.derive?.degrees ?? 0;
1026
+ if (deg === 0) return { t: k.t, v: [0] };
1027
+ const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
1028
+ return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
1029
+ }),
1030
+ });
1031
+ }
775
1032
  }
776
1033
  for (const [tkBone, tkFrac] of [
777
1034
  ['bun_ctl', f.turn_bun_counter_x],