@rig-c/parts 1.27.0 → 1.28.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/docs/AUTHORING.md CHANGED
@@ -2637,6 +2637,7 @@ See-through with another seed.
2637
2637
  | <a id="CONFIG_CLOTH_PARTS_UNIQUE"></a>`CONFIG_CLOTH_PARTS_UNIQUE` | a `cloth` recipe sets both `part` and `parts`, or its part list names the same part more than once (issue #437) | set at most one of `part` or `parts`, and list each part once in root-to-tip order |
2638
2638
  | <a id="CONFIG_CLOTH_SPRING_STABLE"></a>`CONFIG_CLOTH_SPRING_STABLE` | a `cloth` recipe's strength — declared, or the default 100 / 100 / 50 — whose spring is unstable at a damping the recipe runs at (`damping`, `alternate_damping`, or rig-c's 0.85 when `damping` is left out): k = strength·massInverse·step² at or past 2(1 + damping^(60·step)), step 1/60 and mass 1 (rig-c's defaults; the recipe sets neither). The detail gives k and the bound | a lower strength, or a higher damping |
2639
2639
  | <a id="CONFIG_TURN_BONE_FREE"></a>`CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
2640
+ | <a id="CONFIG_TURN_DEFORM_RANGE"></a>`CONFIG_TURN_DEFORM_RANGE` | an entry in `turn.deform` lies outside its allowed `[min, max]` interval in `FACE_DEFORM_RANGES` (`src/turn.ts`, issue #436); the detail gives the value found, the interval required and the default fraction | put that `turn.deform.<key>` inside the stated range, or remove it to take the Set B ×2.5 default |
2640
2641
  | <a id="CONFIG_PROPOSED_RIG_SCALE"></a>`CONFIG_PROPOSED_RIG_SCALE` | `proposed_rig_scale` (the `scale_rig_per_source` `propose` drafted the rig sections at, issue #354) differs from `assemble.rig_scale`: the coordinates in `bones`, `meshes`, `limbs` and `motion` are in that rig canvas's pixels and do not scale when `assemble.rig_scale` changes | re-run `assemble` and `propose` at the new `assemble.rig_scale`, or restore `assemble.rig_scale` to `proposed_rig_scale` |
2641
2642
 
2642
2643
  ### inputs
package/docs/STABILITY.md CHANGED
@@ -160,8 +160,9 @@ part name, `[i]` an entry of a list):
160
160
  - **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
161
161
  - **motion.blink.still.&lt;part&gt;** — required: `row`, `bone`
162
162
  - **motion.animations_from**, the file it names — required: `spec`, `animations`
163
- - **turn** — required: `pivot`, `depths`
163
+ - **turn** — required: `pivot`, `depths`; optional: `deform`
164
164
  - **turn.depths.&lt;part&gt;** — a See-through depth
165
+ - **turn.deform.&lt;key&gt;** — a per-degree fraction or coupling share (FACE_DEFORM_FRACTIONS, issue #436)
165
166
  - **scene** — no field (`{}`; a record or an annotation only)
166
167
  - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
167
168
  <!-- /stable:config -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.ts CHANGED
@@ -1045,6 +1045,21 @@ export interface LoopPhysics {
1045
1045
  in: 'rig.json' | 'motion.json';
1046
1046
  }
1047
1047
 
1048
+ /**
1049
+ * A throwaway copy of `rig` whose accompanying `motion` replaces `animations`
1050
+ * with a synthetic set (`still`, `blink_shut`, `stress ...`, `sweep ...`) omits
1051
+ * any `slider` constraint whose target animation is not in that set.
1052
+ */
1053
+ function withoutSliders(rig: Record<string, unknown>, animations: Record<string, unknown>): Record<string, unknown> {
1054
+ if (!Array.isArray(rig.constraints)) return rig;
1055
+ const kept = rig.constraints.filter((c) => !(isRecord(c) && c.type === 'slider' && typeof c.animation === 'string' && !(c.animation in animations)));
1056
+ if (kept.length === rig.constraints.length) return rig;
1057
+ const out: Record<string, unknown> = { ...rig };
1058
+ if (kept.length > 0) out.constraints = kept;
1059
+ else delete out.constraints;
1060
+ return out;
1061
+ }
1062
+
1048
1063
  /**
1049
1064
  * The rig spec and motion spec with every physics constraint left out, and
1050
1065
  * the constraints it left out; null when there is none, so a rig without one
@@ -3331,8 +3346,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3331
3346
  mkdirSync(stressDir, { recursive: true });
3332
3347
  const base = stripped === null ? inp.rig : stripped.rig;
3333
3348
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3334
- writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...base, images }));
3335
- writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnimations(plan) }));
3349
+ const stressAnims = stressAnimations(plan);
3350
+ writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...withoutSliders(base, stressAnims), images }));
3351
+ writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnims }));
3336
3352
  const sb = rigc(['build', '--rig', join(stressDir, 'rig.json'), '--motion', join(stressDir, 'motion.json'), '--out', join(stressDir, 'build'), '--profile', 'spine']);
3337
3353
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${STRESS_LINE}'s turned copy)`, sb, buildGateLines(sb.out)));
3338
3354
  const sr = rigc(['render', '--candidate', join(stressDir, 'build'), '--fps', String(IDLE_FPS), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', join(stressDir, 'render')]);
@@ -3380,7 +3396,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3380
3396
  const sets = new Map<string, { under: FrameSet; bare: FrameSet }>();
3381
3397
  if (copy.slots.length > 0) {
3382
3398
  for (const [file, image] of copy.images) writePng(join(imagesDir, file), image);
3383
- writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...copy.rig, images: imagesDir }));
3399
+ const sweepAnims = isRecord(copy.motion.animations) ? copy.motion.animations : {};
3400
+ writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...withoutSliders(copy.rig, sweepAnims), images: imagesDir }));
3384
3401
  writeFileSync(join(sweepDir, 'motion.json'), JSON.stringify(copy.motion));
