@rig-c/parts 1.32.0 → 1.34.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
  /**
@@ -108,8 +109,6 @@ export const FACE_DEFORM_FRACTIONS = {
108
109
  tilt_topknot_y: 0.5,
109
110
  /** Forehead bang forward parallax per degree of yaw (`W/deg`; `-3.50 px` at `+15°` on `W = 106`). */
110
111
  turn_bang_parallax_x: -(3.5 / 15) / 106,
111
- /** Share of the cheek yaw horizontal field carried by lower `hair_back` (`0.90×` of `dxCheek`). */
112
- turn_hair_back_cheek_x: 0.9,
113
112
  /** Rear bun counter-parallax per degree of yaw (`W/deg`; `+8.00 px` at `+15°` on `W = 106`). */
114
113
  turn_bun_counter_x: 8.0 / 15 / 106,
115
114
  /** Rear hairpin counter-parallax per degree of yaw (`W/deg`; `+6.00 px` at `+15°` on `W = 106`). */
@@ -143,7 +142,6 @@ export const FACE_DEFORM_RANGES: Readonly<Record<FaceDeformKey, readonly [number
143
142
  tilt_ear_y: [0, 1.5],
144
143
  tilt_topknot_y: [0, 1.5],
145
144
  turn_bang_parallax_x: [-0.015, 0],
146
- turn_hair_back_cheek_x: [0, 1.5],
147
145
  turn_bun_counter_x: [0, 0.02],
148
146
  turn_hairpin_counter_x: [0, 0.02],
149
147
  turn_ear_x: [0, 1.5],
@@ -165,13 +163,93 @@ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDefo
165
163
  return out;
166
164
  }
167
165
 
168
- /** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, and optional face surface deform overrides. */
166
+ /**
167
+ * Issue #472: the rated band of a single face plate — yaw `±15°`, pitch `±12°`.
168
+ * Beyond this band on one plate, the foreshortened far eye runs out of cheek to
169
+ * sit on; going further takes an alternate face set (`assemble.alternates`),
170
+ * whose swap threshold is this band edge.
171
+ */
172
+ export const FACE_RATED_YAW_BAND = 15;
173
+ export const FACE_RATED_PITCH_BAND = 12;
174
+ export const FACE_RATED_BAND = { yaw: FACE_RATED_YAW_BAND, pitch: FACE_RATED_PITCH_BAND } as const;
175
+
176
+ /**
177
+ * Issue #477: allowed `[min, max]` range (in rig px) for an authored ear
178
+ * `derive.depth` in `turn` or `tilt`. Ears sit at or behind the head pivot
179
+ * (`depth <= 0`, where `-14` moves the ears slightly against the face turn); a
180
+ * depth outside `[-50, 0]` is refused by `CONFIG_TURN_EAR_DEPTH_RANGE`.
181
+ */
182
+ export const EAR_DEPTH_RANGE: readonly [number, number] = [-50, 0];
183
+
184
+ /** Default part names belonging to the `head` subtree when computing the head-only turn pivot (issue #478). */
185
+ export const DEFAULT_HEAD_TURN_PARTS: readonly string[] = [
186
+ 'face',
187
+ 'nose',
188
+ 'mouth',
189
+ 'eyewhite_r',
190
+ 'eyewhite_l',
191
+ 'iris_r',
192
+ 'iris_l',
193
+ 'irides_r',
194
+ 'irides_l',
195
+ 'lash_r',
196
+ 'lash_l',
197
+ 'eyelash_r',
198
+ 'eyelash_l',
199
+ 'brow_r',
200
+ 'brow_l',
201
+ 'eyebrow_r',
202
+ 'eyebrow_l',
203
+ 'ear_r',
204
+ 'ear_l',
205
+ 'ears',
206
+ 'hair_front',
207
+ 'hair_back',
208
+ 'hairpin',
209
+ 'headwear',
210
+ 'bun',
211
+ 'topknot',
212
+ ];
213
+
214
+ /** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, optional head-only pivot override, optional curved-surface cylinder radius, optional rated band, and optional face surface deform overrides. */
169
215
  export interface TurnSpec {
170
216
  pivot: number;
171
217
  depths: Record<string, number>;
218
+ head?: { pivot?: number; radius?: number };
219
+ surface?: { radius?: number };
220
+ radius?: number;
221
+ band?: number | { yaw?: number; pitch?: number };
172
222
  deform?: Partial<FaceDeformFractions>;
173
223
  }
174
224
 
225
+ /**
226
+ * Issue #479: the cylinder radius `R` (in rig px) for the curved-surface face
227
+ * yaw projection — `spec.surface.radius`, `spec.radius`, or `spec.head.radius`
228
+ * when declared, defaulting to half the face plate's opaque width (`W / 2`).
229
+ */
230
+ export function turnSurfaceRadius(spec: TurnSpec | null | undefined, W: number): number {
231
+ const r = spec?.surface?.radius ?? spec?.radius ?? spec?.head?.radius;
232
+ return typeof r === 'number' && Number.isFinite(r) && r > 0 ? r : W / 2;
233
+ }
234
+
235
+ /**
236
+ * Issue #479: horizontal displacement `Δx` of a point at rest offset `x`
237
+ * (`hx - X0`) on a vertical cylinder of radius `radius` turned by `deg`
238
+ * degrees of yaw (`θ = deg · π / 180`), with the rotated surface angle clamped
239
+ * to the visible limb `[-π/2, π/2]`. At the leading limb (`u = -sign(deg)`),
240
+ * `Δx = 0` so the leading outline holds fixed; across the interior (`|u| < 1`),
241
+ * points slide toward the leading side (`-radius · sin θ` at `x = 0`), widening
242
+ * the trailing cheek and foreshortening the leading cheek.
243
+ */
244
+ export function cylinderYawDx(x: number, deg: number, radius: number): number {
245
+ if (deg === 0 || !(radius > 0)) return 0;
246
+ const theta = (deg * Math.PI) / 180;
247
+ const u = Math.max(-1, Math.min(1, x / radius));
248
+ const phi0 = Math.asin(u);
249
+ const phi1 = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, phi0 - theta));
250
+ return radius * (Math.sin(phi1) - u);
251
+ }
252
+
175
253
  /** One carried part: its depth and the fraction of the control's offset it travels. */
176
254
  export interface TurnFraction {
177
255
  part: string;
@@ -218,6 +296,48 @@ export function turnPivot(rows: ReadonlyArray<{ depth: number; area: number }>):
218
296
  return a > 0 ? pyRound(ad / a, TURN_PLACES) : null;
219
297
  }
220
298
 
299
+ /**
300
+ * Issue #478: the head-only turn pivot — either `spec.head.pivot` when
301
+ * declared, or the `opaque_px`-weighted mean of `spec.depths` restricted to the
302
+ * `head` subtree parts (`headParts`). Neck and torso parts are excluded so a
303
+ * front-heavy body does not pull the head's rotation axis forward of the skull.
304
+ */
305
+ export function headTurnPivot(
306
+ spec: TurnSpec,
307
+ headParts: ReadonlySet<string> = new Set(DEFAULT_HEAD_TURN_PARTS),
308
+ partAreas?: Readonly<Record<string, number>>,
309
+ ): number | null {
310
+ if (typeof spec.head?.pivot === 'number' && Number.isFinite(spec.head.pivot)) {
311
+ return pyRound(spec.head.pivot, TURN_PLACES);
312
+ }
313
+ const headEntries = Object.entries(spec.depths).filter(([part, d]) => headParts.has(part) && typeof d === 'number' && Number.isFinite(d));
314
+ const entries = headEntries.length > 0 ? headEntries : Object.entries(spec.depths).filter(([, d]) => typeof d === 'number' && Number.isFinite(d));
315
+ return turnPivot(entries.map(([part, depth]) => ({ depth, area: partAreas?.[part] ?? 1 })));
316
+ }
317
+
318
+ /**
319
+ * Issue #478: a head-mounted part's turn fraction `(headPivot − depth) / (headPivot − headNearest)`
320
+ * about the head-only pivot. Parts behind `headPivot` (`hair_back`, rear `hairpin`)
321
+ * receive a negative fraction and move opposite to the face and irises.
322
+ */
323
+ export function headTurnFraction(
324
+ spec: TurnSpec,
325
+ part: string,
326
+ headParts: ReadonlySet<string> = new Set(DEFAULT_HEAD_TURN_PARTS),
327
+ partAreas?: Readonly<Record<string, number>>,
328
+ ): number | null {
329
+ const depth = spec.depths[part];
330
+ if (typeof depth !== 'number' || !Number.isFinite(depth)) return null;
331
+ const pivot = headTurnPivot(spec, headParts, partAreas);
332
+ const headDepths: Record<string, number> = {};
333
+ for (const [p, d] of Object.entries(spec.depths)) {
334
+ if (headParts.has(p) && typeof d === 'number' && Number.isFinite(d)) headDepths[p] = d;
335
+ }
336
+ const nearest = turnNearest(Object.keys(headDepths).length > 0 ? headDepths : spec.depths);
337
+ if (pivot === null || nearest === null || !(pivot > nearest.depth)) return null;
338
+ return turnFraction(pivot, nearest.depth, depth);
339
+ }
340
+
221
341
  /** The leaf bone that carries `part`'s share of `bone` under the turn: an identity child of `bone`, so it follows everything `bone` does. */