3385
3402
  const sb = rigc(['build', '--rig', join(sweepDir, 'rig.json'), '--motion', join(sweepDir, 'motion.json'), '--out', join(sweepDir, 'build'), '--profile', 'spine']);
3386
3403
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${SWEEP_LINE}'s bent copy)`, sb, buildGateLines(sb.out)));
@@ -3423,7 +3440,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3423
3440
  try {
3424
3441
  mkdirSync(stillDir, { recursive: true });
3425
3442
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3426
- writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
3427
3443
  const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
3428
3444
  const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
3429
3445
  const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
@@ -3437,6 +3453,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3437
3453
  tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
3438
3454
  };
3439
3455
  }
3456
+ const stillRig = { ...withoutSliders(inp.rig, animations), images };
3457
+ writeFileSync(join(stillDir, 'rig.json'), JSON.stringify(stillRig));
3440
3458
  writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
3441
3459
  const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
3442
3460
  if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
@@ -3460,7 +3478,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3460
3478
  }
3461
3479
  if (inp.source !== null) {
3462
3480
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
3463
- writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig({ ...inp.rig, images })));
3481
+ writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig(stillRig)));
3464
3482
  const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
3465
3483
  if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
3466
3484
  const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
package/src/config.ts CHANGED
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
95
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
96
- import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
96
+ import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
97
97
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
98
98
 
99
99
  export type Point = [number, number];
@@ -2975,7 +2975,7 @@ function checkScene(c: Check, v: Json): void {
2975
2975
 
2976
2976
  function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
2977
2977
  const at = 'config.turn';
2978
- const o = c.object(at, v, ['pivot', 'depths'], []);
2978
+ const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
2979
2979
  if (o === null) return;
2980
2980
  const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
2981
2981
  const depths: Record<string, number> = {};
@@ -2996,6 +2996,35 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
2996
2996
  }
2997
2997
  }
2998
2998
  }