222
342
  export function turnTwin(part: string, bone: string): string {
223
343
  return `${TURN_BONE}_${part}_${bone}`;
@@ -257,7 +377,10 @@ export function evalPitchFaceSurface(
257
377
 
258
378
  /**
259
379
  * Evaluate the yaw (`turn`) surface deformation field `(dx, dy)` in `head`
260
- * space at `(hx, hy)` for `deg` degrees of yaw.
380
+ * space at `(hx, hy)` for `deg` degrees of yaw (issue #479: cylinder projection
381
+ * of radius `radius`, default `W / 2`, with lower-face jaw taper so the leading
382
+ * jaw edge holds and the trailing cheek widens; `flatYawTable: true` restores
383
+ * the flat yaw table for the Bar 4 negative control or explicit fraction overrides).
261
384
  */
262
385
  export function evalYawFaceSurface(
263
386
  hx: number,
@@ -268,16 +391,28 @@ export function evalYawFaceSurface(
268
391
  X0: number,
269
392
  Y0: number,
270
393
  f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
394
+ radius?: number,
395
+ flatYawTable = false,
271
396
  ): [number, number] {
272
397
  if (deg === 0) return [0, 0];
273
398
  const s = Math.sign(deg);
274
399
  const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
275
400
  const uy = Math.max(-1.35, Math.min(1.35, (hy - Y0) / (H / 2)));
276
- const uLead = Math.min(1.2, Math.max(0, -s * ux));
277
- const uTrail = Math.min(1.2, Math.max(0, s * ux));
278
- const fracXPerDeg = unmult(f.turn_mid_x) + unmult(f.turn_lead_compress_x) * Math.pow(uLead, 1.2) + unmult(f.turn_trail_shift_x) * Math.pow(uTrail, 1.2);
279
401
  const fracYPerDeg = unmult(f.turn_cheek_arch_y) * (-s * ux) * Math.max(0, 1 - Math.abs(uy));
280
- return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, FACE_DEFORM_MULT * deg * H * fracYPerDeg];
402
+ const dy = FACE_DEFORM_MULT * deg * H * fracYPerDeg;
403
+ if (flatYawTable) {
404
+ const uLead = Math.min(1.2, Math.max(0, -s * ux));
405
+ const uTrail = Math.min(1.2, Math.max(0, s * ux));
406
+ const fracXPerDeg = unmult(f.turn_mid_x) + unmult(f.turn_lead_compress_x) * Math.pow(uLead, 1.2) + unmult(f.turn_trail_shift_x) * Math.pow(uTrail, 1.2);
407
+ return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, dy];
408
+ }
409
+ const R = radius !== undefined && radius > 0 ? radius : W / 2;
410
+ const rawDx = cylinderYawDx(hx - X0, deg, R);
411
+ const absUx = Math.abs((hx - X0) / (W / 2));
412
+ const tOutline = Math.max(0, Math.min(1, (absUx - 0.7) / (1.0 - 0.7)));
413
+ const wOutline = 1 - tOutline * tOutline * (3 - 2 * tOutline);
414
+ const dx = rawDx * wOutline;
415
+ return [dx, dy];
281
416
  }
282
417
 
283
418
  export interface FaceSurfaceDeformOptions {
@@ -291,6 +426,37 @@ export interface FaceSurfaceDeformOptions {
291
426
  opaqueHeight?: number;
292
427
  /** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
293
428
  overrides?: Readonly<Partial<FaceDeformFractions>> | null;
429
+ /**
430
+ * Issue #472: true when `assemble.alternates` declares an alternate face set
431
+ * for the overflow beyond `±15°` yaw / `±12°` pitch. When false or absent,
432
+ * any `yaw` / `pitch` slider constraints on the rig are preceded by clamping
433
+ * transform constraints holding the dial bones to `[-15, 15]` and `[-12, 12]`.
434
+ */
435
+ hasAlternateFaceSet?: boolean;
436
+ /** Issue #478: `config.turn` holding `pivot`, `depths` and optional `head.pivot` / `radius`. */
437
+ turn?: TurnSpec | null;
438
+ /** Issue #478: each part's `opaque_px` from `parts.json` for weighting the head-only pivot. */
439
+ partAreas?: Readonly<Record<string, number>>;
440
+ /**
441
+ * Issue #478: when true, refuse by `CONFIG_TURN_REQUIRED` if `turn` is absent
442
+ * or missing `depths` for attached `face`, `hair_front`, or `hair_back`.
443
+ */
444
+ requireTurn?: boolean;
445
+ /**
446
+ * Issue #478 Bar 4 negative control: when true, flip the back-layer fraction
447
+ * sign so `hair_back` and `hairpin` move with the face instead of against it.
448
+ */
449
+ flipBackLayerSignMutant?: boolean;
450
+ /**
451
+ * Issue #479: explicit cylinder radius override in rig px (defaults to
452
+ * `turnSurfaceRadius(turn, W)` = `W / 2`).
453
+ */
454
+ surfaceRadius?: number;
455
+ /**
456
+ * Issue #479 Bar 4 negative control: when true, restore the flat yaw table
457
+ * instead of the cylinder projection.
458
+ */
459
+ flatYawTableMutant?: boolean;
294
460
  }
295
461
 
296
462
  interface DeformWeight {
@@ -328,7 +494,7 @@ interface DeriveSpec {
328
494
 
329
495
  interface SliderKey {
330
496
  t: number;
331
- v?: number[];
497
+ v?: number[] | Record<string, number[]>;
332
498
  derive?: DeriveSpec;
333
499
  }
334
500
 
@@ -390,14 +556,16 @@ function buildFaceGrid(width: number, height: number): { points: Array<[number,
390
556
  /**
391
557
  * Promote the `face` region on `head` to a 5×5 grid mesh and populate the
392
558
  * `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.
559
+ * deform timelines, feature field-sampled tracks (#468) and ear/topknot tracks
560
+ * (issues #436, #477, #478). When `motion` holds neither `tilt` nor `turn` with
561
+ * slider `derive` keys, neither `rig` nor `motion` is touched.
396
562
  */
397
563
  export function applyFaceSurfaceDeform(
398
564
  rig: {
399
565
  bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
566
+ slots?: Array<{ name: string; bone: string }>;
400
567
  skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
568
+ constraints?: Array<Record<string, unknown>>;
401
569
  invariants?: Record<string, unknown>;
402
570
  },
403
571
  motion: {
@@ -413,17 +581,141 @@ export function applyFaceSurfaceDeform(
413
581
  (rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
414
582
  (rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
415
583
  if (!hasDerive) return;
416
- const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
417
- const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
418
- if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
419
- if (turnAnim !== undefined) motion.animations.turn = turnAnim;
420
584
 
421
585
  const isOpts =
422
586
  options !== undefined &&
423
587
  options !== null &&
424
- ('imageWidth' in options || 'imageHeight' in options || 'opaqueWidth' in options || 'opaqueHeight' in options || 'overrides' in options);
588
+ ('imageWidth' in options ||
589
+ 'imageHeight' in options ||
590
+ 'opaqueWidth' in options ||
591
+ 'opaqueHeight' in options ||
592
+ 'overrides' in options ||
593
+ 'hasAlternateFaceSet' in options ||
594
+ 'turn' in options ||
595
+ 'partAreas' in options ||
596
+ 'requireTurn' in options ||
597
+ 'flipBackLayerSignMutant' in options ||
598
+ 'surfaceRadius' in options ||
599
+ 'flatYawTableMutant' in options);
425
600
  const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
601
+
602
+ // Issue #477 & #478: refuse an out-of-range ear derive.depth or missing config.turn by name before touching rig or motion.
603
+ const preProblems: Problem[] = [];
604
+ if (opts.requireTurn === true) {
605
+ if (opts.turn === undefined || opts.turn === null) {
606
+ preProblems.push({
607
+ code: 'CONFIG_TURN_REQUIRED',
608
+ object: 'config.turn',
609
+ detail: 'is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys; copy the turn block propose drafted into config.turn so the head pivot and each head part\'s depth are declared',
610
+ });
611
+ } else {
612
+ for (const reqPart of ['face', 'hair_front', 'hair_back'] as const) {
613
+ if (rig.skins.default[reqPart]?.[reqPart] !== undefined && !(reqPart in opts.turn.depths)) {
614
+ preProblems.push({
615
+ code: 'CONFIG_TURN_REQUIRED',
616
+ object: `config.turn.depths.${reqPart}`,
617
+ detail: `is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys and "${reqPart}" is attached; include "${reqPart}"'s See-through depth_median in config.turn.depths so the head pivot and fraction are defined`,
618
+ });
619
+ }
620
+ }
621
+ }
622
+ }
623
+ const [earLo, earHi] = EAR_DEPTH_RANGE;
624
+ for (const [animName, anim] of [
625
+ ['tilt', rawTilt],
626
+ ['turn', rawTurn],
627
+ ] as const) {
628
+ if (anim === undefined) continue;
629
+ anim.tracks.forEach((tr, ti) => {
630
+ const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
631
+ if (!isEarTrack) return;
632
+ tr.keys.forEach((k, ki) => {
633
+ const d = k.derive?.depth;
634
+ if (typeof d === 'number') {
635
+ if (d < earLo || d > earHi) {
636
+ preProblems.push({
637
+ code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
638
+ object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
639
+ detail: `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
640
+ });
641
+ }
642
+ } else if (d !== undefined && typeof d === 'object') {
643
+ for (const [member, val] of Object.entries(d)) {
644
+ if (typeof val === 'number' && (val < earLo || val > earHi)) {
645
+ preProblems.push({
646
+ code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
647
+ object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
648
+ detail: `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
649
+ });
650
+ }
651
+ }
652
+ }
653
+ });
654
+ });
655
+ }
656
+ refuseIfAny(preProblems);
657
+
658
+ const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
659
+ const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
660
+ if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
661
+ if (turnAnim !== undefined) motion.animations.turn = turnAnim;
662
+
426
663
  const f = resolveFaceDeformFractions(opts.overrides);
664
+ const hasFlatYawOverride =
665
+ opts.overrides !== undefined &&
666
+ opts.overrides !== null &&
667
+ (opts.overrides.turn_mid_x !== undefined ||
668
+ opts.overrides.turn_lead_compress_x !== undefined ||
669
+ opts.overrides.turn_trail_shift_x !== undefined);
670
+ const useFlatYaw = opts.flatYawTableMutant === true || hasFlatYawOverride;
671
+
672
+ // Issue #472: when no alternate face set is declared, clamp dial bones to the single-plate rated band (±15° yaw, ±12° pitch).
673
+ if (!opts.hasAlternateFaceSet && Array.isArray(rig.constraints)) {
674
+ const nextConstraints: Array<Record<string, unknown>> = [];
675
+ const existingNames = new Set(rig.constraints.map((c) => c.name));
676
+ const makeClampConstraint = (name: string, bone: string, prop: unknown, band: number): Record<string, unknown> => {
677
+ const useX = prop === 'x' || prop === 'translatex';
678
+ const key = useX ? 'x' : 'rotate';
679
+ const mixKey = useX ? 'mixX' : 'mixRotate';
680
+ return {
681
+ name,
682
+ type: 'transform',
683
+ source: bone,
684
+ bones: [bone],
685
+ localSource: true,
686
+ localTarget: true,
687
+ clamp: true,
688
+ properties: {
689
+ [key]: {
690
+ offset: -band,
691
+ to: {
692
+ [key]: {
693
+ offset: -band,
694
+ max: band,
695
+ scale: 1,
696
+ },
697
+ },
698
+ },
699
+ },
700
+ [mixKey]: 1,
701
+ };
702
+ };
703
+ for (const c of rig.constraints) {
704
+ if (c.type === 'slider' && typeof c.bone === 'string') {
705
+ const isYaw = c.name === 'yaw' || c.animation === 'turn';
706
+ const isPitch = c.name === 'pitch' || c.animation === 'tilt';
707
+ if (isYaw && !existingNames.has('yaw_clamp')) {
708
+ existingNames.add('yaw_clamp');
709
+ nextConstraints.push(makeClampConstraint('yaw_clamp', c.bone, c.property, FACE_RATED_YAW_BAND));
710
+ } else if (isPitch && !existingNames.has('pitch_clamp')) {
711
+ existingNames.add('pitch_clamp');
712
+ nextConstraints.push(makeClampConstraint('pitch_clamp', c.bone, c.property, FACE_RATED_PITCH_BAND));
713
+ }
714
+ }
715
+ nextConstraints.push(c);
716
+ }
717
+ rig.constraints = nextConstraints;
718
+ }
427
719
 
428
720
  const faceSlot = rig.skins.default.face;
429
721
  if (faceSlot === undefined || faceSlot.face === undefined) return;
@@ -434,13 +726,15 @@ export function applyFaceSurfaceDeform(
434
726
  let faceRegX = -1;
435
727
  let faceRegY = 49;
436
728
 
729
+ const faceBone = rig.slots?.find((s) => s.name === 'face')?.bone ?? (rig.bones.some((b) => b.name === 'turn_face_head') ? 'turn_face_head' : 'head');
730
+
437
731
  if (!('type' in faceAtt)) {
438
732
  faceRegX = faceAtt.x;
439
733
  faceRegY = faceAtt.y;
440
734
  const grid = buildFaceGrid(faceW, faceH);
441
735
  const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
442
736
  {
443
- bone: 'head',
737
+ bone: faceBone,
444
738
  x: pyRound(faceRegX - faceW / 2 + px, 6),
445
739
  y: pyRound(faceRegY + faceH / 2 - py, 6),
446
740
  weight: 1,
@@ -470,8 +764,8 @@ export function applyFaceSurfaceDeform(
470
764
  sumY += w[0]?.y ?? 0;
471
765
  }
472
766
  if (faceAtt.weights.length > 0) {
473
- faceRegX = Math.round(sumX / faceAtt.weights.length);
474
- faceRegY = Math.round(sumY / faceAtt.weights.length);
767
+ faceRegX = Math.round((sumX / faceAtt.weights.length) * 2) / 2;
768
+ faceRegY = Math.round((sumY / faceAtt.weights.length) * 2) / 2;
475
769
  }
476
770
  }
477
771
 
@@ -479,9 +773,13 @@ export function applyFaceSurfaceDeform(
479
773
  const H = opts.opaqueHeight ?? faceH - 8;
480
774
  const X0 = faceRegX;
481
775
  const Y0 = faceRegY - 1;
776
+ const cylRadius =
777
+ opts.surfaceRadius !== undefined && Number.isFinite(opts.surfaceRadius) && opts.surfaceRadius > 0
778
+ ? opts.surfaceRadius
779
+ : turnSurfaceRadius(opts.turn, W);
482
780
 
483
781
  const boneOrder = new Map<string, number>(rig.bones.map((b, i) => [b.name, i]));
484
- for (const slotName of ['hair_front', 'hair_back']) {
782
+ for (const slotName of ['hair_front', 'hair_back', 'hairpin']) {
485
783
  const slotObj = rig.skins.default[slotName];
486
784
  const att = slotObj?.[slotName];
487
785
  if (att !== undefined && 'type' in att) {
@@ -531,8 +829,31 @@ export function applyFaceSurfaceDeform(
531
829
  };
532
830
 
533
831
  const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
832
+ const referencedGroups = new Set<string>();
833
+ for (const anim of [tiltAnim, turnAnim]) {
834
+ for (const tr of anim?.tracks ?? []) {
835
+ if (tr.group !== undefined) referencedGroups.add(tr.group);
836
+ }
837
+ }
838
+ const defaultGroups: Readonly<Record<string, readonly string[]>> = {
839
+ features: ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'pupil_r', 'pupil_l', 'eyelash_r', 'eyelash_l', 'eyebrow_r', 'eyebrow_l', 'nose', 'mouth'],
840
+ ears: ['ear_r', 'ear_l'],
841
+ bangs: ['bang_r0', 'bang_c0', 'bang_l0'].map((b) => (boneMap.has(`${b}_ctl`) ? `${b}_ctl` : b)),
842
+ topknot: ['bun_ctl', 'hairpin', 'headwear'],
843
+ };
844
+ for (const g of referencedGroups) {
845
+ if (motion.groups?.[g] === undefined && defaultGroups[g] !== undefined) {
846
+ const members = defaultGroups[g].filter((b) => boneMap.has(b));
847
+ if (members.length > 0) {
848
+ motion.groups ??= {};
849
+ motion.groups[g] = members;
850
+ }
851
+ }
852
+ }
853
+
534
854
  const evalDeriveKey = (boneName: string, k: SliderKey): number => {
535
855
  if (Array.isArray(k.v)) return k.v[0] ?? 0;
856
+ if (k.v !== undefined && typeof k.v === 'object') return k.v[boneName]?.[0] ?? 0;
536
857
  const d = k.derive;
537
858
  if (d === undefined) return 0;
538
859
  const b = boneMap.get(boneName);
@@ -546,27 +867,44 @@ export function applyFaceSurfaceDeform(
546
867
  };
547
868
 
548
869
  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',
870
+ bang_r0: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
871
+ bang_r1: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
872
+ bang_c0: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
873
+ bang_c1: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
874
+ bang_l0: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
875
+ bang_l1: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
876
+ bun: boneMap.has('bun_ctl') ? 'bun_ctl' : 'bun',
556
877
  };
557
878
 
558
879
  const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
559
- const driver = mapParent[boneName] ?? boneName;
880
+ const drivers = new Set<string>();
881
+ let cur: string | undefined = boneName;
882
+ const visited = new Set<string>();
883
+ while (cur !== undefined && !visited.has(cur) && cur !== 'head' && cur !== 'turn_face_head' && cur !== 'root') {
884
+ visited.add(cur);
885
+ if (!cur.startsWith('turn_')) {
886
+ drivers.add(mapParent[cur] ?? cur);
887
+ }
888
+ cur = boneMap.get(cur)?.parent;
889
+ }
890
+ if (drivers.size === 0) {
891
+ const stripped = boneName.replace(/^turn_[^_]+_/, '');
892
+ if (stripped !== 'head' && stripped !== 'root') {
893
+ drivers.add(mapParent[stripped] ?? stripped);
894
+ }
895
+ }
560
896
  let dx = 0;
561
897
  let dy = 0;
562
- for (const tr of tracks) {
563
- const inGroup = tr.group !== undefined && (motion.groups?.[tr.group]?.includes(driver) ?? false);
564
- if (tr.bone !== driver && !inGroup) continue;
565
- const key = tr.keys.find((k) => Math.abs(k.t - t) < 1e-6);
566
- if (key === undefined) continue;
567
- const val = evalDeriveKey(driver, key);
568
- if (tr.property === 'translatex') dx = val;
569
- if (tr.property === 'translatey') dy = val;
898
+ for (const driver of drivers) {
899
+ for (const tr of tracks) {
900
+ const inGroup = tr.group !== undefined && (motion.groups?.[tr.group]?.includes(driver) ?? false);
901
+ if (tr.bone !== driver && !inGroup) continue;
902
+ const key = tr.keys.find((k) => Math.abs(k.t - t) < 1e-6);
903
+ if (key === undefined) continue;
904
+ const val = evalDeriveKey(driver, key);
905
+ if (tr.property === 'translatex') dx += val;
906
+ if (tr.property === 'translatey') dy += val;
907
+ }
570
908
  }
571
909
  return [dx, dy];
572
910
  };
@@ -586,10 +924,14 @@ export function applyFaceSurfaceDeform(
586
924
  const faceVerts = getMeshSetup(faceMesh);
587
925
  const hairFrontAtt = rig.skins.default.hair_front?.hair_front;
588
926
  const hairBackAtt = rig.skins.default.hair_back?.hair_back;
927
+ const hairpinAtt = rig.skins.default.hairpin?.hairpin;
589
928
  const hairFrontVerts = hairFrontAtt !== undefined && 'type' in hairFrontAtt ? getMeshSetup(hairFrontAtt) : null;
590
929
  const hairBackVerts = hairBackAtt !== undefined && 'type' in hairBackAtt ? getMeshSetup(hairBackAtt) : null;
930
+ const hairpinVerts = hairpinAtt !== undefined && 'type' in hairpinAtt && !boneMap.has('hairpin') ? getMeshSetup(hairpinAtt) : null;
591
931
 
592
932
  const hyCrown = Y0 + (67 / 128) * H;
933
+ const hyCrownStart = Y0 + (24 / 128) * H;
934
+ const hyCrownZero = Y0 + (48 / 128) * H;
593
935
  const hyBunSpan = (85 / 128) * H;
594
936
  const hyBangBottom = Y0 - (68 / 128) * H;
595
937
  const hyBangSpan = (130 / 128) * H;
@@ -598,16 +940,37 @@ export function applyFaceSurfaceDeform(
598
940
  const hyEar = Y0 - (16 / 128) * H;
599
941
  const earRx = boneMap.get('ear_r')?.x ?? -51;
600
942
  const earLx = boneMap.get('ear_l')?.x ?? 47.5;
943
+ const featRx = (24 / 106) * W;
944
+ const featRy = (12 / 128) * H;
945
+ const featMembers = motion.groups?.features ?? [];
946
+
947
+ const minDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, fallback: number): number => {
948
+ if (typeof depth === 'number') return depth > 0 ? depth : fallback;
949
+ if (depth === undefined) return fallback;
950
+ const vals = Object.values(depth).filter((v): v is number => typeof v === 'number' && v > 0);
951
+ return vals.length > 0 ? Math.min(...vals) : fallback;
952
+ };
953
+
954
+ const memberDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, boneName: string, zRef: number): number => {
955
+ if (typeof depth === 'number') return depth;
956
+ return depth?.[boneName] ?? zRef;
957
+ };
601
958
 
602
959
  if (tiltAnim !== undefined) {
603
960
  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
- ];
961
+ const refTiltTrack = origTiltTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTiltTracks[0];
962
+ const isLegacyTilt =
963
+ tiltAnim.duration === 2.5 &&
964
+ Math.abs(refTiltTrack?.keys[0]?.derive?.degrees ?? 0) === 25;
965
+ const tiltKeys: Array<{ t: number; deg: number }> = isLegacyTilt
966
+ ? [
967
+ { t: 0, deg: -25 },
968
+ { t: 0.75, deg: -10 },
969
+ { t: 1.25, deg: 0 },
970
+ { t: 2.0, deg: 15 },
971
+ { t: 2.5, deg: 25 },
972
+ ]
973
+ : (refTiltTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
611
974
  const tiltDeform: DeformTimelineEntry[] = [
612
975
  {
613
976
  slot: 'face',
@@ -657,6 +1020,31 @@ export function applyFaceSurfaceDeform(
657
1020
  });
658
1021
  }
659
1022
  for (const tr of tiltAnim.tracks) {
1023
+ if (tr.group === 'features' && (tr.property === 'translatey' || tr.property === 'scaley') && featMembers.length > 0) {
1024
+ const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 34);
1025
+ tr.keys = tr.keys.map((k) => {
1026
+ const deg = k.derive?.degrees ?? 0;
1027
+ if (deg === 0) return { t: k.t, v: tr.property === 'scaley' ? [1] : [0] };
1028
+ const vMap: Record<string, number[]> = {};
1029
+ for (const bname of featMembers) {
1030
+ const b = boneMap.get(bname);
1031
+ const bx = b?.x ?? 0;
1032
+ const by = b?.y ?? Y0;
1033
+ const z = memberDepthOf(k.derive?.depth, bname, zRef);
1034
+ const dFrac = zRef > 0 ? z / zRef : 1;
1035
+ const uy = Math.max(-1, Math.min(1, (by - Y0) / (H / 2)));
1036
+ const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - uy * uy));
1037
+ const [, dyD] = evalPitchFaceSurface(bx, by - featRy, effDeg, W, H, X0, Y0, f);
1038
+ const [, dyU] = evalPitchFaceSurface(bx, by + featRy, effDeg, W, H, X0, Y0, f);
1039
+ if (tr.property === 'translatey') {
1040
+ vMap[bname] = [pyRound(0.5 * (dyD + dyU), 4)];
1041
+ } else {
1042
+ vMap[bname] = [pyRound(Math.max(0.2, 1 + (dyU - dyD) / (2 * featRy)), 4)];
1043
+ }
1044
+ }
1045
+ return { t: k.t, v: vMap };
1046
+ });
1047
+ }
660
1048
  if (tr.group === 'ears' && tr.property === 'translatey') {
661
1049
  tr.keys = tr.keys.map((k) => {
662
1050
  const deg = k.derive?.degrees ?? 0;
@@ -674,7 +1062,7 @@ export function applyFaceSurfaceDeform(
674
1062
  });
675
1063
  }
676
1064
  }
677
- const refTiltKeys = origTiltTracks[0]?.keys ?? [];
1065
+ const refTiltKeys = refTiltTrack?.keys ?? [];
678
1066
  for (const [earBone, earHx] of [
679
1067
  ['ear_r', earRx],
680
1068
  ['ear_l', earLx],
@@ -696,19 +1084,191 @@ export function applyFaceSurfaceDeform(
696
1084
 
697
1085
  if (turnAnim !== undefined) {
698
1086
  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
- ];
1087
+ const refTurnTrack = origTurnTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTurnTracks[0];
1088
+ const isLegacyTurn =
1089
+ turnAnim.duration === 3.0 &&
1090
+ Math.abs(refTurnTrack?.keys[0]?.derive?.degrees ?? 0) === 30;
1091
+ const turnKeys: Array<{ t: number; deg: number }> = isLegacyTurn
1092
+ ? [
1093
+ { t: 0, deg: -30 },
1094
+ { t: 0.75, deg: -15 },
1095
+ { t: 1.5, deg: 0 },
1096
+ { t: 2.25, deg: 15 },
1097
+ { t: 3.0, deg: 30 },
1098
+ ]
1099
+ : (refTurnTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
1100
+ const refTurnKeys = refTurnTrack?.keys ?? [];
1101
+
1102
+ // Issue #478: compute head-subtree turn fractions about the head-only pivot.
1103
+ const headParts = new Set<string>(DEFAULT_HEAD_TURN_PARTS);
1104
+
1105
+ const backSign = opts.flipBackLayerSignMutant === true ? -1 : 1;
1106
+ const rawBackFrac = opts.turn != null ? headTurnFraction(opts.turn, 'hair_back', headParts, opts.partAreas) : null;
1107
+ const rawHairpinFrac = opts.turn != null ? headTurnFraction(opts.turn, 'hairpin', headParts, opts.partAreas) : null;
1108
+ const hairpinRidesBack =
1109
+ hairpinAtt !== undefined &&
1110
+ 'type' in hairpinAtt &&
1111
+ hairpinAtt.weights.some((infls) => infls.some((w) => w.bone.includes('hairback_')));
1112
+
1113
+ const dxFaceCenterAt = (deg: number): number => FACE_DEFORM_MULT * deg * W * unmult(f.turn_mid_x);
1114
+ const dxBunAt = (deg: number): number =>
1115
+ backSign * FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_bun_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
1116
+ const dxBackAt = (deg: number): number => {
1117
+ if (rawBackFrac !== null) {
1118
+ return backSign * rawBackFrac * dxFaceCenterAt(deg);
1119
+ }
1120
+ return dxBunAt(deg);
1121
+ };
1122
+ const dxHairpinAt = (deg: number): number => {
1123
+ if (rawHairpinFrac !== null && rawHairpinFrac < 0) {
1124
+ return backSign * rawHairpinFrac * dxFaceCenterAt(deg);
1125
+ }
1126
+ if (hairpinRidesBack && rawBackFrac !== null) {
1127
+ const ratio = f.turn_bun_counter_x > 0 ? f.turn_hairpin_counter_x / f.turn_bun_counter_x : 0.75;
1128
+ return dxBackAt(deg) * ratio;
1129
+ }
1130
+ return backSign * FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_hairpin_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
1131
+ };
1132
+
1133
+ const earMembers = motion.groups?.ears ?? ['ear_r', 'ear_l'].filter((b) => boneMap.has(b));
1134
+ const hasAuthoredEarTurnTx = turnAnim.tracks.some(
1135
+ (tr) => tr.property === 'translatex' && (tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l'),
1136
+ );
1137
+ turnAnim.tracks = turnAnim.tracks.filter(
1138
+ (tr) => !(tr.group === 'topknot' && tr.property === 'translatex'),
1139
+ );
1140
+ for (const tr of turnAnim.tracks) {
1141
+ if (tr.group === 'features' && (tr.property === 'translatex' || tr.property === 'scalex') && featMembers.length > 0) {
1142
+ const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 36);
1143
+ tr.keys = tr.keys.map((k) => {
1144
+ const deg = k.derive?.degrees ?? 0;
1145
+ if (deg === 0) return { t: k.t, v: tr.property === 'scalex' ? [1] : [0] };
1146
+ const vMap: Record<string, number[]> = {};
1147
+ const s = Math.sign(deg);
1148
+ for (const bname of featMembers) {
1149
+ const b = boneMap.get(bname);
1150
+ const bx = b?.x ?? X0;
1151
+ const by = b?.y ?? Y0;
1152
+ const z = memberDepthOf(k.derive?.depth, bname, zRef);
1153
+ const dFrac = zRef > 0 ? z / zRef : 1;
1154
+ const ux = Math.max(-1, Math.min(1, (bx - X0) / (W / 2)));
1155
+ const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - ux * ux));
1156
+ const [dxL] = evalYawFaceSurface(bx - featRx, by, effDeg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
1157
+ const [dxR] = evalYawFaceSurface(bx + featRx, by, effDeg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
1158
+ const rawDelta = (dxR - dxL) / (2 * featRx);
1159
+ const isTrailing = s * (bx - X0) > featRx * 0.5;
1160
+ let sx: number;
1161
+ if (!useFlatYaw && isTrailing) {
1162
+ const cylL = cylinderYawDx(bx - X0 - featRx * 0.5, effDeg, cylRadius);
1163
+ const cylR = cylinderYawDx(bx - X0 + featRx * 0.5, effDeg, cylRadius);
1164
+ sx = 1 + Math.abs((cylR - cylL) / featRx);
1165
+ } else {
1166
+ sx = isTrailing ? 1 + Math.abs(rawDelta) : Math.max(0.2, 1 + rawDelta);
1167
+ }
1168
+ sx = pyRound(sx, 4);
1169
+ let dx: number;
1170
+ if (useFlatYaw) {
1171
+ dx = isTrailing
1172
+ ? (s > 0 ? dxR - featRx * (sx - 1) : dxL + featRx * (sx - 1))
1173
+ : 0.5 * (dxL + dxR);
1174
+ } else {
1175
+ const [dxCenter] = evalYawFaceSurface(bx, by, effDeg, W, H, X0, Y0, f, cylRadius, false);
1176
+ if (Math.abs(deg) > FACE_RATED_YAW_BAND) {
1177
+ const [plateLeftDx] = evalYawFaceSurface(X0 - W / 2, by, deg, W, H, X0, Y0, f, cylRadius, false);
1178
+ const [plateRightDx] = evalYawFaceSurface(X0 + W / 2, by, deg, W, H, X0, Y0, f, cylRadius, false);
1179
+ const plateMinX = X0 - W / 2 + plateLeftDx;
1180
+ const plateMaxX = X0 + W / 2 + plateRightDx;
1181
+ const minDx = Math.ceil((plateMinX - bx + featRx * sx - 1e-9) * 10000) / 10000;
1182
+ const maxDx = Math.floor((plateMaxX - bx - featRx * sx + 1e-9) * 10000) / 10000;
1183
+ dx = Math.max(minDx, Math.min(maxDx, dxCenter));
1184
+ } else {
1185
+ dx = dxCenter;
1186
+ }
1187
+ }
1188
+ if (tr.property === 'translatex') {
1189
+ vMap[bname] = [pyRound(dx, 4)];
1190
+ } else {
1191
+ vMap[bname] = [sx];
1192
+ }
1193
+ }
1194
+ return { t: k.t, v: vMap };
1195
+ });
1196
+ } else if (tr.group === 'ears' && tr.property === 'translatex' && earMembers.length > 0) {
1197
+ tr.keys = tr.keys.map((k) => {
1198
+ if (k.derive === undefined) return k;
1199
+ const deg = k.derive.degrees;
1200
+ if (deg === 0) return { t: k.t, v: [0] };
1201
+ const vMap: Record<string, number[]> = {};
1202
+ for (const bname of earMembers) {
1203
+ vMap[bname] = [evalDeriveKey(bname, k)];
1204
+ }
1205
+ return { t: k.t, v: vMap };
1206
+ });
1207
+ } else if ((tr.bone === 'ear_r' || tr.bone === 'ear_l') && tr.property === 'translatex') {
1208
+ const bname = tr.bone;
1209
+ tr.keys = tr.keys.map((k) => {
1210
+ if (k.derive === undefined) return k;
1211
+ const deg = k.derive.degrees;
1212
+ if (deg === 0) return { t: k.t, v: [0] };
1213
+ return { t: k.t, v: [evalDeriveKey(bname, k)] };
1214
+ });
1215
+ }
1216
+ }
1217
+ if (!hasAuthoredEarTurnTx) {
1218
+ for (const [earBone, earHx] of [
1219
+ ['ear_r', earRx],
1220
+ ['ear_l', earLx],
1221
+ ] as const) {
1222
+ if (!boneMap.has(earBone)) continue;
1223
+ turnAnim.tracks.push({
1224
+ bone: earBone,
1225
+ property: 'translatex',
1226
+ keys: refTurnKeys.map((k) => {
1227
+ const deg = k.derive?.degrees ?? 0;
1228
+ if (deg === 0) return { t: k.t, v: [0] };
1229
+ const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
1230
+ return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
1231
+ }),
1232
+ });
1233
+ }
1234
+ }
1235
+ for (const [tkBone, evalTkDx] of [
1236
+ ['bun_ctl', dxBunAt],
1237
+ ['hairpin', dxHairpinAt],
1238
+ ] as const) {
1239
+ if (!boneMap.has(tkBone)) continue;
1240
+ turnAnim.tracks.push({
1241
+ bone: tkBone,
1242
+ property: 'translatex',
1243
+ keys: refTurnKeys.map((k) => {
1244
+ const deg = k.derive?.degrees ?? 0;
1245
+ if (deg === 0) return { t: k.t, v: [0] };
1246
+ return { t: k.t, v: [pyRound(evalTkDx(deg), 4)] };
1247
+ }),
1248
+ });
1249
+ }
1250
+ // Issue #478: cloth chain roots parented to head on hair_back follow hair_back's counter-parallax fraction.
1251
+ for (const b of rig.bones) {
1252
+ if (b.parent === 'head' && b.name.startsWith('hairback_') && b.name.endsWith('_0')) {
1253
+ if (turnAnim.tracks.some((tr) => tr.bone === b.name && tr.property === 'translatex')) continue;
1254
+ turnAnim.tracks.push({
1255
+ bone: b.name,
1256
+ property: 'translatex',
1257
+ keys: refTurnKeys.map((k) => {
1258
+ const deg = k.derive?.degrees ?? 0;
1259
+ if (deg === 0) return { t: k.t, v: [0] };
1260
+ return { t: k.t, v: [pyRound(dxBackAt(deg), 4)] };
1261
+ }),
1262
+ });
1263
+ }
1264
+ }
1265
+
706
1266
  const turnDeform: DeformTimelineEntry[] = [
707
1267
  {
708
1268
  slot: 'face',
709
1269
  attachment: 'face',
710
1270
  keys: turnKeys.map(({ t, deg }) =>
711
- deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f)) },
1271
+ deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw)) },
712
1272
  ),
713
1273
  },
714
1274
  ];
@@ -722,11 +1282,18 @@ export function applyFaceSurfaceDeform(
722
1282
  : {
723
1283
  t,
724
1284
  vertices: expandToDeform(hairFrontVerts, (v) => {
725
- const [dxSurf, dySurf] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
726
- const [bx, by] = getVertexBoneDelta(v, origTurnTracks, t);
1285
+ const [dxSurf, dySurf] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
1286
+ const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
727
1287
  const uBang = Math.max(0, Math.min(1, (v.hy - hyTurnBangBottom) / hyTurnBangSpan));
728
1288
  const bangParallaxX = (deg / 15) * pyRound(f.turn_bang_parallax_x * 15 * W, 8) * uBang;
729
- return [bangParallaxX + dxSurf - bx, dySurf - by];
1289
+ const hyFrontCrownStart = Y0 + (36 / 128) * H;
1290
+ const hyFrontCrownZero = Y0 + (60 / 128) * H;
1291
+ const tCrown = Math.max(0, Math.min(1, (v.hy - hyFrontCrownStart) / (hyFrontCrownZero - hyFrontCrownStart)));
1292
+ const wCrown = 1 - tCrown * tCrown * (3 - 2 * tCrown);
1293
+ const absUx = Math.abs((v.hx - X0) / (W / 2));
1294
+ const tLat = Math.max(0, Math.min(1, (absUx - 0.96) / (1.28 - 0.96)));
1295
+ const wLat = 1 - tLat * tLat * (3 - 2 * tLat);
1296
+ return [(bangParallaxX + dxSurf) * wCrown * wLat - bx, dySurf * wCrown * wLat - by];
730
1297
  }),
731
1298
  },
732
1299
  ),
@@ -742,51 +1309,43 @@ export function applyFaceSurfaceDeform(
742
1309
  : {
743
1310
  t,
744
1311
  vertices: expandToDeform(hairBackVerts, (v) => {
745
- const [bx, by] = getVertexBoneDelta(v, origTurnTracks, t);
746
- const [dxCheek, dyCheek] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
1312
+ const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
1313
+ const [, dyCheek] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
747
1314
  const uBun = Math.max(0, Math.min(1, (v.hy - hyCrown) / hyBunSpan));
748
- const bunCounterX = FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_bun_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
749
- const targetDx = (1 - uBun) * (dxCheek * f.turn_hair_back_cheek_x) + uBun * bunCounterX;
750
- return [targetDx - bx, (1 - uBun) * dyCheek - by];
1315
+ const wBun =
1316
+ (v.boneWeights.bun ?? 0) +
1317
+ (v.boneWeights.bun_ctl ?? 0) +
1318
+ (v.boneWeights.turn_hair_back_bun ?? 0) +
1319
+ (v.boneWeights.turn_hair_back_bun_ctl ?? 0);
1320
+ const wTopBun = Math.max(uBun, wBun);
1321
+ const tCrown = Math.max(0, Math.min(1, (v.hy - hyCrownStart) / (hyCrownZero - hyCrownStart)));
1322
+ const wCrown = 1 - tCrown * tCrown * (3 - 2 * tCrown);
1323
+ const tCraniumY = Math.max(0, Math.min(1, (v.hy - (Y0 - (24 / 128) * H)) / ((32 / 128) * H)));
1324
+ const absUx = Math.abs((v.hx - X0) / (W / 2));
1325
+ const tLat = Math.max(0, Math.min(1, (absUx - 0.55) / (0.95 - 0.55)));
1326
+ const wLatCranium = 1 - tCraniumY * (tLat * tLat * (3 - 2 * tLat));
1327
+ const targetDx = (1 - wTopBun) * (dxBackAt(deg) * wCrown * wLatCranium) + wTopBun * dxBunAt(deg);
1328
+ return [targetDx - bx, (1 - wTopBun) * dyCheek * wCrown - by];
751
1329
  }),
752
1330
  },
753
1331
  ),
754
1332
  });
755
1333
  }
756
- const refTurnKeys = origTurnTracks[0]?.keys ?? [];
757
- turnAnim.tracks = turnAnim.tracks.filter(
758
- (tr) => !(tr.group === 'ears' && tr.property === 'translatex') && !(tr.group === 'topknot' && tr.property === 'translatex'),
759
- );
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) => {
769
- const deg = k.derive?.degrees ?? 0;
770
- 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
- });
775
- }
776
- for (const [tkBone, tkFrac] of [
777
- ['bun_ctl', f.turn_bun_counter_x],
778
- ['hairpin', f.turn_hairpin_counter_x],
779
- ] as const) {
780
- if (!boneMap.has(tkBone)) continue;
781
- turnAnim.tracks.push({
782
- bone: tkBone,
783
- property: 'translatex',
784
- keys: refTurnKeys.map((k) => {
785
- const deg = k.derive?.degrees ?? 0;
786
- if (deg === 0) return { t: k.t, v: [0] };
787
- const tkPx = pyRound((tkFrac * 15 * W) / FACE_DEFORM_MULT, 8);
788
- return { t: k.t, v: [pyRound(FACE_DEFORM_MULT * (deg / 15) * tkPx, 4)] };
789
- }),
1334
+ if (hairpinVerts !== null) {
1335
+ turnDeform.push({
1336
+ slot: 'hairpin',
1337
+ attachment: 'hairpin',
1338
+ keys: turnKeys.map(({ t, deg }) =>
1339
+ deg === 0
1340
+ ? { t }
1341
+ : {
1342
+ t,
1343
+ vertices: expandToDeform(hairpinVerts, (v) => {
1344
+ const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
1345
+ return [dxHairpinAt(deg) - bx, -by];
1346
+ }),
1347
+ },
1348
+ ),
790
1349
  });
791
1350
  }
792
1351
  turnAnim.deform = turnDeform;