2999
+ if ('deform' in o) {
3000
+ const df = o.deform;
3001
+ if (!isPlainObject(df)) {
3002
+ c.fail('CONFIG_FIELD_TYPE', `${at}.deform`, `is ${show(df)}; an object of face deform key -> per-degree fraction is required`);
3003
+ } else {
3004
+ const known = Object.keys(FACE_DEFORM_FRACTIONS);
3005
+ for (const [key, val] of Object.entries(df)) {
3006
+ if (isRecordKey(key)) continue;
3007
+ if (key === 'note' || key.endsWith('_note')) {
3008
+ if (typeof val !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.deform.${key}`, `is ${show(val)}; an annotation is a string`);
3009
+ continue;
3010
+ }
3011
+ if (!Object.hasOwn(FACE_DEFORM_FRACTIONS, key)) {
3012
+ c.fail('CONFIG_KEY_KNOWN', `${at}.deform.${key}`, `is not a known face deform fraction; expected one of ${known.join(', ')} — ${DOORS}`);
3013
+ continue;
3014
+ }
3015
+ if (c.number(`${at}.deform.${key}`, val)) {
3016
+ const [lo, hi] = FACE_DEFORM_RANGES[key as FaceDeformKey];
3017
+ if (val < lo || val > hi) {
3018
+ c.fail(
3019
+ 'CONFIG_TURN_DEFORM_RANGE',
3020
+ `${at}.deform.${key}`,
3021
+ `is ${val}; a per-degree fraction in [${lo}, ${hi}] is required (default ${FACE_DEFORM_FRACTIONS[key as FaceDeformKey]})`,
3022
+ );
3023
+ }
3024
+ }
3025
+ }
3026
+ }
3027
+ }
2999
3028
  if (pivot !== null && depthsOk) {
3000
3029
  const nearest = turnNearest(depths);
3001
3030
  if (nearest !== null && !(pivot > nearest.depth)) {
package/src/coords.ts CHANGED
@@ -22,5 +22,5 @@
22
22
  * `flattenRig` goes back through `toWorld`. No rotation matrix is written out
23
23
  * by hand in this package.
24
24
  */
25
- export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from 'rig-c/src/transform.ts';
25
+ export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toBoneLocalVector, toWorld } from 'rig-c/src/transform.ts';
26
26
  export type { BoneTransform } from 'rig-c/src/transform.ts';
package/src/motion.ts CHANGED
@@ -145,16 +145,26 @@ export interface MotionSpec {
145
145
  animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
146
146
  }
147
147
 
148
+ const FACE_DEFORM_HOOK = new WeakMap<MotionSpec, (merged: MotionSpec & { animations: Record<string, unknown> }) => void>();
149
+
150
+ /** Attach a face-surface-deform hook to `motion` so {@link withAnimations} applies it when `turn`/`tilt` slider animations are merged (issue #436). */
151
+ export function bindFaceSurfaceDeform(motion: MotionSpec, hook: (merged: MotionSpec & { animations: Record<string, unknown> }) => void): void {
152
+ FACE_DEFORM_HOOK.set(motion, hook);
153
+ }
154
+
148
155
  /**
149
156
  * The motion `motion.json` holds: the idle, and after it every animation the
150
- * file `config.motion.animations_from` names, as read (issue #183) — nothing
151
- * added, nothing re-keyed, nothing judged; each is rigc's to accept at the
152
- * gate. With none, `motion` itself, so a config that names no file writes the
153
- * bytes it wrote before the field existed.
157
+ * file `config.motion.animations_from` names, as read (issue #183) — plus, when
158
+ * those animations hold `turn` or `tilt` slider `derive` keys, the Set B ×2.5
159
+ * face surface deform (issue #436). With none, `motion` itself, so a config
160
+ * that names no file writes the bytes it wrote before the field existed.
154
161
  */
155
162
  export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
156
163
  if (extra === null) return motion;
157
- return { ...motion, animations: { ...motion.animations, ...extra } };
164
+ const merged = { ...motion, animations: { ...motion.animations, ...extra } };
165
+ const hook = FACE_DEFORM_HOOK.get(motion);
166
+ if (hook !== undefined) hook(merged);
167
+ return merged;
158
168
  }
159
169
 
160
170
  /** One sine channel over an idle of `duration` seconds. */
package/src/rig.ts CHANGED
@@ -165,13 +165,13 @@ import {
165
165
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
167
  import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
- import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
168
+ import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
169
  import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
170
170
  import { EYE_GROUP_TAGS } from './propose.ts';
171
171
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
172
172
  import { pyRound } from './round.ts';
173
173
  import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
174
- import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
174
+ import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
175
175
  import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
176
176
 
177
177
  /** Transparent pixels added round every part image — the reference's `PAD`. */
@@ -1996,6 +1996,17 @@ export function buildRig(
1996
1996
  // issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
1997
1997
  const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
1998
1998
  if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
1999
+ const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
2000
+ const facePart = parts.parts.find((p) => p.name === 'face');
2001
+ bindFaceSurfaceDeform(motion, (merged) => {
2002
+ applyFaceSurfaceDeform(rig, merged, {
2003
+ imageWidth: faceImg?.width,
2004
+ imageHeight: faceImg?.height,
2005
+ opaqueWidth: facePart?.w,
2006
+ opaqueHeight: facePart?.h,
2007
+ overrides: cfg.turn?.deform,
2008
+ });
2009
+ });
1999
2010
  return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
2000
2011
  }
2001
2012
 
package/src/turn.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  * that the depth maps do not carry relief inside a layer, so a part moves as
29
29
  * one piece. Pure: no file, no clock.
30
30
  */
31
+ import { computeWorldTransforms, toBoneLocalVector, toWorld, type BoneTransform } from './coords.ts';
31
32
  import { pyRound } from './round.ts';
32
33
 
33
34
  /**
@@ -67,10 +68,108 @@ export const TURN_BONE = 'turn';
67
68
  /** The places a pivot and a fraction are written to. */
68
69
  export const TURN_PLACES = 6;
69
70
 
70
- /** `config.turn`: the pivot depth and each carried part's See-through `depth_median`. */
71
+ /**
72
+ * The Set B ×2.5 face surface deformation table (issue #436): per-degree
73
+ * fractions of the face plate's opaque width `W` and height `H` (and the
74
+ * dimensionless coupling shares of the hair, ear and topknot parts that sample
75
+ * the same head field), with the sheet's measurements on a `106×128` plate at
76
+ * `+15°` beside each entry.
77
+ */
78
+ export const FACE_DEFORM_FRACTIONS = {
79
+ /** Mid-face vertical shift per degree of pitch (`H/deg`; `-9.02 px` at `+15°` on `H = 128`). */
80
+ tilt_mid_y: -0.0047,
81
+ /** Lower-face chin vertical squash per degree of pitch (`H/deg`; `+5.04 px` squash, chin tip `-3.98 px` at `+15°` on `H = 128`). */
82
+ tilt_chin_squash_y: 0.002625,
83
+ /** Forehead/crown extra drop per degree of pitch (`H/deg`; crown `-11.28 px` at `+15°` on `H = 128`). */
84
+ tilt_crown_drop_y: -0.001175,
85
+ /** Forehead/jaw perspective horizontal taper per degree of pitch (`W/deg`; `±4.77 px` at `+15°` on `W = 106`). */
86
+ tilt_taper_x: 0.003,
87
+ /** Mid-face and chin horizontal shift per degree of yaw (`W/deg`; `-9.54 px` at `+15°` on `W = 106`). */
88
+ turn_mid_x: -0.006,
89
+ /** Far (leading) cheek inward compression per degree of yaw (`W/deg`; far cheek `+3.75 px` inward at `+15°` on `W = 106`). */
90
+ turn_lead_compress_x: 0.00835,
91
+ /** Near (trailing) cheek shift beyond mid-face per degree of yaw (`W/deg`; near cheek `-10.50 px` at `+15°` on `W = 106`, total narrowing `-14.25 px` = `-13.4% W`). */
92
+ turn_trail_shift_x: -0.0006,
93
+ /** Cheek-arch vertical lift/drop per degree of yaw (`H/deg`; `±1.20 px` at `+15°` on `H = 128`). */
94
+ turn_cheek_arch_y: 0.000625,
95
+ /** Forehead bang base vertical travel per degree of pitch (`H/deg`; `-3.00 px` at `+15°` on `H = 128`). */
96
+ tilt_bang_base_y: -0.0015625,
97
+ /** Share of the pitch vertical surface field carried by `hair_front` (`0.95×` of `dySurf`). */
98
+ tilt_hair_front_y: 0.95,
99
+ /** Share of the pitch vertical surface field carried by lower `hair_back` (`0.85×` of `dySurf`). */
100
+ tilt_hair_back_lower_y: 0.85,
101
+ /** Share of the pitch vertical surface field carried by upper `hair_back` (`0.45×` of `dySurf`). */
102
+ tilt_hair_back_bun_y: 0.45,
103
+ /** Share of the pitch horizontal taper carried at the top of `hair_back` (`0.60×` of `dxSurf`). */
104
+ tilt_hair_back_bun_x: 0.6,
105
+ /** Share of the pitch vertical surface field carried by `ears` (`0.55×` of `dySurf`, `-4.31 px` at `+15°`). */
106
+ tilt_ear_y: 0.55,
107
+ /** Share of the crown pitch vertical field carried by `topknot` (`0.50×` of crown `dySurf`, `-5.64 px` at `+15°`). */
108
+ tilt_topknot_y: 0.5,
109
+ /** Forehead bang forward parallax per degree of yaw (`W/deg`; `-3.50 px` at `+15°` on `W = 106`). */
110
+ 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
+ /** Rear bun counter-parallax per degree of yaw (`W/deg`; `+8.00 px` at `+15°` on `W = 106`). */
114
+ turn_bun_counter_x: 8.0 / 15 / 106,
115
+ /** Rear hairpin counter-parallax per degree of yaw (`W/deg`; `+6.00 px` at `+15°` on `W = 106`). */
116
+ turn_hairpin_counter_x: 6.0 / 15 / 106,
117
+ /** Share of the cheek-edge yaw horizontal field carried by `ear_r` and `ear_l` (`0.85×` of `dxCheekEdge`, `-8.92 px` / `+3.14 px` at `+15°`). */
118
+ turn_ear_x: 0.85,
119
+ } as const;
120
+
121
+ export type FaceDeformKey = keyof typeof FACE_DEFORM_FRACTIONS;
122
+ export type FaceDeformFractions = Record<FaceDeformKey, number>;
123
+
124
+ /**
125
+ * Allowed `[min, max]` range for each key of {@link FACE_DEFORM_FRACTIONS}; an
126
+ * override in `config.turn.deform` outside its stated interval is refused by
127
+ * name (`CONFIG_TURN_DEFORM_RANGE`).
128
+ */
129
+ export const FACE_DEFORM_RANGES: Readonly<Record<FaceDeformKey, readonly [number, number]>> = {
130
+ tilt_mid_y: [-0.02, 0],
131
+ tilt_chin_squash_y: [0, 0.015],
132
+ tilt_crown_drop_y: [-0.01, 0],
133
+ tilt_taper_x: [0, 0.015],
134
+ turn_mid_x: [-0.02, 0],
135
+ turn_lead_compress_x: [0, 0.025],
136
+ turn_trail_shift_x: [-0.01, 0.01],
137
+ turn_cheek_arch_y: [0, 0.01],
138
+ tilt_bang_base_y: [-0.01, 0],
139
+ tilt_hair_front_y: [0, 1.5],
140
+ tilt_hair_back_lower_y: [0, 1.5],
141
+ tilt_hair_back_bun_y: [0, 1.5],
142
+ tilt_hair_back_bun_x: [0, 1.5],
143
+ tilt_ear_y: [0, 1.5],
144
+ tilt_topknot_y: [0, 1.5],
145
+ turn_bang_parallax_x: [-0.015, 0],
146
+ turn_hair_back_cheek_x: [0, 1.5],
147
+ turn_bun_counter_x: [0, 0.02],
148
+ turn_hairpin_counter_x: [0, 0.02],
149
+ turn_ear_x: [0, 1.5],
150
+ };
151
+
152
+ /** Uneven 5×5 grid columns and rows for the `face` plate mesh (issue #436). */
153
+ export const FACE_DEFORM_GRID_US: readonly number[] = [0, 0.18, 0.5, 0.82, 1];
154
+ export const FACE_DEFORM_GRID_VS: readonly number[] = [0, 0.22, 0.5, 0.78, 1];
155
+
156
+ /** Resolve {@link FACE_DEFORM_FRACTIONS} with optional recipe overrides. */
157
+ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDeformFractions>> | null): FaceDeformFractions {
158
+ const out: FaceDeformFractions = { ...FACE_DEFORM_FRACTIONS };
159
+ if (overrides !== undefined && overrides !== null) {
160
+ for (const k of Object.keys(FACE_DEFORM_FRACTIONS) as FaceDeformKey[]) {
161
+ const v = overrides[k];
162
+ if (typeof v === 'number') out[k] = v;
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, and optional face surface deform overrides. */
71
169
  export interface TurnSpec {
72
170
  pivot: number;
73
171
  depths: Record<string, number>;
172
+ deform?: Partial<FaceDeformFractions>;
74
173
  }
75
174
 
76
175
  /** One carried part: its depth and the fraction of the control's offset it travels. */
@@ -128,3 +227,568 @@ export function turnTwin(part: string, bone: string): string {
128
227
  export function turnConstraintName(part: string): string {
129
228
  return `${TURN_BONE}_${part}`;
130
229
  }
230
+
231
+ const FACE_DEFORM_MULT = 2.5;
232
+ const unmult = (v: number): number => pyRound(v / FACE_DEFORM_MULT, 8);
233
+
234
+ /**
235
+ * Evaluate the pitch (`tilt`) surface deformation field `(dx, dy)` in `head`
236
+ * space at `(hx, hy)` for `deg` degrees of pitch.
237
+ */
238
+ export function evalPitchFaceSurface(
239
+ hx: number,
240
+ hy: number,
241
+ deg: number,
242
+ W: number,
243
+ H: number,
244
+ X0: number,
245
+ Y0: number,
246
+ f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
247
+ ): [number, number] {
248
+ if (deg === 0) return [0, 0];
249
+ const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
250
+ const uy = Math.max(-1.35, Math.min(1.35, (hy - Y0) / (H / 2)));
251
+ const uChin = Math.min(1.2, Math.max(0, -uy));
252
+ const uCrown = Math.min(1.2, Math.max(0, uy));
253
+ const fracYPerDeg = unmult(f.tilt_mid_y) + unmult(f.tilt_chin_squash_y) * Math.pow(uChin, 1.25) + unmult(f.tilt_crown_drop_y) * Math.pow(uCrown, 1.25);
254
+ const fracXPerDeg = unmult(f.tilt_taper_x) * ux * Math.max(-1.1, Math.min(1.1, uy));
255
+ return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, FACE_DEFORM_MULT * deg * H * fracYPerDeg];
256
+ }
257
+
258
+ /**
259
+ * Evaluate the yaw (`turn`) surface deformation field `(dx, dy)` in `head`
260
+ * space at `(hx, hy)` for `deg` degrees of yaw.
261
+ */
262
+ export function evalYawFaceSurface(
263
+ hx: number,
264
+ hy: number,
265
+ deg: number,
266
+ W: number,
267
+ H: number,
268
+ X0: number,
269
+ Y0: number,
270
+ f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
271
+ ): [number, number] {
272
+ if (deg === 0) return [0, 0];
273
+ const s = Math.sign(deg);
274
+ const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
275
+ 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
+ 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];
281
+ }
282
+
283
+ export interface FaceSurfaceDeformOptions {
284
+ /** Padded `face.png` width in rig px (defaults to the mesh width or 114). */
285
+ imageWidth?: number;
286
+ /** Padded `face.png` height in rig px (defaults to the mesh height or 136). */
287
+ imageHeight?: number;
288
+ /** Opaque face plate width `W` in rig px (defaults to `imageWidth - 8`). */
289
+ opaqueWidth?: number;
290
+ /** Opaque face plate height `H` in rig px (defaults to `imageHeight - 8`). */
291
+ opaqueHeight?: number;
292
+ /** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
293
+ overrides?: Readonly<Partial<FaceDeformFractions>> | null;
294
+ }
295
+
296
+ interface DeformWeight {
297
+ bone: string;
298
+ x: number;
299
+ y: number;
300
+ weight: number;
301
+ }
302
+
303
+ interface DeformMeshAttachment {
304
+ type: 'mesh';
305
+ image: string;
306
+ width: number;
307
+ height: number;
308
+ uvs: number[];
309
+ triangles: number[];
310
+ hull: number;
311
+ weights: DeformWeight[][];
312
+ }
313
+
314
+ interface DeformRegionAttachment {
315
+ image: string;
316
+ x: number;
317
+ y: number;
318
+ rotation?: number;
319
+ }
320
+
321
+ interface DeriveSpec {
322
+ kind: 'yaw' | 'pitch';
323
+ degrees: number;
324
+ about?: number;
325
+ depth?: number | Readonly<Record<string, number>>;
326
+ carried?: number | Readonly<Record<string, number>>;
327
+ }
328
+
329
+ interface SliderKey {
330
+ t: number;
331
+ v?: number[];
332
+ derive?: DeriveSpec;
333
+ }
334
+
335
+ interface SliderTrack {
336
+ bone?: string;
337
+ group?: string;
338
+ property: string;
339
+ keys: SliderKey[];
340
+ }
341
+
342
+ interface DeformTimelineEntry {
343
+ slot: string;
344
+ attachment: string;
345
+ keys: Array<{ t: number; vertices?: number[] }>;
346
+ }
347
+
348
+ interface SliderAnimation {
349
+ duration: number;
350
+ tracks: SliderTrack[];
351
+ deform?: DeformTimelineEntry[];
352
+ }
353
+
354
+ function buildFaceGrid(width: number, height: number): { points: Array<[number, number]>; uvs: number[]; triangles: number[]; hull: number } {
355
+ const us = FACE_DEFORM_GRID_US;
356
+ const vs = FACE_DEFORM_GRID_VS;
357
+ const cols = us.length - 1;
358
+ const rows = vs.length - 1;
359
+ const ring: Array<[number, number]> = [];
360
+ for (let c = 0; c <= cols; c++) ring.push([c, 0]);
361
+ for (let r = 1; r <= rows; r++) ring.push([cols, r]);
362
+ for (let c = cols - 1; c >= 0; c--) ring.push([c, rows]);
363
+ for (let r = rows - 1; r >= 1; r--) ring.push([0, r]);
364
+ const interior: Array<[number, number]> = [];
365
+ for (let r = 1; r < rows; r++) for (let c = 1; c < cols; c++) interior.push([c, r]);
366
+ const all = [...ring, ...interior];
367
+ const idx = new Map<string, number>();
368
+ all.forEach(([c, r], i) => idx.set(`${c},${r}`, i));
369
+ const points: Array<[number, number]> = [];
370
+ const uvs: number[] = [];
371
+ for (const [c, r] of all) {
372
+ const u = us[c];
373
+ const v = vs[r];
374
+ points.push([u * width, v * height]);
375
+ uvs.push(pyRound(u, 6), pyRound(v, 6));
376
+ }
377
+ const triangles: number[] = [];
378
+ for (let r = 0; r < rows; r++) {
379
+ for (let c = 0; c < cols; c++) {
380
+ const tl = idx.get(`${c},${r}`) as number;
381
+ const tr = idx.get(`${c + 1},${r}`) as number;
382
+ const bl = idx.get(`${c},${r + 1}`) as number;
383
+ const br = idx.get(`${c + 1},${r + 1}`) as number;
384
+ triangles.push(tl, bl, br, tl, br, tr);
385
+ }
386
+ }
387
+ return { points, uvs, triangles, hull: ring.length };
388
+ }
389
+
390
+ /**
391
+ * Promote the `face` region on `head` to a 5×5 grid mesh and populate the
392
+ * `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.
396
+ */
397
+ export function applyFaceSurfaceDeform(
398
+ rig: {
399
+ bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
400
+ skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
401
+ invariants?: Record<string, unknown>;
402
+ },
403
+ motion: {
404
+ groups?: Record<string, string[]>;
405
+ animations: Record<string, unknown>;
406
+ },
407
+ options?: FaceSurfaceDeformOptions | Readonly<Partial<FaceDeformFractions>> | null,
408
+ ): void {
409
+ const rawTilt = motion.animations.tilt as SliderAnimation | undefined;
410
+ const rawTurn = motion.animations.turn as SliderAnimation | undefined;
411
+ if (rawTilt === undefined && rawTurn === undefined) return;
412
+ const hasDerive =
413
+ (rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
414
+ (rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
415
+ 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
+
421
+ const isOpts =
422
+ options !== undefined &&
423
+ options !== null &&
424
+ ('imageWidth' in options || 'imageHeight' in options || 'opaqueWidth' in options || 'opaqueHeight' in options || 'overrides' in options);
425
+ const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
426
+ const f = resolveFaceDeformFractions(opts.overrides);
427
+
428
+ const faceSlot = rig.skins.default.face;
429
+ if (faceSlot === undefined || faceSlot.face === undefined) return;
430
+ const faceAtt = faceSlot.face;
431
+
432
+ let faceW = opts.imageWidth ?? 114;
433
+ let faceH = opts.imageHeight ?? 136;
434
+ let faceRegX = -1;
435
+ let faceRegY = 49;
436
+
437
+ if (!('type' in faceAtt)) {
438
+ faceRegX = faceAtt.x;
439
+ faceRegY = faceAtt.y;
440
+ const grid = buildFaceGrid(faceW, faceH);
441
+ const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
442
+ {
443
+ bone: 'head',
444
+ x: pyRound(faceRegX - faceW / 2 + px, 6),
445
+ y: pyRound(faceRegY + faceH / 2 - py, 6),
446
+ weight: 1,
447
+ },
448
+ ]);
449
+ faceSlot.face = {
450
+ type: 'mesh',
451
+ image: faceAtt.image,
452
+ width: faceW,
453
+ height: faceH,
454
+ uvs: grid.uvs,
455
+ triangles: grid.triangles,
456
+ hull: grid.hull,
457
+ weights: faceWeights,
458
+ };
459
+ if (rig.invariants !== undefined) {
460
+ if (typeof rig.invariants.meshSlots === 'number') rig.invariants.meshSlots += 1;
461
+ if (typeof rig.invariants.meshTriangles === 'number') rig.invariants.meshTriangles += grid.triangles.length / 3;
462
+ }
463
+ } else {
464
+ faceW = faceAtt.width;
465
+ faceH = faceAtt.height;
466
+ let sumX = 0;
467
+ let sumY = 0;
468
+ for (const w of faceAtt.weights) {
469
+ sumX += w[0]?.x ?? 0;
470
+ sumY += w[0]?.y ?? 0;
471
+ }
472
+ if (faceAtt.weights.length > 0) {
473
+ faceRegX = Math.round(sumX / faceAtt.weights.length);
474
+ faceRegY = Math.round(sumY / faceAtt.weights.length);
475
+ }
476
+ }
477
+
478
+ const W = opts.opaqueWidth ?? faceW - 8;
479
+ const H = opts.opaqueHeight ?? faceH - 8;
480
+ const X0 = faceRegX;
481
+ const Y0 = faceRegY - 1;
482
+
483
+ const boneOrder = new Map<string, number>(rig.bones.map((b, i) => [b.name, i]));
484
+ for (const slotName of ['hair_front', 'hair_back']) {
485
+ const slotObj = rig.skins.default[slotName];
486
+ const att = slotObj?.[slotName];
487
+ if (att !== undefined && 'type' in att) {
488
+ for (const infls of att.weights) {
489
+ infls.sort((a, b) => (boneOrder.get(a.bone) ?? 0) - (boneOrder.get(b.bone) ?? 0));
490
+ }
491
+ }
492
+ }
493
+
494
+ const tf = computeWorldTransforms(rig.bones);
495
+ const headTf = tf.get('head') as BoneTransform | undefined;
496
+ if (headTf === undefined) return;
497
+
498
+ interface VertexSetup {
499
+ hx: number;
500
+ hy: number;
501
+ boneWeights: Record<string, number>;
502
+ infls: DeformWeight[];
503
+ }
504
+
505
+ const getMeshSetup = (att: DeformMeshAttachment): VertexSetup[] =>
506
+ att.weights.map((infls) => {
507
+ let wx = 0;
508
+ let wy = 0;
509
+ const boneWeights: Record<string, number> = {};
510
+ for (const inf of infls) {
511
+ const m = tf.get(inf.bone) as BoneTransform;
512
+ const [x, y] = toWorld(m, inf.x, inf.y);
513
+ wx += x * inf.weight;
514
+ wy += y * inf.weight;
515
+ boneWeights[inf.bone] = (boneWeights[inf.bone] ?? 0) + inf.weight;
516
+ }
517
+ return { hx: wx - headTf.worldX, hy: wy - headTf.worldY, boneWeights, infls };
518
+ });
519
+
520
+ const expandToDeform = (verts: readonly VertexSetup[], evalVertex: (v: VertexSetup) => [number, number]): number[] => {
521
+ const out: number[] = [];
522
+ for (const v of verts) {
523
+ const [dx, dy] = evalVertex(v);
524
+ for (const inf of v.infls) {
525
+ const m = tf.get(inf.bone) as BoneTransform;
526
+ const [bx, by] = toBoneLocalVector(m, dx, dy);
527
+ out.push(pyRound(bx, 4), pyRound(by, 4));
528
+ }
529
+ }
530
+ return out;
531
+ };
532
+
533
+ const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
534
+ const evalDeriveKey = (boneName: string, k: SliderKey): number => {
535
+ if (Array.isArray(k.v)) return k.v[0] ?? 0;
536
+ const d = k.derive;
537
+ if (d === undefined) return 0;
538
+ const b = boneMap.get(boneName);
539
+ const coord = d.kind === 'yaw' ? (b?.x ?? 0) : (b?.y ?? 0);
540
+ const rad = (d.degrees * Math.PI) / 180;
541
+ const depth = typeof d.depth === 'number' ? d.depth : (d.depth?.[boneName] ?? 0);
542
+ const carried = typeof d.carried === 'number' ? d.carried : (d.carried?.[boneName] ?? 0);
543
+ const val = (coord - (d.about ?? 0)) * (Math.cos(rad) - 1) - (depth - carried) * Math.sin(rad);
544
+ const r = Math.round(val * 1e6) / 1e6;
545
+ return r === 0 ? 0 : r;
546
+ };
547
+
548
+ 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',
556
+ };
557
+
558
+ const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
559
+ const driver = mapParent[boneName] ?? boneName;
560
+ let dx = 0;
561
+ 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;
570
+ }
571
+ return [dx, dy];
572
+ };
573
+
574
+ const getVertexBoneDelta = (v: VertexSetup, tracks: readonly SliderTrack[], t: number): [number, number] => {
575
+ let bx = 0;
576
+ let by = 0;
577
+ for (const [bname, w] of Object.entries(v.boneWeights)) {
578
+ const [dx, dy] = getBoneDeltaAtTime(tracks, t, bname);
579
+ bx += dx * w;
580
+ by += dy * w;
581
+ }
582
+ return [bx, by];
583
+ };
584
+
585
+ const faceMesh = faceSlot.face as DeformMeshAttachment;
586
+ const faceVerts = getMeshSetup(faceMesh);
587
+ const hairFrontAtt = rig.skins.default.hair_front?.hair_front;
588
+ const hairBackAtt = rig.skins.default.hair_back?.hair_back;
589
+ const hairFrontVerts = hairFrontAtt !== undefined && 'type' in hairFrontAtt ? getMeshSetup(hairFrontAtt) : null;
590
+ const hairBackVerts = hairBackAtt !== undefined && 'type' in hairBackAtt ? getMeshSetup(hairBackAtt) : null;
591
+
592
+ const hyCrown = Y0 + (67 / 128) * H;
593
+ const hyBunSpan = (85 / 128) * H;
594
+ const hyBangBottom = Y0 - (68 / 128) * H;
595
+ const hyBangSpan = (130 / 128) * H;
596
+ const hyTurnBangBottom = Y0 - (28 / 128) * H;
597
+ const hyTurnBangSpan = (80 / 128) * H;
598
+ const hyEar = Y0 - (16 / 128) * H;
599
+ const earRx = boneMap.get('ear_r')?.x ?? -51;
600
+ const earLx = boneMap.get('ear_l')?.x ?? 47.5;
601
+
602
+ if (tiltAnim !== undefined) {
603
+ 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
+ ];
611
+ const tiltDeform: DeformTimelineEntry[] = [
612
+ {
613
+ slot: 'face',
614
+ attachment: 'face',
615
+ keys: tiltKeys.map(({ t, deg }) =>
616
+ deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalPitchFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f)) },
617
+ ),
618
+ },
619
+ ];
620
+ if (hairFrontVerts !== null) {
621
+ tiltDeform.push({
622
+ slot: 'hair_front',
623
+ attachment: 'hair_front',
624
+ keys: tiltKeys.map(({ t, deg }) =>
625
+ deg === 0
626
+ ? { t }
627
+ : {
628
+ t,
629
+ vertices: expandToDeform(hairFrontVerts, (v) => {
630
+ const [dxSurf, dySurf] = evalPitchFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
631
+ const [bx, by] = getVertexBoneDelta(v, origTiltTracks, t);
632
+ const baseBangDy = (deg / 15) * (f.tilt_bang_base_y * 15 * H) * Math.max(0, Math.min(1, (v.hy - hyBangBottom) / hyBangSpan));
633
+ return [dxSurf - bx, baseBangDy + dySurf * f.tilt_hair_front_y - by];
634
+ }),
635
+ },
636
+ ),
637
+ });
638
+ }
639
+ if (hairBackVerts !== null) {
640
+ tiltDeform.push({
641
+ slot: 'hair_back',
642
+ attachment: 'hair_back',
643
+ keys: tiltKeys.map(({ t, deg }) =>
644
+ deg === 0
645
+ ? { t }
646
+ : {
647
+ t,
648
+ vertices: expandToDeform(hairBackVerts, (v) => {
649
+ const [bx, by] = getVertexBoneDelta(v, origTiltTracks, t);
650
+ const [dxSurf, dySurf] = evalPitchFaceSurface(v.hx, Math.min(hyCrown, v.hy), deg, W, H, X0, Y0, f);
651
+ const uBun = Math.max(0, Math.min(1, (v.hy - hyCrown) / hyBunSpan));
652
+ const targetDy = (1 - uBun) * (dySurf * f.tilt_hair_back_lower_y) + uBun * (dySurf * f.tilt_hair_back_bun_y);
653
+ return [dxSurf * (1 - pyRound(1 - f.tilt_hair_back_bun_x, 8) * uBun) - bx, targetDy - by];
654
+ }),
655
+ },
656
+ ),
657
+ });
658
+ }
659
+ for (const tr of tiltAnim.tracks) {
660
+ if (tr.group === 'ears' && tr.property === 'translatey') {
661
+ tr.keys = tr.keys.map((k) => {
662
+ const deg = k.derive?.degrees ?? 0;
663
+ if (deg === 0) return { t: k.t, v: [0] };
664
+ const [, dyEar] = evalPitchFaceSurface(0, hyEar, deg, W, H, X0, Y0, f);
665
+ return { t: k.t, v: [pyRound(dyEar * f.tilt_ear_y, 4)] };
666
+ });
667
+ }
668
+ if (tr.group === 'topknot' && tr.property === 'translatey') {
669
+ tr.keys = tr.keys.map((k) => {
670
+ const deg = k.derive?.degrees ?? 0;
671
+ if (deg === 0) return { t: k.t, v: [0] };
672
+ const [, dyCrown] = evalPitchFaceSurface(0, hyCrown, deg, W, H, X0, Y0, f);
673
+ return { t: k.t, v: [pyRound(dyCrown * f.tilt_topknot_y, 4)] };
674
+ });
675
+ }
676
+ }
677
+ const refTiltKeys = origTiltTracks[0]?.keys ?? [];
678
+ for (const [earBone, earHx] of [
679
+ ['ear_r', earRx],
680
+ ['ear_l', earLx],
681
+ ] as const) {
682
+ if (!boneMap.has(earBone)) continue;
683
+ tiltAnim.tracks.push({
684
+ bone: earBone,
685
+ property: 'translatex',
686
+ keys: refTiltKeys.map((k) => {
687
+ const deg = k.derive?.degrees ?? 0;
688
+ if (deg === 0) return { t: k.t, v: [0] };
689
+ const [dxEar] = evalPitchFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
690
+ return { t: k.t, v: [pyRound(dxEar, 4)] };
691
+ }),
692
+ });
693
+ }
694
+ tiltAnim.deform = tiltDeform;
695
+ }
696
+
697
+ if (turnAnim !== undefined) {
698
+ 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
+ ];
706
+ const turnDeform: DeformTimelineEntry[] = [
707
+ {
708
+ slot: 'face',
709
+ attachment: 'face',
710
+ 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)) },
712
+ ),
713
+ },
714
+ ];
715
+ if (hairFrontVerts !== null) {
716
+ turnDeform.push({
717
+ slot: 'hair_front',
718
+ attachment: 'hair_front',
719
+ keys: turnKeys.map(({ t, deg }) =>
720
+ deg === 0
721
+ ? { t }
722
+ : {
723
+ t,
724
+ 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);
727
+ const uBang = Math.max(0, Math.min(1, (v.hy - hyTurnBangBottom) / hyTurnBangSpan));
728
+ const bangParallaxX = (deg / 15) * pyRound(f.turn_bang_parallax_x * 15 * W, 8) * uBang;
729
+ return [bangParallaxX + dxSurf - bx, dySurf - by];
730
+ }),
731
+ },
732
+ ),
733
+ });
734
+ }
735
+ if (hairBackVerts !== null) {
736
+ turnDeform.push({
737
+ slot: 'hair_back',
738
+ attachment: 'hair_back',
739
+ keys: turnKeys.map(({ t, deg }) =>
740
+ deg === 0
741
+ ? { t }
742
+ : {
743
+ t,
744
+ 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);
747
+ 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];
751
+ }),
752
+ },
753
+ ),
754
+ });
755
+ }
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
+ }),
790
+ });
791
+ }
792
+ turnAnim.deform = turnDeform;
793
+ }
794
+ }