PageSourceSearch

https://kashiko-hub.vercel.app/kashiko-konbini/kashiko/actor.js

js kashiko-hub.vercel.app collected 2026-10-03 07:11:19 UTC 89,747 bytes, 1,266 lines download raw bytes

1// かし子モデル工房 — the "actor" layer on top of the 秘密の森 Kashiko GLB.
2//
3// What it adds to the plain clips:
4//   * locomotion that blends idle → walk → run → sprint by speed, with the three
5//     gaits phase-locked and played at the rate the feet actually travel
6//     (stride speeds are measured from the clips at load, so feet don't skate)
7//   * upper-body actions (wave while walking) via split upper / lower tracks,
8//     and full-body actions that cross-fade in and out of locomotion
9//   * procedural acting on top: lean into turns and acceleration, breathing,
10//     head / neck look-at, idle glances
11//   * secondary motion: hair morphs, the two hair tails (TailRoot bones) and
12//     the smock hem react to her movement through damped springs
13//   * the natural blink from かし子とチャンバラ (motions.js), plus eye states
14//     (closed for sleep, squeezed for effort)
15const T = window.THREE;
16const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
17const damp = (a, b, k, dt) => a + (b - a) * (1 - Math.exp(-k * dt));
18
19// Bones that belong to the upper body. Everything else (hips, legs, the lowest
20// spine, the hem morph) stays with the legs.
21const UPPER = /^(Spine01|Spine02|neck|Head|head_end|headfront|(Left|Right)(Shoulder|Arm|ForeArm|Hand|Thumb|Index|Middle|Ring|Pinky)\d?|TailRoot_[LR]|(Left|Right)SleeveForearm|char1|EyeIndependent_[LR])\./;
22
23function splitClip(clip) {
24  const up = [], low = [];
25  for (const tr of clip.tracks) (UPPER.test(tr.name) ? up : low).push(tr);
26  return {upper: new T.AnimationClip(clip.name + '#upper', clip.duration, up), lower: new T.AnimationClip(clip.name + '#lower', clip.duration, low)};
27}
28
29export const ACTIONS = {
30  // key: clip, how it layers, label, playback speed, whether she can keep walking
31  wave:      {clip: 'greeting',    layer: 'upper', label: '手をふる',       speed: 1.1, cut: [0.2, 3.2], happy: true, arms: 'wave'},
32  clap:      {clip: 'clap_dance',  layer: 'full',  label: '手拍子ダンス',  speed: 1},
33  dance:     {clip: 'dance',       layer: 'full',  label: 'ダンス',        speed: 1},
34  disco:     {clip: 'disco',       layer: 'full',  label: 'ディスコ',      speed: 1},
35  twist:     {clip: 'twist',       layer: 'full',  label: 'ツイスト',      speed: 1},
36  turn:      {clip: 'turn_step',   layer: 'full',  label: 'くるっとステップ', speed: 1},
37  punch:     {clip: 'punch_combo', layer: 'full',  label: 'パンチ連打',    speed: 1.15, effort: true, tuck: .85},
38  kick:      {clip: 'front_kick',  layer: 'full',  label: '前げり',        speed: 1.15, effort: true, arms: 'guard'},
39  dodge:     {clip: 'dodge',       layer: 'full',  label: 'よける',        speed: 1.2, tuck: .85},
40  bow:       {clip: 'bow',         layer: 'full',  label: 'おじぎ',        speed: 1, arms: 'hang'},
41  victory:   {clip: 'victory',     layer: 'full',  label: 'やったー',      speed: 1, happy: true},
42  hop:       {clip: 'hop',         layer: 'full',  label: 'ぴょん',        speed: 1},
43  swing:     {clip: 'swing',       layer: 'full',  label: 'スイング',      speed: 1, cut: [1.15, 3.13], fade: .3},
44  sweep:     {clip: 'rogue_sweep', layer: 'full',  label: 'なぎはらい',    speed: .9, effort: true},
45  thrust:    {clip: 'rogue_thrust',layer: 'full',  label: 'つき',          speed: .9, effort: true},
46  smash:     {clip: 'rogue_smash', layer: 'full',  label: 'たたきつけ',    speed: .9, effort: true},
47  throw:     {clip: 'rogue_throw', layer: 'upper', label: 'ひろう・なげる', speed: .9},
48  cast:      {clip: 'rogue_cast',  layer: 'upper', label: 'まほう',        speed: .8},
49  hook:      {clip: 'rogue_hook',  layer: 'full',  label: 'フック',        speed: .9, effort: true, tuck: .6},
50  sleep:     {clip: 'sleep_prone', layer: 'full',  label: 'おひるね',      speed: 1, loop: true, eyes: 0, unplant: 50, fade: .6, kneel: 1},
51  // Snappy moves cut from the long show clips. Pressing again while one plays
52  // carries on into the next segment of the same clip (no pose jump).
53  jab1:      {clip: 'punch_combo', layer: 'upper', label: 'パンチ', speed: 1.7, cut: [0.5, 1.45], effort: true, fade: .14, tuck: .85},
54  jab2:      {clip: 'punch_combo', layer: 'upper', label: 'パンチ', speed: 1.7, cut: [1.45, 2.3], effort: true, fade: .14, tuck: .85},
55  jab3:      {clip: 'punch_combo', layer: 'upper', label: 'パンチ', speed: 1.7, cut: [2.3, 3.15], effort: true, fade: .14, tuck: .85},
56  jab4:      {clip: 'punch_combo', layer: 'upper', label: 'パンチ', speed: 1.7, cut: [3.15, 4.05], effort: true, fade: .14, tuck: .85},
57  kick1:     {clip: 'front_kick',  layer: 'full',  label: 'キック', speed: 1.6, cut: [0.55, 2.1], effort: true, fade: .16, arms: 'guard'},
58  kick2:     {clip: 'front_kick',  layer: 'full',  label: 'キック', speed: 1.6, cut: [2.1, 4.3], effort: true, fade: .16, arms: 'guard'},
59  weave:     {clip: 'dodge',       layer: 'full',  label: 'ウィービング', speed: 1.7, cut: [0.25, 2.4], fade: .16, tuck: .85},
60};
61const GAITS = ['walk', 'run', 'sprint'];
62
63export function createActor(gltf, {height = 1.43, eyeStates = null} = {}) {
64  // eyeStates: {texture, region: [x0, y0, w, h]} — a 4-row flipbook (open / half / closed / smile)
65  // painted over the baked front view of the eyes; when given, blinks and eye
66  // expressions swap pictures instead of folding the painted eye.
67  const hero = gltf.scene;
68  const box = new T.Box3().setFromObject(hero);
69  const s = height / box.getSize(new T.Vector3()).y;
70  hero.scale.setScalar(s);
71  hero.position.set(-(box.min.x + box.max.x) * s / 2, -box.min.y * s, -(box.min.z + box.max.z) * s / 2);
72  const root = new T.Group(); root.add(hero);
73  hero.traverse(n => { if (n.isMesh) { n.castShadow = true; n.frustumCulled = false; } });
74  const bone = n => hero.getObjectByName(n);
75  const B = {hips: bone('Hips'), spine: bone('Spine'), spine1: bone('Spine01'), spine2: bone('Spine02'), neck: bone('neck'), head: bone('Head'),
76    tailL: bone('TailRoot_L'), tailR: bone('TailRoot_R'), footL: bone('LeftFoot'), footR: bone('RightFoot'), armL: bone('LeftArm'), armR: bone('RightArm'), foreL: bone('LeftForeArm'), foreR: bone('RightForeArm'),
77    handL: bone('LeftHand'), handR: bone('RightHand'), clavL: bone('LeftShoulder'), clavR: bone('RightShoulder'), midL: bone('LeftMiddle1'), midR: bone('RightMiddle1')};
78  // NB the spine chain runs Hips → Spine02 → Spine01 → Spine (chest) → neck / shoulders
79  const clips = {}; for (const c of gltf.animations) clips[c.name] = c;
80  // The looping clips were exported with the loop pose doubled at both ends
81  // (A,A,B…Y,A,A): every cycle the whole body stood still for 2 frames
82  // (67 ms — 13% of a sprint stride) and then jumped. Keep one A at each end.
83  for (const n of ['walk', 'run', 'sprint']) {
84    const c = clips[n]; if (!c || window.__seamQA === 0) continue;
85    const m = Math.max(...c.tracks.map(t => t.times.length)); if (m < 5) continue;
86    const full = c.tracks.filter(t => t.times.length === m), ref = full[0];
87    const dup = t => { const s = t.getValueSize(), v = t.values, eq = (i, j) => { for (let k = 0; k < s; k++) if (Math.abs(v[i * s + k] - v[j * s + k]) > 1e-4) return false; return true; }; return eq(0, 1) && eq(m - 2, m - 1); };
88    if (full.filter(dup).length < full.length * .6) continue;
89    const dt = ref.times[1] - ref.times[0];
90    // resample every track on k1…k(m-2) of the original timeline
91    const N = m - 2, times = new Float32Array(N);
92    for (let j = 0; j < N; j++) times[j] = j * dt;
93    for (const t of c.tracks) {
94      const s = t.getValueSize(), it = t.createInterpolant(), vals = new Float32Array(N * s);
95      for (let j = 0; j < N; j++) { const r = it.evaluate(ref.times[0] + (j + 1) * dt); for (let k = 0; k < s; k++) vals[j * s + k] = r[k]; }
96      t.times = times.slice(); t.values = vals;
97      if (t instanceof T.QuaternionKeyframeTrack) for (let j = 0; j < N; j++) { const q = new T.Quaternion().fromArray(vals, j * 4).normalize(); q.toArray(vals, j * 4); }
98    }
99    c.duration = (N - 1) * dt;
100  }
101  // The legs also jump from standing still to full speed within one key
102  // (knee 3° → 21° per 33 ms at toe-off): soften the gait legs with a light
103  // [1 2 1] filter round the loop (the last key repeats the first).
104  for (const n of ['walk', 'run', 'sprint']) {
105    const c = clips[n]; if (!c || window.__smoothQA === 0) continue;
106    for (const t of c.tracks) {
107      if (!(t instanceof T.QuaternionKeyframeTrack) || !/(UpLeg|Leg|Foot|Hips)\.quaternion$/.test(t.name)) continue;
108      const N = t.times.length - 1; if (N < 4) continue;       // unique keys in the cycle
109      const q = [...Array(N)].map((_, j) => new T.Quaternion().fromArray(t.values, j * 4));
110      const a = new T.Quaternion(), out = new Float32Array(t.values.length);
111      for (let j = 0; j < N; j++) {
112        a.copy(q[(j + N - 1) % N]).slerp(q[(j + 1) % N], .5).slerp(q[j], .5);
113        a.toArray(out, j * 4);
114      }
115      for (let k = 0; k < 4; k++) out[N * 4 + k] = out[k];
116      t.values = out;
117    }
118  }
119  const mixer = new T.AnimationMixer(hero);
120
121  // ---------- locomotion (split so an upper-body action can replace the top) ----------
122  const loco = {};
123  for (const n of ['idle', ...GAITS]) {
124    const {upper, lower} = splitClip(clips[n]);
125    loco[n] = {up: mixer.clipAction(upper), low: mixer.clipAction(lower), w: n === 'idle' ? 1 : 0, dur: clips[n].duration};
126    for (const a of [loco[n].up, loco[n].low]) { a.play(); a.weight = loco[n].w; if (n !== 'idle') a.timeScale = 0; }
127  }
128
129  // Natural ground speed of each gait: how fast the planted foot slides back
130  // in the in-place clip (model units → metres through the fit scale).
131  const gaitSpeed = {walk: .33, run: 1, sprint: 1.4};
132  // How fast she actually travels in each gait. Her chibi legs have a tiny
133  // stride, so the clips are played faster (up to MAX_RATE) to keep up.
134  const moveSpeed = {walk: .7, run: 1.9, sprint: 2.8}, MAX_RATE = 2.0;
135  // Stride lengthening: played at clip stride, her little steps meant ~225
136  // steps/min walking (young children: ~150-170). The feet are carried
137  // further fore and aft by leg IK instead, so the same speed needs fewer steps.
138  const STRIDE = {walk: 1.3, run: 1.35, sprint: 1.3};
139  function measureGaits() {
140    const m2 = new T.AnimationMixer(hero), p = new T.Vector3(), inv = new T.Matrix4();
141    for (const n of GAITS) {
142      const a = m2.clipAction(clips[n]); a.play(); const dur = clips[n].duration, N = 48, pts = [];
143      for (let i = 0; i <= N; i++) {
144        m2.setTime(dur * i / N); hero.updateMatrixWorld(true); inv.copy(root.matrixWorld).invert();
145        for (const f of [B.footL, B.footR]) { f.getWorldPosition(p).applyMatrix4(inv); pts.push({i, f, y: p.y, z: p.z}); }
146      }
147      let sum = 0, cnt = 0;
148      for (const f of [B.footL, B.footR]) {
149        const seq = pts.filter(q => q.f === f), minY = Math.min(...seq.map(q => q.y));
150        for (let i = 1; i < seq.length; i++) if (seq[i].y < minY + .02 && seq[i - 1].y < minY + .02 && seq[i].z < seq[i - 1].z) { sum += (seq[i - 1].z - seq[i].z) / (dur / N); cnt++; }
151      }
152      if (cnt > 2 && sum / cnt > .2) gaitSpeed[n] = sum / cnt;
153      if (n === 'walk') ankleY = Math.min(...pts.map(q => q.y));
154      a.stop(); m2.uncacheClip(clips[n]);
155    }
156    m2.stopAllAction();
157  }
158  // Store the fit transform so a measurement pass can't drift the model.
159  const heroPos = hero.position.clone();
160  let ankleY = .07;
161  try { measureGaits(); } catch (e) { console.warn('gait measure', e); }
162  hero.position.copy(heroPos);
163
164  // ---------- actions ----------
165  const act = {cur: null, def: null, t: 0, len: 0, w: 0, fade: .22, a: null, key: null, queue: null, onEnd: null};
166  const actionCache = {};
167  function actionFor(def) {
168    const k = def.clip + '|' + def.layer;
169    if (!actionCache[k]) {
170      const c = def.layer === 'upper' ? splitClip(clips[def.clip]).upper : clips[def.clip];
171      const a = mixer.clipAction(c); a.setLoop(T.LoopRepeat, Infinity); a.weight = 0; actionCache[k] = a;
172    }
173    return actionCache[k];
174  }
175  function play(key, {onEnd} = {}) {
176    const def = ACTIONS[key]; if (!def || !clips[def.clip]) return 0;
177    // chain: the same clip, starting where the current segment ends → just keep playing
178    if (busy() && act.def.clip === def.clip && act.def.layer === def.layer && def.cut && Math.abs(def.cut[0] - act.cut[1]) < .08) {
179      act.len += (def.cut[1] - def.cut[0]) / (def.speed || 1); act.cut[1] = def.cut[1]; act.def = def; act.key = key;
180      if (def.effort) face.effort = .6;
181      return act.len - act.t;
182    }
183    // hand the old action over to a short fade-out instead of cutting it
184    if (act.a && act.a !== actionFor(def)) {
185      if (act.prev) act.prev.a.stop();
186      act.prev = act.w > .02 ? {a: act.a, w: act.w, full: act.def?.layer === 'full'} : (act.a.stop(), null);
187    }
188    const a = actionFor(def); a.reset(); a.play(); a.timeScale = def.speed || 1;
189    const [c0, c1] = def.cut || [0, clips[def.clip].duration];
190    a.time = c0; act.cut = [c0, c1];
191    act.fade = def.fade || .22;
192    act.a = a; act.def = def; act.key = key; act.t = 0; act.len = def.loop ? Infinity : (c1 - c0) / (def.speed || 1); act.onEnd = onEnd;
193    act.w = act.prev ? 0 : act.w > 0 && act.prevLayer === def.layer ? act.w : 0; act.prevLayer = def.layer;
194    if (def.effort) face.effort = .6;
195    if (def.happy) face.happy = Math.min(act.len, 2.2);
196    return act.len;
197  }
198  function stop() { if (act.def) act.len = Math.min(act.len, act.t + act.fade); }
199  const busy = () => !!act.def && act.t < act.len;
200  const blocksMove = () => busy() && act.def.layer === 'full';
201
202  // ---------- eyes: natural blink (from かし子とチャンバラ motions.js) ----------
203  const face = {override: null, effort: 0, happy: 0};
204  const lidMorphs = [];
205  hero.traverse(n => { const dict = n.morphTargetDictionary; if (!dict) return;
206    for (const key of ['Blink_L', 'Blink_R', 'LidArc_L', 'LidArc_R']) if (dict[key] !== undefined) lidMorphs.push([n, dict[key]]); });
207  const eyes = {L: {open: {value: 1}}, R: {open: {value: 1}}}, faceParts = [];
208  hero.traverse(n => { if (!n.isMesh) return; const m = /^EyeOriginal_([LR])_([123])$/.exec(n.name); if (m) { faceParts.push(n); if (m[2] === '2') eyes[m[1]].eye = n; } });
209  const EYE_GLSL = `
210    float eyeW(float x, vec4 a, vec4 b, float soft) {
211      float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014;
212      return 1.0 - smoothstep(lim - soft, lim + b.w, abs(dx));
213    }
214    float eyeTop(float x, vec4 a, vec4 b) {
215      float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014;
216      float t = clamp(abs(dx) / (lim * 1.12), 0.0, 1.0);
217      return a.z + (a.w - a.z) * (.35 + .65 * pow(1.0 - t * t * t, 1.0 / 3.0)) + .006 * t;
218    }
219    // Where the lids meet. Normally the lower lid line (a.z); smiling lifts its
220    // middle into an arch, so a closed eye reads as a happy "^".
221    float eyePivot(float x, vec4 a, vec4 b) {
222      float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014;
223      float t = clamp(abs(dx) / lim, 0.0, 1.0);
224      return a.z + (a.w - a.z) * .42 * uEyeSmile * (1.0 - t * t);
225    }
226    // Lowest point that folds in: the lower lid, or the eye's bottom edge when
227    // the whole eye must close into one line (sleep, smile).
228    float eyeLow(vec4 a, vec4 b) { return mix(a.z, b.x, max(uEyeSmile, uEyeShut)); }
229    float eyeLid(float x, float y, vec4 a, vec4 b, float open) {
230      float w = eyeW(x, a, b, 0.0), top = eyeTop(x, a, b), lo = eyeLow(a, b);
231      if (w <= 0.0 || y <= lo || y > top) return y;
232      float piv = eyePivot(x, a, b);
233      // a full close (sleep / smile) is painted by eyeDraw, so don't fold
234      open = mix(open, 1.0, max(uEyeSmile, uEyeShut) * (1.0 - smoothstep(.15, .5, open)));
235      return y - (y - piv) * (1.0 - open) * w;
236    }
237    float eyeRemnant(vec2 p, vec4 a, vec4 b, float open) {
238      float w = eyeW(p.x, a, b, .004), top = eyeTop(p.x, a, b);
239      if (w <= 0.0 || p.y <= top - .002 || p.y > top + .015) return 0.0;
240      return w * smoothstep(0.0, .35, 1.0 - open) * smoothstep(top - .002, top + .001, p.y) * (1.0 - smoothstep(top + .009, top + .015, p.y));
241    }
242    float eyeBand(vec2 p, vec4 a, vec4 b, float open) {
243      float w = eyeW(p.x, a, b, 0.0);
244      if (w <= 0.0 || open > .995) return 0.0;
245      float top = eyeTop(p.x, a, b), piv = eyePivot(p.x, a, b), lo = eyeLow(a, b);
246      float movedTop = top - (top - piv) * (1.0 - open) * w, movedLow = lo + (piv - lo) * (1.0 - open) * w;
247      return ((p.y > movedTop && p.y <= top + .0005) || (p.y < movedLow - .0015 && p.y >= lo - .0005)) ? 1.0 : 0.0;
248    }
249    // Fully closed eyes (sleep / smile) are painted, not folded: the eye area
250    // becomes lid skin and a lash-coloured line is drawn across it — a soft
251    // downward arc when sleeping, a "^" arch when smiling.
252    vec2 eyeDraw(vec2 p, vec4 a, vec4 b, float open) {
253      float closing = (1.0 - smoothstep(.15, .5, open)) * max(uEyeSmile, uEyeShut);
254      if (closing <= 0.0) return vec2(0.0);
255      float dx = (p.x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z + .012 : a.y + .04;
256      if (abs(dx) > lim || abs(p.x) > .166) return vec2(0.0);   // never over the side locks (storybook hair beyond |x| .17)
257      float ts = clamp(abs(dx) / lim, 0.0, 1.0);
258      float top = eyeTop(p.x, a, b) + .005 + .02 * ts * ts, lo = eyeLow(a, b) - .012;   // reach the painted rim at the corners too
259      if (p.y < lo || p.y > min(top, .912)) return vec2(0.0);
260      float h = a.w - a.z, t = clamp(abs(dx) / (a.y + .004), 0.0, 1.0);
261      float bow = uEyeSmile * .5 - uEyeShut * (1.0 - uEyeSmile) * .16;
262      float lineY = a.z + h * (.28 - uEyeSmile * .08) + h * bow * (1.0 - t * t);
263      float thick = .0045 * (1.0 - pow(t, 3.0)) + .0008;
264      float ink = (1.0 - smoothstep(thick, thick + .0014, abs(p.y - lineY))) * (1.0 - smoothstep(.9, 1.0, t));
265      return vec2(closing, ink * closing);
266    }
267    float eyeGaze(vec2 p, vec4 a, vec4 b) {
268      vec2 c = vec2(a.x, (b.x + a.w) * .5), r = vec2(a.y * .98, (a.w - b.x) * .5);
269      float d = length((p - c) / r);
270      return 1.0 - smoothstep(.35, 1.0, d);
271    }
272    // Gaze: vertices inside each eye slide toward where she looks, most at the
273    // centre and not at all at the rim, so the painted iris glides inside the
274    // white (the texture moves with the vertices, whatever its UV layout).
275    // Wide (surprised) eyes: the eye swells from its centre, fading out a bit
276    // beyond its rim so the skin around stays joined.
277    vec2 eyeBulge(vec2 p, vec4 a, vec4 b) {
278      vec2 c = vec2(a.x, (b.x + a.w) * .5), r = vec2(a.y * 1.05, (a.w - b.x) * .52);
279      float d = length((p - c) / r);
280      return (p - c) * uEyeWide * .14 * (1.0 - smoothstep(.85, 1.45, d));
281    }
282    vec2 eyeGazeShift(vec2 p) {
283      return uEyeLook * max(eyeGaze(p, uEyeLA, uEyeLB), eyeGaze(p, uEyeRA, uEyeRB)) + eyeBulge(p, uEyeLA, uEyeLB) + eyeBulge(p, uEyeRA, uEyeRB);
284    }
285    float eyeLids(vec2 p) {
286      p += eyeGazeShift(p);
287      float y = eyeLid(p.x, p.y, uEyeLA, uEyeLB, uEyeOpenL);
288      return eyeLid(p.x, y, uEyeRA, uEyeRB, uEyeOpenR);
289    }`;
290  const eyeU = {uEyeLA: {value: new T.Vector4()}, uEyeLB: {value: new T.Vector4()}, uEyeRA: {value: new T.Vector4()}, uEyeRB: {value: new T.Vector4()},
291    uEyeOpenL: eyes.L.open, uEyeOpenR: eyes.R.open, uEyeSkin: {value: new T.Color('#fbdcc8')}, uEyeSmile: {value: 0}, uEyeShut: {value: 0}, uEyeLook: {value: new T.Vector2()}, uEyeWide: {value: 0}, uBlush: {value: 0},
292    uEyeTex: {value: eyeStates?.texture || null}, uEyeRegion: {value: new T.Vector4(...(eyeStates?.region || [0, 0, 1, 1]))}, uEyeState: {value: 0}, uEyeOn: {value: eyeStates ? 1 : 0}};
293  const decl = 'uniform vec4 uEyeLA; uniform vec4 uEyeLB; uniform vec4 uEyeRA; uniform vec4 uEyeRB; uniform float uEyeOpenL; uniform float uEyeOpenR; uniform vec3 uEyeSkin; uniform float uEyeSmile; uniform float uEyeShut; uniform vec2 uEyeLook; uniform float uEyeWide; uniform float uBlush; uniform sampler2D uEyeTex; uniform vec4 uEyeRegion; uniform float uEyeState; uniform float uEyeOn;\n'
294    + 'varying vec2 vEyeOrig; varying float vEyeDefY;\n';
295  function sampleSkin(mesh, point) {
296    try {
297      const pos = mesh.geometry.attributes.position, uv = mesh.geometry.attributes.uv, img = mesh.material.map?.image;
298      if (!uv || !img) return;
299      let best = -1, bd = Infinity;
300      for (let i = 0; i < pos.count; i += 3) { const d = (pos.getX(i) - point.x) ** 2 + (pos.getY(i) - point.y) ** 2; if (d < bd) { bd = d; best = i; } }
301      const c = document.createElement('canvas'); c.width = c.height = 1; const g = c.getContext('2d');
302      const w = img.width, h = img.height, u = uv.getX(best), v = uv.getY(best);
303      g.drawImage(img, Math.floor(u * w) - 2, Math.floor(v * h) - 2, 5, 5, 0, 0, 1, 1);
304      const [r, gg, bb] = g.getImageData(0, 0, 1, 1).data;
305      eyeU.uEyeSkin.value.setRGB(r / 255, gg / 255, bb / 255, T.SRGBColorSpace);
306    } catch (_) { /* keep the default skin tone */ }
307  }
308  // The storybook look paints a BOX of hair colour on the side of the head
309  // (behind the side locks); its bottom-front corner showed as a square block
310  // of hair in front of the ear. Without it the side of the face is plain
311  // skin up to the side locks, and the front and 3/4 views don't change.
312  // (a sloping "sideburn" edge instead still read as a hard wedge)
313  function softenHairBacking() {
314    const seen = new Set();
315    hero.traverse(n => {
316      if (!n.isMesh) return;
317      for (const m of Array.isArray(n.material) ? n.material : [n.material]) {
318        if (!m || seen.has(m) || !m.onBeforeCompile || m.userData.hairline) continue;
319        seen.add(m); m.userData.hairline = true;
320        const inner = m.onBeforeCompile, key = m.customProgramCacheKey;
321        m.onBeforeCompile = function (shader, renderer) {
322          inner.call(this, shader, renderer);
323          // keep the backing behind the fringe (without it skin showed inside
324          // the side locks) but end it softly above the ear instead of a box corner
325          if (window.__hbQA !== 1) shader.fragmentShader = shader.fragmentShader.replace(/float hairBacking=[^;]*;/, `float hbE=length(vec2((paperPosition.y-0.95)/(0.95-HB_LO),(paperPosition.z-0.075)/0.068));
326              float hairBacking=smoothstep(0.143,0.155,abs(paperPosition.x))
327                *(1.0-smoothstep(0.224,0.239,abs(paperPosition.x)))
328                *(1.0-smoothstep(0.965,1.0,hbE))
329                *(1.0-smoothstep(0.940,0.958,paperPosition.y));`.replace('HB_LO', (window.__hbLo ?? .76).toFixed(3)));
330        };
331        m.customProgramCacheKey = function () { return (key ? key.call(this) : '') + '-backing' + (window.__hbQA === 1 ? '-qa' : '') + (window.__hbLo ?? .76); };
332        m.needsUpdate = true;
333      }
334    });
335  }
336  // Call after the storybook materials are attached (it wraps them).
337  function setupBlink() {
338    setupPacifier(); setupBag(); softenHairBacking();
339    for (const [side, eye] of Object.entries(eyes)) {
340      if (!eye.eye) continue;
341      eye.eye.geometry.computeBoundingBox();
342      const b = eye.eye.geometry.boundingBox, h = b.max.y - b.min.y, cx = (b.min.x + b.max.x) / 2;
343      eyeU['uEye' + side + 'A'].value.set(cx, (b.max.x - b.min.x) / 2, b.min.y + h * .16, Math.min(b.max.y + h * .2, .909));
344      const soft = .006, outer = Math.max(0, Math.min(h * .22, .168 - (Math.abs(cx) + (b.max.x - b.min.x) / 2) - soft));
345      eyeU['uEye' + side + 'B'].value.set(b.min.y - h * .04, Math.sign(cx) || 1, outer, soft);
346      if (side === 'L') { const patch = faceParts.find(n => n.name === 'EyeOriginal_L_1'); if (patch) sampleSkin(patch, {x: cx - Math.sign(cx) * ((b.max.x - b.min.x) / 2 + h * .35), y: (b.min.y + b.max.y) / 2 + h * .15}); }
347    }
348    const cache = new Map();
349    for (const n of faceParts) {
350      const base = n.material;
351      if (!cache.has(base)) {
352        const mat = base.clone(), inner = base.onBeforeCompile;
353        mat.onBeforeCompile = (shader, renderer) => {
354          if (inner) inner.call(mat, shader, renderer);
355          // The storybook look repaints hair-coloured paint as fringe everywhere
356          // except a RECTANGULAR "eye window"; its corners showed as a square
357          // edge in the hair beside the eyes. Use a soft ellipse round each eye.
358          shader.fragmentShader = shader.fragmentShader.replace(/float eyeWindow=step\(0\.035[^;]*;/,
359            `float eyeWindow=1.0-smoothstep(.92,1.0,min(length((paperPosition.xy-vec2(-.105,.858))/vec2(.071,.08)),length((paperPosition.xy-vec2(.105,.858))/vec2(.071,.08))));`);
360          Object.assign(shader.uniforms, eyeU);
361          shader.vertexShader = decl + EYE_GLSL + '\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
362            vEyeOrig = transformed.xy;
363            transformed.x += eyeGazeShift(vEyeOrig).x;
364            transformed.y = eyeLids(vEyeOrig);
365            vEyeDefY = transformed.y;`);
366          shader.fragmentShader = decl + EYE_GLSL + '\n' + shader.fragmentShader.replace('#include <map_fragment>', `#include <map_fragment>
367            vec2 eyeHere = vec2(vEyeOrig.x, vEyeDefY);
368            if (max(eyeBand(eyeHere, uEyeLA, uEyeLB, uEyeOpenL), eyeBand(eyeHere, uEyeRA, uEyeRB, uEyeOpenR)) > 0.5
369                || abs(eyeLids(vEyeOrig) - vEyeDefY) > .004)
370              diffuseColor.rgb = uEyeSkin;
371            else {
372              float eyeRem = max(eyeRemnant(vEyeOrig, uEyeLA, uEyeLB, uEyeOpenL), eyeRemnant(vEyeOrig, uEyeRA, uEyeRB, uEyeOpenR));
373              vec3 eyeC = diffuseColor.rgb; float eyeMx = max(eyeC.r, max(eyeC.g, eyeC.b)), eyeSat = (eyeMx - min(eyeC.r, min(eyeC.g, eyeC.b))) / max(eyeMx, 1e-4);
374              float eyeDark = max(1.0 - smoothstep(.2, .6, dot(eyeC, vec3(.299, .587, .114))), 1.0 - smoothstep(.3, .5, eyeSat / .55));
375              diffuseColor.rgb = mix(diffuseColor.rgb, uEyeSkin, eyeRem * eyeDark);
376            }
377            // cheeks flush (shy, happy, out of breath): a soft pink under each eye
378            if (uBlush > .001) {
379              for (int i = 0; i < 2; i++) {
380                vec4 ea = i == 0 ? uEyeLA : uEyeRA; vec4 eb = i == 0 ? uEyeLB : uEyeRB;
381                vec2 c = vec2(ea.x + eb.y * ea.y * .35, eb.x - .004), r = vec2(ea.y * 1.0, (ea.w - eb.x) * .28);
382                float bl = uBlush * (1.0 - smoothstep(.2, 1.0, length((vEyeOrig - c) / r)));
383                diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(1.0, .74, .76), bl * .6);
384              }
385            }
386            vec2 eyeDL = eyeDraw(vEyeOrig, uEyeLA, uEyeLB, uEyeOpenL), eyeDR = eyeDraw(vEyeOrig, uEyeRA, uEyeRB, uEyeOpenR);
387            vec2 eyeD = max(eyeDL, eyeDR);
388            diffuseColor.rgb = mix(diffuseColor.rgb, uEyeSkin, eyeD.x);
389            diffuseColor.rgb = mix(diffuseColor.rgb, vec3(.16, .09, .07), eyeD.y);
390            // flipbook: the baked eye picture for the current state, looked up by
391            // where this pixel sits on the face (so gaze / wide-eye warps carry it)
392            if (uEyeOn > .5) {
393              vec2 fq = (vEyeOrig - uEyeRegion.xy) / uEyeRegion.zw;
394              if (fq.x > 0.0 && fq.x < 1.0 && fq.y > 0.0 && fq.y < 1.0) {
395                vec4 fb = texture2D(uEyeTex, vec2(fq.x, (3.0 - uEyeState + fq.y) * .25));
396                // only around each eye (the ellipse the states were painted in),
397                // fading out at its rim, so the picture's own edge never shows
398                vec2 fp = fq * vec2(1400.0, 700.0); fp.y = 700.0 - fp.y;
399                float fr = min(length((fp - vec2(280.0, 300.0)) / vec2(262.0, 292.0)), length((fp - vec2(1120.0, 300.0)) / vec2(262.0, 292.0)));
400                float fw = (1.0 - smoothstep(.9, 1.0, fr)) * step(.5, fb.a);
401                // open eyes: the model's own painting, untouched
402                fw *= step(.5, uEyeState) * (1.0 - smoothstep(.163, .172, abs(vEyeOrig.x)));
403                #ifdef EYE_BACKING
404                  // only where the OPEN picture has the dark upper lash (that is where the
405                  // lid patch cracks); gaps elsewhere (between the bangs) stay as they were
406                  vec4 fb0 = texture2D(uEyeTex, vec2(fq.x, (3.0 + fq.y) * .25));
407                  if (fb.a < .5 || fr > .9 || dot(fb0.rgb, vec3(.299, .587, .114)) > .2) discard;
408                  fw = 1.0;               // the backing always shows the picture (open state = the bake)
409                #endif
410                #ifdef USE_MAP
411                  vec3 fcol = diffuse * fb.rgb;
412                #else
413                  vec3 fcol = fb.rgb;          // the white of the eye (no texture): the picture already holds its colour
414                #endif
415                diffuseColor.rgb = mix(diffuseColor.rgb, fcol, fw);
416              }
417            }`);
418        };
419        mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|eye-flip-6';
420        cache.set(base, mat);
421      }
422      n.material = cache.get(base);
423    }
424    if (eyeStates) { buildBacking(cache); splitLashBits(); }   // specks on a closed eye: cracks in the lid patch AND tiny hair bits
425  }
426  // A few tiny islands of the hair mesh sit on the upper lash line. Open, they
427  // vanish into the dark lash; on a closed eye they float as specks. Move them
428  // into their own mesh so they can be hidden while the eye is not open.
429  const lashBits = [];
430  function splitLashBits() {
431    for (const nm of ['EyeOriginal_L_4', 'EyeOriginal_R_4']) {
432      const m = hero.getObjectByName(nm); if (!m || !m.geometry.index) continue;
433      const g = m.geometry, P = g.attributes.position, I = g.index, n = P.count;
434      const par = new Int32Array(n).map((_, i) => i), f = x => { while (par[x] !== x) { par[x] = par[par[x]]; x = par[x]; } return x; };
435      const weld = new Map();
436      for (let i = 0; i < n; i++) { const k = Math.round(P.getX(i) * 2e4) + ',' + Math.round(P.getY(i) * 2e4) + ',' + Math.round(P.getZ(i) * 2e4); if (weld.has(k)) par[f(i)] = f(weld.get(k)); else weld.set(k, i); }
437      for (let t = 0; t < I.count; t += 3) { const a = f(I.getX(t)); par[f(I.getX(t + 1))] = a; par[f(I.getX(t + 2))] = a; }
438      const box = new Map();
439      for (let t = 0; t < I.count; t += 3) { const r = f(I.getX(t)); let c = box.get(r); if (!c) box.set(r, c = {x0: 9, x1: -9, y0: 9, y1: -9});
440        for (let k = 0; k < 3; k++) { const v = I.getX(t + k), x = P.getX(v), y = P.getY(v); c.x0 = Math.min(c.x0, x); c.x1 = Math.max(c.x1, x); c.y0 = Math.min(c.y0, y); c.y1 = Math.max(c.y1, y); } }
441      const isBit = c => c.y0 > .905 && c.y1 < .926 && Math.abs((c.x0 + c.x1) / 2) > .03 && Math.abs((c.x0 + c.x1) / 2) < .175 && c.x1 - c.x0 < .04;
442      const keep = [], bits = [];
443      for (let t = 0; t < I.count; t += 3) (isBit(box.get(f(I.getX(t)))) ? bits : keep).push(I.getX(t), I.getX(t + 1), I.getX(t + 2));
444      if (!bits.length) continue;
445      g.setIndex(keep);                                        // the outline mesh shares this geometry, so it follows
446      const bg = new T.BufferGeometry(); for (const [k, a] of Object.entries(g.attributes)) bg.setAttribute(k, a); bg.setIndex(bits);
447      if (g.morphAttributes) bg.morphAttributes = g.morphAttributes;
448      const bm = new T.SkinnedMesh(bg, m.material); bm.name = nm + '_lashbits';
449      bm.position.copy(m.position); bm.quaternion.copy(m.quaternion); bm.scale.copy(m.scale);
450      bm.bindMode = m.bindMode; bm.bind(m.skeleton, m.bindMatrix); bm.frustumCulled = false; bm.castShadow = m.castShadow;
451      m.parent.add(bm); lashBits.push(bm);
452    }
453  }
454  // The lid patch has hairline cracks along the top of the eye; with the eye
455  // open they vanish into the dark lash, but on a closed eye the dark hair far
456  // behind shows through them as specks. A backing sheet just behind the face
457  // (depth, skin weights copied from the nearest face vertex) shows the same
458  // eye picture through every crack.
459  function buildBacking(cache) {
460    const src = faceParts.filter(n => n.geometry.attributes.uv);
461    for (const side of [-1, 1]) {
462      const parts = src.filter(n => n.name.includes(side < 0 ? '_R_' : '_L_')); if (!parts.length) continue;
463      const P = parts.map(n => n.geometry.attributes.position), SI = parts.map(n => n.geometry.attributes.skinIndex), SW = parts.map(n => n.geometry.attributes.skinWeight);
464      const nx = 60, ny = 14, x0 = side * .035, x1 = side * .172, y0 = .898, y1 = .928;   // just the band where the lid patch cracks
465      const pos = [], si = [], sw = [], idx = [];
466      for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
467        const x = x0 + (x1 - x0) * i / (nx - 1), y = y0 + (y1 - y0) * j / (ny - 1);
468        let best = 1e9, bp = 0, bv = 0, zMin = 1e9;
469        for (let p = 0; p < P.length; p++) { const A = P[p]; for (let v = 0; v < A.count; v += 2) { const d = (A.getX(v) - x) ** 2 + (A.getY(v) - y) ** 2; if (d < best) { best = d; bp = p; bv = v; } if (d < .008 * .008) zMin = Math.min(zMin, A.getZ(v)); } }
470        // behind every face layer here (so bangs and lids stay in front of it)
471        pos.push(x, y, P[bp].getZ(bv) - .0025);
472        for (let c = 0; c < 4; c++) { si.push(SI[bp].getComponent(bv, c)); sw.push(SW[bp].getComponent(bv, c)); }
473      }
474      for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) { const a = j * nx + i; idx.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); }
475      const g = new T.BufferGeometry();
476      g.setAttribute('position', new T.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new T.Float32BufferAttribute(new Array(nx * ny * 2).fill(0), 2));
477      g.setAttribute('skinIndex', new T.Uint16BufferAttribute(si, 4)); g.setAttribute('skinWeight', new T.Float32BufferAttribute(sw, 4)); g.setIndex(idx); g.computeVertexNormals();
478      const ref = parts[0], mat = cache.get(ref.userData.eyeBase || [...cache.keys()][0]).clone();
479      mat.onBeforeCompile = cache.get([...cache.keys()][0]).onBeforeCompile; mat.defines = {...(mat.defines || {}), EYE_BACKING: 1}; mat.side = T.DoubleSide;
480      mat.customProgramCacheKey = () => 'eye-backing-10';
481      const m = new T.SkinnedMesh(g, mat); m.name = 'EyeBacking_' + (side < 0 ? 'R' : 'L');
482      m.position.copy(ref.position); m.quaternion.copy(ref.quaternion); m.scale.copy(ref.scale);
483      m.bindMode = ref.bindMode; m.bind(ref.skeleton, ref.bindMatrix); m.frustumCulled = false;
484      ref.parent.add(m);
485    }
486  }
487  // ---------- pacifier: a little "chupa chupa" now and then ----------
488  const suckU = {value: 0};
489  function setupPacifier() {
490    const cache = new Map();
491    hero.traverse(n => {
492      if (!n.isMesh || !/Pacifier/.test(n.material?.name || '')) return;
493      const base = n.material;
494      if (!cache.has(base)) {
495        const mat = base.clone(), inner = base.onBeforeCompile;
496        mat.onBeforeCompile = (shader, renderer) => {
497          if (inner) inner.call(mat, shader, renderer);
498          shader.uniforms.uSuck = suckU;
499          shader.vertexShader = 'uniform float uSuck;\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
500            transformed.z -= uSuck * .014;`);
501        };
502        mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|suck1';
503        cache.set(base, mat);
504      }
505      n.material = cache.get(base);
506    });
507  }
508  // ---------- shoulder bag: swings on its strap ----------
509  // The bag is part of the props mesh, so its vertices (a box on her left hip,
510  // in bind space) are turned about the strap's lower end, fading out at the
511  // box edge; the hat and everything else stay put.
512  const bagU = {value: new T.Vector2()};
513  function setupBag() {
514    const cache = new Map();
515    hero.traverse(n => {
516      if (!n.isMesh || n.userData.storybookOutline || !/Gloss_Props(_v17|_Trim_v19|_Strap_v19)/.test(n.material?.name || '')) return;
517      const base = n.material;
518      if (!cache.has(base)) {
519        const mat = base.clone(), inner = base.onBeforeCompile;
520        mat.onBeforeCompile = (shader, renderer) => {
521          if (inner) inner.call(mat, shader, renderer);
522          shader.uniforms.uBag = bagU;
523          shader.vertexShader = 'uniform vec2 uBag;\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
524            {
525              vec3 bp = vec3(.168, .352, .09);
526              float bw = (1.0 - smoothstep(.30, .37, transformed.y)) * smoothstep(.05, .085, transformed.x) * (1.0 - smoothstep(.245, .28, transformed.x))
527                       * smoothstep(-.06, -.02, transformed.z) * (1.0 - smoothstep(.2, .24, transformed.z));
528              if (bw > 0.0) {
529                vec3 d = transformed - bp;
530                float cx = cos(uBag.x), sx = sin(uBag.x), cz = cos(uBag.y), sz = sin(uBag.y);
531                vec3 r = vec3(d.x, d.y * cx - d.z * sx, d.y * sx + d.z * cx);            // swing forward / back
532                r = vec3(r.x * cz - r.y * sz, r.x * sz + r.y * cz, r.z);                 // swing out / in
533                transformed = bp + mix(d, r, bw);
534              }
535            }`);
536        };
537        mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|bag1';
538        cache.set(base, mat);
539      }
540      n.material = cache.get(base);
541    });
542  }
543  const suck = {t: 3, n: 0, ph: 0};
544  function updateSuck(dt) {
545    const asleep = act.def && act.def.eyes === 0 && busy();
546    suck.t -= dt;
547    if (suck.t <= 0 && suck.n <= 0) { suck.n = 2 + (Math.random() * 3 | 0); suck.ph = 0; suck.t = asleep ? 2 + Math.random() * 2 : 4 + Math.random() * 6; }
548    let v = 0;
549    if (suck.n > 0) { suck.ph += dt * (asleep ? 3.2 : 5.5); v = Math.max(0, Math.sin(suck.ph * Math.PI)); if (suck.ph >= 1) { suck.ph = 0; suck.n--; } }
550    suckU.value = window.__suckQA ?? v;
551  }
552  const blink = {t: 0, next: 1.6, dur: .2, openS: 1};
553  const blinkCurve = t => { const c = .07, h = .03, o = .1;
554    if (t < c) return 1 - t / c; if (t < c + h) return 0; if (t < c + h + o) { const k = (t - c - h) / o; return k * k * (3 - 2 * k); } return 1; };
555  function updateEyes(dt) {
556    blink.t += dt; let open = 1;
557    if (blink.t >= blink.next) {
558      const tt = blink.t - blink.next; open = blinkCurve(tt);
559      if (tt > blink.dur) { blink.t = 0; blink.next = 2.2 + Math.random() * 3.2; if (Math.random() < .18) blink.next = .25; }
560    }
561    // Held states: sleeping (closed), effort (half-squeezed), happy (narrowed).
562    face.effort = Math.max(0, face.effort - dt); face.happy = Math.max(0, face.happy - dt); face.surprise = Math.max(0, (face.surprise || 0) - dt);
563    face.wideS = damp(face.wideS || 0, face.surprise > 0 || face.expr === 'surprise' ? 1 : 0, face.surprise > 0 ? 30 : 6, dt);
564    eyeU.uEyeWide.value = window.__wideQA ?? face.wideS;
565    face.blushS = damp(face.blushS || 0, Math.max(face.happy > 0 ? .8 : 0, face.expr === 'smile' ? .8 : 0, face.shy || 0, (st.tiredS || 0) * .7), 3, dt);
566    eyeU.uBlush.value = window.__blushQA ?? face.blushS;
567    let hold = 1, smile = 0, shut = 0;
568    if (act.def && act.def.eyes != null && busy()) { hold = act.def.eyes; shut = 1; }
569    else if (face.surprise > 0) hold = 1;
570    else if (face.effort > 0) hold = .45;
571    else if (face.happy > 0) { hold = .05; smile = 1; }
572    if (face.expr === 'smile') { hold = .05; smile = 1; } else if (face.expr === 'sleep') { hold = 0; shut = 1; } else if (face.expr === 'squint') hold = .45;
573    if (face.override != null) hold = face.override;
574    // the lid shape changes only while the eye is (nearly) closed, so it never pops
575    face.smileS = damp(face.smileS || 0, smile, 10, dt); face.shutS = damp(face.shutS || 0, shut, 10, dt);
576    eyeU.uEyeSmile.value = face.smileS; eyeU.uEyeShut.value = face.shutS;
577    blink.openS = damp(blink.openS, hold, 14, dt);
578    open = Math.min(open, blink.openS);
579    if (window.__eyeQA != null) open = window.__eyeQA;
580    if (eyeU.uEyeOn.value > .5) {
581      // anime-style: pick a drawn state (no folding) — open / half / closed, or the smile arch
582      const closedPic = face.smileS > .5 ? 3 : 2;
583      eyeU.uEyeState.value = window.__stateQA ?? (open > .72 ? 0 : open > .22 ? 1 : closedPic);
584      for (const b of lashBits) b.visible = eyeU.uEyeState.value < .5;
585      eyeU.uEyeSmile.value = 0; eyeU.uEyeShut.value = 0;
586      for (const eye of Object.values(eyes)) eye.open.value = 1;
587    } else for (const eye of Object.values(eyes)) eye.open.value = Math.max(.04, open);
588    for (const [n, i] of lidMorphs) n.morphTargetInfluences[i] = 0;
589  }
590
591  // ---------- procedural layer state ----------
592  const hairMesh = (() => { let m = null; hero.traverse(n => { if (n.morphTargetDictionary && n.morphTargetDictionary.Hair_Sway_L !== undefined && !m) m = n; }); return m; })();
593  const hemMesh = (() => { let m = null; hero.traverse(n => { if (n.morphTargetDictionary && n.morphTargetDictionary.Hem_Sway !== undefined && !m) m = n; }); return m; })();
594  const morphI = (m, k) => m ? m.morphTargetDictionary[k] : undefined;
595  const legs = {};
596  for (const [k, pre] of [['L', 'Left'], ['R', 'Right']]) legs[k] = {up: bone(pre + 'UpLeg'), knee: bone(pre + 'Leg'), foot: bone(pre + 'Foot'), toe: bone(pre + 'ToeBase'), lock: null, w: 0, down: false};
597  const FING = ['Thumb1', 'Thumb2', 'Index1', 'Index2', 'Middle1', 'Middle2', 'Ring1', 'Ring2', 'Pinky1', 'Pinky2'];
598  const hands = ['Left', 'Right'].map(pre => ({hand: bone(pre + 'Hand'), fingers: FING.map(f => bone(pre + f)).filter(Boolean),
599    index: bone(pre + 'Index1'), pinky: bone(pre + 'Pinky1'), middle: bone(pre + 'Middle1'), thumb: bone(pre + 'Thumb1')}));
600  const edited = [...hands.flatMap(h => h.fingers), B.spine, B.spine1, B.spine2, B.neck, B.head, B.tailL, B.tailR, B.hips, B.armL, B.armR, B.foreL, B.foreR, B.handL, B.handR, B.clavL, B.clavR, ...Object.values(legs).flatMap(l => [l.up, l.knee, l.foot])].filter(Boolean);
601  const baseQ = new Map(edited.map(b => [b, {q: b.quaternion.clone(), p: b.position.clone()}]));
602  const morphBase = [];
603  for (const m of [hairMesh, hemMesh]) if (m) morphBase.push([m, m.morphTargetInfluences.slice()]);
604  function restore() {
605    for (const [b, v] of baseQ) { b.quaternion.copy(v.q); b.position.copy(v.p); }
606    for (const [m, arr] of morphBase) for (let i = 0; i < arr.length; i++) m.morphTargetInfluences[i] = arr[i];
607  }
608  function remember() {
609    for (const [b, v] of baseQ) { v.q.copy(b.quaternion); v.p.copy(b.position); }
610    for (const [m, arr] of morphBase) for (let i = 0; i < arr.length; i++) arr[i] = m.morphTargetInfluences[i];
611  }
612
613  const st = {
614    speed: 0, prevSpeed: 0, accel: 0, yawRate: 0, air: 0, phase: 0,
615    lean: 0, bank: 0, look: {yaw: 0, pitch: 0, w: 0}, lookTarget: null, glance: {t: 2, yaw: 0, pitch: 0, hold: 0},
616    breath: 0, idleTime: 0, crouch: 0, landS: [0, 0], airPose: 0, rise: 0,
617    // springs: [value, velocity]
618    hairX: [0, 0], hairZ: [0, 0], tailX: [0, 0], tailZ: [0, 0], hem: [0, 0], lift: [0, 0],
619    headPrev: null, headVel: new T.Vector3(), headAcc: new T.Vector3(),
620  };
621  const spring = (sv, target, k, c, dt) => { const a = (target - sv[0]) * k - sv[1] * c; sv[1] += a * dt; sv[0] += sv[1] * dt; return sv[0]; };
622
623  const qT = new T.Quaternion(), qP = new T.Quaternion(), qB = new T.Quaternion(), vA = new T.Vector3(), vB = new T.Vector3(), rootQ = new T.Quaternion(), invRootQ = new T.Quaternion();
624  // Rotate a bone about a WORLD axis (bone-axis conventions don't matter).
625  function turnWorld(b, axis, ang) {
626    if (!b || !ang) return;
627    qT.setFromAxisAngle(axis, ang); b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB);
628    b.quaternion.copy(qP.invert().multiply(qT.multiply(qB))); b.updateMatrixWorld(true);
629  }
630  const axX = new T.Vector3(), axY = new T.Vector3(), axZ = new T.Vector3();
631
632  // ---------- foot planting ----------
633  // A foot whose ankle comes down to the ground is pinned where it landed until
634  // the clip lifts it again (or it has been dragged too far, e.g. turning on
635  // the spot), so planted feet don't skate however fast the clip is played.
636  const iv = {s: new T.Vector3(), e: new T.Vector3(), w: new T.Vector3(), t: new T.Vector3(), d: new T.Vector3(), p: new T.Vector3(), ed: new T.Vector3(), fq: new T.Quaternion(), inv: new T.Matrix4()};
637  function rotateBoneWorld(b, from, to) {
638    qT.setFromUnitVectors(from.normalize(), to.normalize());
639    b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB);
640    b.quaternion.copy(qP.invert().multiply(qT.multiply(qB))); b.updateMatrixWorld(true);
641  }
642  function solveLeg(leg, target, pole, keepPlane = false) {
643    const {up, knee, foot} = leg;
644    foot.getWorldQuaternion(iv.fq);                                  // keep the clip's foot angle
645    up.getWorldPosition(iv.s); knee.getWorldPosition(iv.e); foot.getWorldPosition(iv.w);
646    const a = iv.e.distanceTo(iv.s), b = iv.w.distanceTo(iv.e);
647    iv.d.copy(target).sub(iv.s);
648    // soft reach limit: past 94% of the leg length the target is eased in, so
649    // the knee never locks straight and then snaps bent a frame later
650    let dist = iv.d.length(); const L0 = (a + b) * .94, Lr = (a + b) * .05;
651    if (dist > L0) dist = L0 + Lr * (1 - Math.exp(-(dist - L0) / Lr));
652    dist = clamp(dist, Math.abs(a - b) + 1e-3, a + b - 1e-3); iv.d.normalize();
653    const cosA = clamp((a * a + dist * dist - b * b) / (2 * a * dist), -1, 1);
654    // bend in the plane the knee already bends in (so a tiny correction is a
655    // tiny change, never a snap to another plane); `pole` only for a straight leg
656    // (blended by how bent it is, so a nearly straight leg hands over smoothly;
657    // keepPlane may also be a 0-1 weight)
658    const keep = keepPlane === true ? 1 : keepPlane || 0;
659    iv.t.copy(pole).addScaledVector(iv.d, -pole.dot(iv.d)).normalize();
660    if (keep > 0) {
661      iv.p.copy(iv.e).sub(iv.s); iv.p.addScaledVector(iv.d, -iv.p.dot(iv.d));
662      const bend = iv.p.length() / a, k = keep * clamp((bend - .03) / .12, 0, 1);
663      if (k > 0) {                       // turn the plane round the hip-foot line (a lerp flips when they're opposite)
664        iv.p.normalize();
665        const ang = Math.atan2(iv.w.crossVectors(iv.t, iv.p).dot(iv.d), iv.t.dot(iv.p));
666        iv.t.applyAxisAngle(iv.d, ang * k).normalize();
667      }
668    }
669    iv.p.copy(iv.t);
670    iv.ed.copy(iv.s).addScaledVector(iv.d, a * cosA).addScaledVector(iv.p, a * Math.sqrt(1 - cosA * cosA));
671    rotateBoneWorld(up, iv.e.clone().sub(iv.s), iv.ed.clone().sub(iv.s));
672    knee.getWorldPosition(iv.e); foot.getWorldPosition(iv.w);
673    rotateBoneWorld(knee, iv.w.clone().sub(iv.e), iv.s.clone().addScaledVector(iv.d, dist).sub(iv.e));
674    foot.parent.getWorldQuaternion(qP); foot.quaternion.copy(qP.invert().multiply(iv.fq)); foot.updateMatrixWorld(true);
675  }
676  const vFoot = new T.Vector3(), vLocal = new T.Vector3(), vPole = new T.Vector3();
677  let groundY = 0; const events = [];
678  const groundYNow = () => root.getWorldPosition(vLocal).y;
679  function plantFeet(dt, weight) {
680    iv.inv.copy(root.matrixWorld).invert(); groundY = root.getWorldPosition(vLocal).y;
681    for (const [k, leg] of Object.entries(legs)) {
682      if (!leg.foot) continue;
683      leg.foot.getWorldPosition(vFoot); vLocal.copy(vFoot).applyMatrix4(iv.inv);
684      const h = vLocal.y - ankleY;
685      if (!leg.down && h < .016 && weight > .5) { leg.down = true; leg.lock = (leg.lock || new T.Vector3()).copy(vFoot); events.push({type: 'step', side: k, pos: vFoot.clone(), speed: st.speed}); }
686      else if (leg.down && (h > .024 || weight < .5)) leg.down = false;
687      // dragged too far (turning on the spot, a clip that slides): ease the pin
688      // toward where the clip has the foot instead of letting it pop back
689      if (leg.down && vFoot.distanceTo(leg.lock) > (Math.abs(st.yawRate) > 1.2 ? .07 : .14)) leg.lock.lerp(vFoot, 1 - Math.exp(-dt * 10));
690      leg.w = damp(leg.w, leg.down ? 1 : 0, leg.down ? Math.max(14, 30 - st.speed * 5.5) : 16, dt);   // fast runs: pin a touch softer (no knee snap at touchdown)
691      // Never below the ground (a crouch lowers the body; the knees bend instead).
692      const floorY = groundY + ankleY * .98, sink = floorY - vFoot.y;
693      if ((leg.w < .01 || !leg.lock) && (sink <= .002 || weight < .3)) continue;
694      const tgt = leg.lock && leg.w > .01 ? vFoot.clone().lerp(leg.lock, leg.w * weight) : vFoot.clone();
695      tgt.y += Math.max(0, floorY - tgt.y) * clamp((weight - .3) / .4, 0, 1);   // kept above the floor, eased off as the pin lets go
696      vPole.set(k === 'L' ? .15 : -.15, 0, 1).applyQuaternion(rootQ);    // knees forward, a little out
697      solveLeg(leg, tgt, vPole, true);
698    }
699  }
700
701  // ---------- arms ----------
702  // degrees: swing offset / amplitude (shoulder flexion), abduction, elbow base / extra on the forward swing
703  const ARM = {
704    idle:   {off: 3, amp: 0,  abd: 15, elb: 16, elbF: 0,  lag: .0},
705    walk:   {off: -3, amp: 17, abd: 13, elb: 24, elbF: 14, lag: .05},
706    run:    {off: -8, amp: 36, abd: 12, elb: 86, elbF: 12, lag: .04},
707    sprint: {off: -10, amp: 46, abd: 13, elb: 80, elbF: 16, lag: .03},
708  };
709  const deg = Math.PI / 180, av = {u: new T.Vector3(), f: new T.Vector3(), b: new T.Vector3(), s: new T.Vector3(), e: new T.Vector3(), h: new T.Vector3(), m: new T.Vector3(), q: new T.Quaternion(), q2: new T.Quaternion()};
710  // turn bone b so that (child - b) points along `dir` (world), by weight w
711  function aimBone(b, child, dir, w) {
712    if (w < .001) return;
713    b.getWorldPosition(av.s); child.getWorldPosition(av.e); av.e.sub(av.s).normalize();
714    w *= clamp((av.e.dot(dir) + .85) / .6, 0, 1);            // nearly opposite: the turn axis is arbitrary, so don't
715    if (w < .001) return;
716    av.q.setFromUnitVectors(av.e, dir);
717    if (w < .999) av.q.slerp(av.q2.identity(), 1 - w);
718    b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB);
719    b.quaternion.copy(qP.invert().multiply(av.q.multiply(qB))); b.updateMatrixWorld(true);
720  }
721  const armSt = {w: 0};
722  // turn unit vector v toward unit vector to by fraction t (on the sphere)
723  const tq = new T.Quaternion(), tq0 = new T.Quaternion();
724  function turnToward(v, to, t) { tq.setFromUnitVectors(v, to); tq0.identity().slerp(tq, t); v.applyQuaternion(tq0).normalize(); return v; }
725  function armsLayer(dt, speed, aw, full, upperOnly, m = {}) {
726    // how much of the arm pose is ours: all of locomotion, none under an action
727    // actions that bring their own arm pose (kicks: a guard; wave: a real wave)
728    const mode = busy() && act.def?.arms ? act.def.arms : null;
729    armSt.modeW = damp(armSt.modeW || 0, mode ? 1 : 0, 9, dt); if (mode) armSt.mode = mode;
730    const mw = armSt.modeW, mname = armSt.mode;
731    const want = Math.max((1 - aw) * (1 - (st.holdS || 0)), mode ? 1 : 0) * (window.__armsQA === 0 ? 0 : 1);
732    armSt.w = damp(armSt.w, want, 10, dt); const w = armSt.w;
733    if (w < .01) return;
734    armSt.t = (armSt.t || 0) + dt;
735    // blend the parameter sets by the gait weights
736    const P = {off: 0, amp: 0, abd: 0, elb: 0, elbF: 0, lag: 0};
737    for (const n of ['idle', 'walk', 'run', 'sprint']) { const k = loco[n].w; for (const key in P) P[key] += ARM[n][key] * k; }
738    // sitting: forearms rest forward on the lap
739    const sit = st.sitS || 0;
740    const legFwd = Math.sin(2 * Math.PI * (st.phase - P.lag));     // +1: left foot forward (phase .25)
741    const breathe = Math.sin(st.breath) * 1.2;
742    // Shoulder line: the clip twists it ±13-20°; people keep it to roughly
743    // ±5° walking and ±10° running, turned against the pelvis (the right
744    // shoulder comes forward with the right arm). Set it, don't add to it.
745    if (B.armL && B.armR && window.__chestQA !== 0) {
746      const want = legFwd * P.amp * .3 * deg * (1 - sit);              // left foot forward → right shoulder forward
747      B.armL.getWorldPosition(av.s); B.armR.getWorldPosition(av.e); av.s.sub(av.e).applyQuaternion(invRootQ);
748      const cur = Math.atan2(-av.s.z, av.s.x), rest = st.shoulderRest ?? (st.shoulderRest = cur);
749      // yaw measured this way grows as the left shoulder goes back, and a
750      // positive turn about her up axis takes the left shoulder back
751      const err = (rest + want) - cur;
752      const k = .85 * w;
753      turnWorld(B.spine1, axY, err * k * .4); turnWorld(B.spine, axY, err * k * .6);
754    }
755    for (const [side, arm, fore, hand, clav] of [[1, B.armL, B.foreL, B.handL, B.clavL], [-1, B.armR, B.foreR, B.handR, B.clavR]]) {
756      if (!arm || !fore || !hand) continue;
757      const swing = side > 0 ? -legFwd : legFwd;                    // opposite arm to leg
758      let flex = P.off + P.amp * swing + breathe * (1 - Math.min(1, speed));
759      let abd = P.abd + Math.abs(swing) * P.amp * .08;              // a touch wider at the ends of the swing
760      let elb = P.elb + P.elbF * Math.max(0, swing);
761      flex = flex * (1 - sit) + 28 * sit; abd = abd * (1 - sit) + 16 * sit; elb = elb * (1 - sit) + 55 * sit;
762      // jumping (standing more than running): arms swing back in the crouch,
763      // up-forward on take-off, and forward-out to meet the landing
764      { const still = clamp(1 - speed / 2.2, .35, 1);
765        const prepW = clamp((m.crouch || 0) / .085, 0, 1) * (1 - st.air) * still, airJ = st.air * still;
766        if (prepW > .01) { flex += (-42 - flex) * prepW; elb += (22 - elb) * prepW; abd += (12 - abd) * prepW; }
767        if (airJ > .01) { const up = Math.max(0, st.rise || 0), down = Math.max(0, -(st.rise || 0));
768          const jf = 30 + 70 * up - 5 * down, je = 35 + 15 * up, ja = 16 + 14 * down;
769          flex += (jf - flex) * airJ; elb += (je - elb) * airJ; abd += (ja - abd) * airJ; } }
770      if (mname === 'guard' && mw > .01) { flex += (24 - flex) * mw; abd += (18 - abd) * mw; elb += (100 - elb) * mw; }   // fists up in front of the chest
771      // relaxed shoulders: the collar bone drops a little and follows the swing
772      if (clav) { turnWorld(clav, axZ, -side * .06 * w); turnWorld(clav, axY, side * flex * deg * .12 * w); }
773      // upper arm: hang, swing (flexion about her left-right axis), open out (abduction)
774      const fl = flex * deg, ab = abd * deg;
775      av.u.set(side * Math.sin(ab), -Math.cos(ab) * Math.cos(fl), Math.cos(ab) * Math.sin(fl)).applyQuaternion(rootQ).normalize();
776      // forearm: bends forward and a little inward (hands toward, never across, the midline)
777      av.b.set(-side * .28, .12, 1).applyQuaternion(rootQ); av.b.addScaledVector(av.u, -av.b.dot(av.u)).normalize();
778      av.f.copy(av.u).multiplyScalar(Math.cos(elb * deg)).addScaledVector(av.b, Math.sin(elb * deg)).normalize();
779      let palmFwd = 0;
780      if (mname === 'wave' && side < 0 && mw > .01) {
781        // a real wave (her right hand): upper arm up and out in front, forearm
782        // upright, palm to the front, the forearm swinging side to side
783        const sw = Math.sin(armSt.t * 2 * Math.PI * 2.3) * .38;
784        const uw = new T.Vector3(side * .78, .42, .46).applyQuaternion(rootQ).normalize();
785        const fw = new T.Vector3(side * (.12 + sw), 1, .12).applyQuaternion(rootQ).normalize();
786        turnToward(av.u, uw, mw); turnToward(av.f, fw, mw); palmFwd = mw;          // along the arc, not a straight-line blend
787      }
788      aimBone(arm, fore, av.u, w);
789      aimBone(fore, hand, av.f, w);
790      // hand: wrist straight along the forearm, palm facing her body
791      const mid = side > 0 ? B.midL : B.midR;
792      if (mid) {
793        aimBone(hand, mid, av.f, w * .8);
794        hand.getWorldPosition(av.h); mid.getWorldPosition(av.m); const fdir = av.m.sub(av.h).normalize();
795        const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name)), th = hand.children.find(c => /Thumb1/.test(c.name));
796        if (idx && pin && th) {
797          // Aim the thumb side (pinky → index knuckles), not a guessed palm
798          // normal: arms down, thumbs forward and palms toward the thighs
799          // (the old palm sign was flipped: thumbs pointed backward); waving,
800          // the palm faces forward, so the thumb side points toward her middle.
801          const ip = idx.getWorldPosition(new T.Vector3()).sub(pin.getWorldPosition(new T.Vector3()));
802          ip.addScaledVector(fdir, -ip.dot(fdir)).normalize();
803          const wantIp = new T.Vector3(-side * (.25 * (1 - palmFwd) + palmFwd), 0, 1 - palmFwd).applyQuaternion(rootQ);
804          wantIp.addScaledVector(fdir, -wantIp.dot(fdir)).normalize();
805          const ang = Math.atan2(new T.Vector3().crossVectors(ip, wantIp).dot(fdir), ip.dot(wantIp));
806          turnWorld(hand, fdir, ang * w * clamp((Math.PI - Math.abs(ang)) / 1.2, 0, 1));   // fades out near ±180° (no flip)
807        }
808      }
809    }
810
811  }
812
813  const tv = {s: new T.Vector3(), e: new T.Vector3(), h: new T.Vector3(), d: new T.Vector3(), c: new T.Vector3(), p: new T.Vector3(), m: new T.Vector3()};
814  function tuckArms(k) {
815    if (k < .01) return;
816    for (const [side, arm, fore, hand, mid] of [[1, B.armL, B.foreL, B.handL, B.midL], [-1, B.armR, B.foreR, B.handR, B.midR]]) {
817      if (!arm || !fore || !hand) continue;
818      arm.getWorldPosition(tv.s); fore.getWorldPosition(tv.e); hand.getWorldPosition(tv.h);
819      tv.d.copy(tv.h).sub(tv.s).normalize();
820      tv.c.copy(tv.e).sub(tv.s); tv.c.addScaledVector(tv.d, -tv.c.dot(tv.d));
820          // where the elbow points now
821      tv.p.set(side * .3, -1, -.2).applyQuaternion(rootQ); tv.p.addScaledVector(tv.d, -tv.p.dot(tv.d));   // down, a little out and back
822      if (tv.c.lengthSq() < 1e-6 || tv.p.lengthSq() < 1e-6) continue;
823      tv.c.normalize(); tv.p.normalize();
824      tv.c.lerp(tv.p, k); if (tv.c.lengthSq() < 1e-6) continue;
825      solveLeg({up: arm, knee: fore, foot: hand}, tv.h.clone(), tv.c.normalize());
826      // wrist in line with the forearm: a fist, not a drooping hand
827      if (mid) { hand.getWorldPosition(tv.h); fore.getWorldPosition(tv.e); aimBone(hand, mid, tv.m.copy(tv.h).sub(tv.e).normalize(), k * .7); }
828      // a punch turns the forearm in (pronation): the fist lands palm down,
829      // thumb side toward her middle — not thumbs-up
830      const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name));
831      if (mid && idx && pin) {
832        hand.getWorldPosition(tv.h); const fdir = mid.getWorldPosition(tv.d).sub(tv.h).normalize();
833        const ip = idx.getWorldPosition(tv.c).sub(pin.getWorldPosition(tv.p)); ip.addScaledVector(fdir, -ip.dot(fdir)).normalize();
834        const want = tv.s.set(-side, -.3, 0).applyQuaternion(rootQ); want.addScaledVector(fdir, -want.dot(fdir));
835        if (want.lengthSq() > 1e-4) {
836          want.normalize();
837          const ang = Math.atan2(tv.e.crossVectors(ip, want).dot(fdir), ip.dot(want));
838          const fax = fore.getWorldPosition(tv.m); fax.subVectors(tv.h, fax).normalize();          // the forearm's own long axis
839          turnWorld(fore, fax, ang * k * .5);                                   // (twisting about it keeps the fist where it is)
840          hand.getWorldPosition(tv.h); const fd2 = mid.getWorldPosition(tv.d).sub(tv.h).normalize();
841          idx.getWorldPosition(tv.c).sub(pin.getWorldPosition(tv.p)); tv.c.addScaledVector(fd2, -tv.c.dot(fd2)).normalize();
842          want.set(-side, -.3, 0).applyQuaternion(rootQ).addScaledVector(fd2, -want.dot(fd2)).normalize();
843          turnWorld(hand, fd2, Math.atan2(tv.e.crossVectors(tv.c, want).dot(fd2), tv.c.dot(want)) * k);   // the wrist does the rest
844        }
845      }
846    }
847  }
848
849  // ---------- hands ----------
850  // Each finger joint turns about the knuckle line, toward the palm (the side
851  // the thumb is on), so the curl direction never depends on bone axes.
852  const hv = {a: new T.Vector3(), b: new T.Vector3(), c: new T.Vector3(), d: new T.Vector3(), k: new T.Vector3(), n: new T.Vector3()};
853  function curlHands(amount) {
854    if (amount < .01) return;
855    for (const h of hands) {
856      if (!h.index || !h.pinky || !h.middle || !h.thumb) continue;
857      h.hand.getWorldPosition(hv.a); h.middle.getWorldPosition(hv.b); const f = hv.b.sub(hv.a).normalize();
858      h.index.getWorldPosition(hv.c); h.pinky.getWorldPosition(hv.d); const k = hv.k.copy(hv.c).sub(hv.d).normalize();
859      const n = hv.n.crossVectors(f, k).normalize();
860      // palm side: +n on the left hand, -n on the right (measured; guessing it
861      // from the thumb's position got both hands backwards)
862      const sp = window.__curlQA === 0 ? (Math.sign(h.thumb.getWorldPosition(hv.c).sub(hv.a).dot(n)) || 1) : h === hands[0] ? 1 : -1;
863      for (const b of h.fingers) {
864        const thumb = /Thumb/.test(b.name), j2 = b.name.endsWith('2');
865        const ang = -sp * amount * (thumb ? .5 : j2 ? 1.25 : 1.1);
866        if (thumb) { turnWorld(b, f, ang * (window.__thF ?? 1.6)); if (window.__thK) turnWorld(b, k, ang * window.__thK); }   // thumb: folds over the fingers (a real fist, not a thumbs-up)
867        else turnWorld(b, k, ang);
868      }
869    }
870  }
871
872  // ---------- per frame ----------
873  // m: {speed (m/s along facing), yawRate (rad/s), air (0..1), vy}
874  function update(dt, m = {}) {
875    dt = Math.min(dt, 1 / 20);
876    const speed = m.speed || 0;
877    st.accel = damp(st.accel, (speed - st.prevSpeed) / Math.max(dt, 1e-3), 6, dt); st.prevSpeed = speed;
878    st.speed = speed; st.yawRate = damp(st.yawRate, m.yawRate || 0, 8, dt); st.air = damp(st.air, m.air || 0, 12, dt);
879
880    // Gait weights by speed (piecewise between measured natural speeds).
881    const vW = moveSpeed.walk, vR = moveSpeed.run, vS = moveSpeed.sprint;
882    const target = {idle: 0, walk: 0, run: 0, sprint: 0};
883    // Turning on the spot: little steps instead of spinning on planted feet.
884    const turnStep = Math.min(.5, Math.max(0, Math.abs(st.yawRate) - .6) * .18);
885    const gaitSpeed_ = speed < .45 ? Math.max(speed, turnStep) : speed;
886    { const speed = gaitSpeed_;
887    if (speed < .08) target.idle = 1;
888    else if (speed < vW * .55) { const k = speed / (vW * .55); target.idle = 1 - k; target.walk = k; }
889    else if (speed < vW) target.walk = 1;
890    else if (speed < vR) { const k = (speed - vW) / (vR - vW); const e = k * k * (3 - 2 * k); target.walk = 1 - e; target.run = e; }
891    else if (speed < vS) { const k = (speed - vR) / (vS - vR); target.run = 1 - k; target.sprint = k; }
892    else target.sprint = 1; }
893    let sum = 0; for (const n in loco) { loco[n].w = damp(loco[n].w, target[n], 10, dt); sum += loco[n].w; }
894    for (const n in loco) loco[n].w /= sum || 1;
895
896    // Phase-locked gaits: one shared stride phase, advanced at the rate the
897    // feet travel so planted feet stay put.
898    let mw = 0, D = 0, V = 0;
899    let SS = 0;
900    for (const n of GAITS) { mw += loco[n].w; D += loco[n].w * loco[n].dur; V += loco[n].w * gaitSpeed[n] * (window.__strideQA === 0 ? 1 : STRIDE[n]); SS += loco[n].w * STRIDE[n]; }
901    st.stride = mw > 1e-3 ? (window.__strideQA === 0 ? 1 : SS / mw) : 1;
902    if (mw > 1e-3) { D /= mw; V /= mw; const rate = clamp(Math.max(gaitSpeed_, .5 * V) / V, .5, MAX_RATE); st.phase = (st.phase + dt * rate / D) % 1; }
903    for (const n of GAITS) { const L = loco[n]; L.up.time = L.low.time = st.phase * L.dur; }
904
905    // Action weight and time.
906    if (act.def) {
907      act.t += dt;
908      const ending = act.t > act.len - act.fade;
909      act.w = damp(act.w, ending ? 0 : 1, Math.min(ending ? 12 : 14, (ending ? 2.6 : 3.1) / act.fade), dt);   // rate from the fade time (never faster than before; long fades are slower)
910      if (act.a.time > act.cut[1] && !act.def.loop) act.a.time = act.cut[1];
911      if (act.t >= act.len && act.w < .02) {
912        act.a.weight = 0; act.a.stop(); const cb = act.onEnd; act.def = null; act.a = null; act.w = 0; cb?.();
913      }
914    }
915    const aw = act.def ? act.w : 0, full = act.def?.layer === 'full', upperOnly = act.def?.layer === 'upper';
916    const pv = act.prev;
917    if (pv) { pv.w = damp(pv.w, 0, 16, dt); pv.a.weight = pv.w; if (pv.w < .01) { pv.a.stop(); pv.a.weight = 0; act.prev = null; } }
918    for (const n in loco) {
919      loco[n].low.weight = loco[n].w * Math.max(0, 1 - (full ? aw : 0) - (pv && pv.full ? pv.w : 0));
920      loco[n].up.weight = loco[n].w * Math.max(0, 1 - aw - (pv ? pv.w : 0));
921    }
922    if (act.a) act.a.weight = aw;
923
924    restore(); mixer.update(dt); remember(); hero.updateMatrixWorld(true);
925    updateEyes(dt); updateSuck(dt);
926
927    // ----- procedural acting -----
928    root.getWorldQuaternion(rootQ); invRootQ.copy(rootQ).invert();
929    axX.set(1, 0, 0).applyQuaternion(rootQ); axY.set(0, 1, 0).applyQuaternion(rootQ); axZ.set(0, 0, 1).applyQuaternion(rootQ);
930    const freeW = 1 - (full ? aw : 0);                   // procedural lean fades out under full actions
931
932    // Lean forward with speed and acceleration; bank into turns (like a runner).
933    const leanT = clamp(speed * .02 + st.accel * .035, -.12, .3) * freeW;
934    const bankT = clamp(-st.yawRate * speed * .06, -.32, .32) * freeW;
935    st.lean = damp(st.lean, leanT, 7, dt); st.bank = damp(st.bank, bankT, 7, dt);
936    turnWorld(B.hips, axZ, st.bank * .55);
937    turnWorld(B.spine2, axX, st.lean * .5); turnWorld(B.spine1, axX, st.lean * .5);   // lean from the lower back
938    turnWorld(B.spine1, axZ, st.bank * .35);
939    // Twist the chest toward where she is turning (after the head, before the hips).
940    turnWorld(B.spine1, axY, clamp(st.yawRate * .05 + (st.goalYaw || 0) * .18, -.3, .3) * freeW);
941
942    // Breathing when still (a slow rise of the chest and shoulders). After a
943    // long run she is out of breath: fast, deep breaths, bent over a little.
944    st.puff = clamp((st.puff || 0) + (speed > moveSpeed.run * .9 ? dt * .22 : -dt * .25), 0, 1.2);   // builds while running hard
945    const tired = speed < .4 ? clamp(st.puff, 0, 1) : 0;
946    st.tiredS = damp(st.tiredS || 0, tired, 4, dt);
947    st.breath += dt * (1.6 + Math.min(speed, 4) * .6 + st.tiredS * 5);
948    const breathe = Math.sin(st.breath) * (.018 + .01 * Math.min(1, Math.abs(st.accel) / 4) + st.tiredS * .05);
949    turnWorld(B.spine, axX, -breathe * (1 - aw * .7));      // breathe with the chest
950    turnWorld(B.spine1, axX, st.tiredS * .22 * freeW);                 // hands-on-knees lean
951    if (st.tiredS > .3 && !busy()) face.effort = Math.max(face.effort, .1);
952
953    // Head stabilisation: people keep the head (and eyes) level while the body
954    // bobs and rocks; take out part of the clip's head pitch/roll.
955    if (B.head) {
956      const k = clamp(.25 + speed * .12, .25, .5) * freeW * (window.__stabQA === 0 ? 0 : 1);
957      B.head.getWorldQuaternion(qB);
958      const fwd = vA.set(0, 0, 1).applyQuaternion(qB); fwd.y = 0;
959      if (fwd.lengthSq() > 1e-6) {
960        fwd.normalize();
961        const up = vB.set(0, 1, 0), right = new T.Vector3().crossVectors(up, fwd).normalize();
962        // a level head with the same heading, keeping the rest pose's own tilt relative to the body
963        const lvl = new T.Quaternion().setFromRotationMatrix(new T.Matrix4().makeBasis(right, up, fwd));
964        const cur = B.head.getWorldQuaternion(new T.Quaternion());
965        const rest = st.headRest || (st.headRest = new T.Quaternion().copy(lvl).invert().multiply(cur));   // idle offset captured once
966        const target = lvl.multiply(rest);
967        const delta = target.multiply(cur.clone().invert());            // world rotation that levels it
968        const q = new T.Quaternion().slerp(delta, k);
969        B.head.parent.getWorldQuaternion(qP);
970        B.head.quaternion.copy(qP.invert().multiply(q.multiply(cur))); B.head.updateMatrixWorld(true);
971      }
972    }
973
974    // Look-at: a target point, or idle glances around when nothing to look at.
975    st.idleTime = speed < .1 && !busy() ? st.idleTime + dt : 0;
976    let wantYaw = 0, wantPitch = 0, wantW = 0;
977    if (st.lookTarget && B.head) {
978      B.head.getWorldPosition(vA); vB.copy(st.lookTarget).sub(vA).applyQuaternion(invRootQ);
979      wantYaw = Math.atan2(vB.x, vB.z); wantPitch = Math.atan2(vB.y, Math.hypot(vB.x, vB.z));
980      wantW = Math.abs(wantYaw) < 1.9 ? 1 : 0;            // don't twist around to look behind
981      st.eyeExtra = wantW ? [wantYaw - clamp(wantYaw, -1.05, 1.05), wantPitch - clamp(wantPitch, -.45, .5)] : [0, 0];
982      wantYaw = clamp(wantYaw, -1.05, 1.05); wantPitch = clamp(wantPitch, -.45, .5);
983    }
984    const g = st.glance; g.t -= dt;
985    if (g.t <= 0) { g.t = 1.6 + Math.random() * 3.5; const idleish = st.idleTime > 1.5; g.yaw = idleish && Math.random() < .55 ? (Math.random() - .5) * 1.6 : 0; g.pitch = idleish ? (Math.random() - .6) * .4 : 0; }
986    if (g.yaw || g.pitch) { const k = st.lookTarget ? .45 : 1; wantYaw = wantYaw * (1 - k) + g.yaw * k; wantPitch = wantPitch * (1 - k) + g.pitch * k; wantW = Math.max(wantW, .9); }
987    // Moving: look where she is going. In a turn the eyes and head lead (they
988    // reach the new direction first), the chest follows, the feet come last.
989    st.goalYaw = damp(st.goalYaw || 0, m.goalYaw || 0, 14, dt);
990    if (speed > .5 || Math.abs(st.goalYaw) > .2) {
991      wantYaw = clamp(st.goalYaw * .85 + st.yawRate * .08, -1.0, 1.0); wantPitch = -.05; wantW = 1;
992      st.eyeExtra = [clamp(st.goalYaw - wantYaw, -.6, .6), 0];
993    }
994    const lk = st.look, lookFree = 1 - aw * (full ? .85 : .5);
995    if (Math.abs(wantYaw * wantW - lk.yaw) > .7 && blink.t < blink.next - .05 && blink.t > .4) blink.t = blink.next;   // a big head turn brings a blink
996    lk.yaw = damp(lk.yaw, wantYaw, 5, dt); lk.pitch = damp(lk.pitch, wantPitch, 5, dt); lk.w = damp(lk.w, wantW * lookFree, 4, dt);
997    const yaw = lk.yaw * lk.w, pitch = lk.pitch * lk.w;
998    st.headYawWant = yaw;                                                // where the head should point (relative to her body)
999    // now and then, while looking at someone, a little head tilt
1000    { const tl = st.tilt || (st.tilt = {t: 4, v: 0, want: 0});
1001      tl.t -= dt; if (tl.t <= 0) { tl.want = st.lookTarget && speed < .1 && !busy() && Math.random() < .5 ? (Math.random() < .5 ? -1 : 1) * (.16 + Math.random() * .1) : 0; tl.t = tl.want ? 1.6 + Math.random() * 1.4 : 2.5 + Math.random() * 3; }
1002      tl.v = damp(tl.v, speed < .1 ? tl.want : 0, 4, dt);
1003      if (B.head && tl.v) { vA.set(0, 0, 1).applyQuaternion(rootQ).applyAxisAngle(axY, yaw); turnWorld(B.head, vA, tl.v); turnWorld(B.neck, vA, tl.v * .3); } }
1004    // Eyes lead the head: they jump to the target at once and settle back as the
1005    // head catches up, cover what the neck can't turn, and flick about a little.
1006    { const ex = st.eyeExtra || [0, 0], sc = st.sacc || (st.sacc = {t: 0, x: 0, y: 0});
1007      sc.t -= dt; if (sc.t <= 0) { sc.t = .6 + Math.random() * 1.8; const calm = speed < .1 ? 1 : .3; sc.x = (Math.random() - .5) * .007 * calm; sc.y = (Math.random() - .5) * .003 * calm; }
1008      const ey = (wantYaw * wantW - yaw) + ex[0], ep = (wantPitch * wantW - pitch) + ex[1];
1009      const gx = clamp(ey * .022 + sc.x, -.016, .016), gy = clamp(ep * .014 + sc.y, -.006, .007);
1010      const gl = eyeU.uEyeLook.value, k = 1 - Math.exp(-dt * 28);
1011      gl.x += (gx - gl.x) * k; gl.y += (gy - gl.y) * k;
1012      if (window.__gazeShy) { gl.x += (window.__gazeShy[0] - gl.x) * k; gl.y += (window.__gazeShy[1] - gl.y) * k; }
1013      if (window.__gazeQA) gl.set(window.__gazeQA[0], window.__gazeQA[1]);
1014    }
1015    turnWorld(B.spine, axY, yaw * .2);
1016    turnWorld(B.neck, axY, yaw * .3); turnWorld(B.head, axY, yaw * .5);
1017    // pitch about her own right axis after the yaw
1018    if (B.head) {
1019      vA.set(1, 0, 0).applyQuaternion(rootQ).applyAxisAngle(axY, yaw * .8);
1020      turnWorld(B.neck, vA, -pitch * .4); turnWorld(B.head, vA, -pitch * .6);
1021      // keep the head a bit more level than the leaning body (eyes on the horizon)
1022      turnWorld(B.head, axX, -st.lean * .45); turnWorld(B.head, axZ, -st.bank * .5);
1023    }
1024
1025    // ----- crouch (jump anticipation, landing squash) and the airborne pose -----
1026    const landing = spring(st.landS, 0, 140, 13, dt);
1027    st.crouch = damp(st.crouch, (m.crouch || 0) + Math.min(speed, 3) * .006, 18, dt);
1028    hero.position.y = heroPos.y - clamp(st.crouch - landing, -.02, .11);
1029    hero.updateMatrixWorld(true);
1030    // landing: the chest dips with the squash
1031    turnWorld(B.spine1, axX, clamp(-landing * 2.2, 0, .3) * (1 - (full ? aw : 0)));
1032    // standing still: a slow weight shift from foot to foot (the planted feet
1033    // stay put, so the knees take it) — nobody stands perfectly still
1034    { const stillW = clamp(1 - speed * 3, 0, 1) * (1 - aw) * (1 - st.air) * (1 - (st.sitS || 0));
1035      st.swayT = (st.swayT || 0) + dt;
1036      const sway = (Math.sin(st.swayT * .55) * .6 + Math.sin(st.swayT * 1.3 + 1) * .25) * stillW;
1037      turnWorld(B.hips, axZ, sway * .045); turnWorld(B.spine1, axZ, -sway * .03);
1038      hero.position.x = heroPos.x + sway * .012; hero.updateMatrixWorld(true); }
1039    // ----- arms: a procedural, human-measured arm swing replaces the clip's -----
1040    // The Meshy walk/run clips hold the arms out at 25-50° (up to 155° when
1041    // running) with the elbows flapping. Gait studies: walking arms swing
1042    // mostly fore-aft, opposite the same-side leg and a little behind it in
1043    // time, amplitude growing with speed, elbows ~20-35° bent (more on the
1044    // forward swing); running elbows ~90°, relaxed shoulders, hands not
1045    // crossing the midline; palms face the body. The chest counter-rotates.
1046    armsLayer(dt, speed, aw, full, upperOnly, m);
1047    // Action clips (punches, dodges) hold the elbows out level with the
1048    // shoulders ("chicken wings"). Keep each fist where the clip puts it, but
1049    // swing the elbow down and in, under the fist, the way a real guard is held.
1050    st.tuckW = damp(st.tuckW || 0, busy() ? (act.def?.tuck || 0) : 0, 10, dt);
1051    st.tuckK = damp(st.tuckK || 0, st.tuckW * aw * (1 - (armSt.modeW || 0)) * (window.__tuckQA ?? 1), 12, dt);   // eased: switching actions resets aw
1052    tuckArms(st.tuckK);
1053
1054    // lying down / getting up: the blend alone tips her over stiff as a plank;
1055    // children go down knees first, so fold the legs while the blend is halfway
1056    if (full && act.def?.kneel && window.__kneelQA !== 0) {
1057      const k = 4 * aw * (1 - aw) * act.def.kneel;
1058      for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) {
1059        turnWorld(leg.up, axX, -1.0 * k); turnWorld(leg.knee, axX, 1.8 * k);
1060      }
1061      turnWorld(B.spine2, axX, .25 * k);
1062    }
1063    const airW = st.air * (1 - (full ? aw : 0));
1064    if (airW > .01) {
1065      const vy = m.vy || 0;
1066      st.rise = damp(st.rise, clamp(vy / 3.5, -1, 1), 10, dt);          // +1 rising … -1 falling
1067      const tuck = (.55 + .35 * Math.max(0, st.rise) - .45 * Math.max(0, -st.rise)) * airW;
1068      const armsUp = (.25 + .45 * Math.max(0, st.rise)) * airW, armsOut = (.38 - .12 * Math.max(0, st.rise)) * airW;   // arms swing up-forward on the way up, open a little for balance as she drops
1069      for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) {
1070        const lead = side === 1 ? 1.15 : .8;                               // one knee a bit higher: less stiff
1071        turnWorld(leg.up, axX, -1.25 * tuck * lead); turnWorld(leg.knee, axX, 1.9 * tuck * lead);
1072        turnWorld(leg.up, axZ, side * .12 * airW);
1073      }
1074      // (the arms in the air are part of the arm layer now)
1075      turnWorld(B.spine1, axX, -.12 * st.rise * airW);                   // arch up on the way up, curl as she falls
1076    }
1077
1078    // bending down (to pick something up)
1079    st.bendS = damp(st.bendS || 0, m.bend || 0, 14, dt);
1080    if (st.bendS > .01) { turnWorld(B.spine2, axX, st.bendS * .45); turnWorld(B.spine1, axX, st.bendS * .55); turnWorld(B.head, axX, -st.bendS * .3); }
1081
1082    // ----- carrying something in both arms (hugged to the chest) -----
1083    st.holdS = damp(st.holdS || 0, m.hold && !(busy() && act.def.layer) ? 1 : 0, 6, dt);
1084    if (st.holdS > .01) {
1085      // both hands on the sides of the ball, a little behind its middle,
1086      // elbows down and out: a hug in front of the tummy
1087      const h = st.holdS, R = m.holdR || .17;
1088      const fwd = vA.set(0, 0, 1).applyQuaternion(rootQ), side = vB.set(1, 0, 0).applyQuaternion(rootQ);
1089      B.spine.getWorldPosition(st.holdC || (st.holdC = new T.Vector3()));
1090      st.holdC.addScaledVector(fwd, R + .1).y -= .1;
1091      for (const [s, arm, fore, hand, mid] of [[1, B.armL, B.foreL, B.handL, B.midL], [-1, B.armR, B.foreR, B.handR, B.midR]]) {
1092        if (!arm || !fore || !hand) continue;
1093        const tgt = st.holdC.clone().addScaledVector(side, s * R * .95).addScaledVector(fwd, -.03);
1094        tgt.lerp(hand.getWorldPosition(tv.h), 1 - h);
1095        solveLeg({up: arm, knee: fore, foot: hand}, tgt, tv.p.set(s * .7, -1, -.4).applyQuaternion(rootQ).normalize(), 1 - h);
1096        // fingers forward round the ball, palm against it
1097        if (mid) aimBone(hand, mid, tv.m.copy(fwd).multiplyScalar(.85).addScaledVector(side, -s * .35).normalize(), h * .9);
1098      }
1099    }
1100
1101    // ----- wrists: a hand never spins faster than a real wrist (~720°/s) -----
1102    // (blending two clips whose hands face opposite ways turned them 50°+ a frame)
1103    if (window.__wristQA !== 0) for (const hand of [B.handL, B.handR]) {
1104      if (!hand) continue;
1105      hand.getWorldQuaternion(qB);
1106      const prev = hand.userData.wq || (hand.userData.wq = qB.clone());
1107      const ang = prev.angleTo(qB), max = 12.5 * dt;
1108      if (ang > max) { prev.slerp(qB, max / ang); hand.parent.getWorldQuaternion(qP); hand.quaternion.copy(qP.invert().multiply(prev)); hand.updateMatrixWorld(true); }
1109      else prev.copy(qB);
1110    }
1111    // ----- hands: fists for effort moves, a loose curl when running -----
1112    { const want = Math.max((act.def?.effort && busy() ? 1 : 0) * aw + clamp((speed - .9) / 2, 0, .45) * (1 - aw) + .12, m.fist || 0);   // m.fist: holding a tool
1113      st.fist = damp(st.fist || 0, want, 12, dt); curlHands(window.__fistQA ?? st.fist); }
1114
1115    // ----- sitting on the ground, legs stretched out in front -----
1116    st.sitS = damp(st.sitS || 0, m.sit ? 1 : 0, 5, dt);
1117    const sitK = st.sitS * st.sitS * (3 - 2 * st.sitS);
1118    if (sitK > .01) {
1119      B.hips.getWorldPosition(vA);
1119 const hipH = vA.y - groundYNow();
1120      hero.position.y -= sitK * Math.max(0, hipH - .13); hero.updateMatrixWorld(true);  // bottom down onto the grass
1121      turnWorld(B.spine2, axX, -.12 * sitK); turnWorld(B.spine1, axX, .1 * sitK);        // lean back a touch, chest over
1122      for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) {
1123        if (!leg.foot) continue;
1124        leg.foot.getWorldPosition(vB);
1125        const tgt = root.localToWorld(new T.Vector3(side * .085, ankleY * .8, .34));
1126        tgt.lerp(vB, 1 - sitK);
1127        solveLeg(leg, tgt, vPole.set(0, 1, .35).applyQuaternion(rootQ));             // knees up a little
1128        leg.down = false; leg.w = 0;
1129      }
1130    }
1131    // ----- step width: feet land nearer the midline when moving -----
1132    // (the clips keep the feet ~19 cm apart — a toddler's waddle; walking
1133    // adults place them ~1/3 of that apart, runners almost on one line)
1134    { const gw = loco.walk.w + loco.run.w + loco.sprint.w, narrow = (.33 * loco.walk.w + .45 * (loco.run.w + loco.sprint.w)) * (1 - (full ? aw : 0)) * (1 - sitK);
1135      if (gw > .02 && narrow > .01 && window.__narrowQA !== 0) {
1136        iv.inv.copy(root.matrixWorld).invert();
1137        for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) {
1138          if (!leg.foot) continue;
1139          leg.foot.getWorldPosition(vFoot); vLocal.copy(vFoot).applyMatrix4(iv.inv);
1140          const toward = (vLocal.x - side * .035) * narrow;      // keep a few cm either side of the line
1141          const reach = (vLocal.z - .01) * ((st.stride || 1) - 1) * Math.min(1, gw * 1.5);   // longer strides fore and aft
1142          if (Math.abs(toward) < .002 && Math.abs(reach) < .002) continue;
1143          vLocal.x -= toward; vLocal.z += reach; const tgt = vLocal.applyMatrix4(root.matrixWorld);
1144          solveLeg(leg, tgt, vPole.set(side * .08, 0, 1).applyQuaternion(rootQ), true);
1145        }
1146      } }
1147    // ----- feet: plant the foot that touches the ground (two-bone leg IK) -----
1148    { const want = (1 - st.air) * (1 - (full ? Math.min(1, aw * (act.def?.unplant || 1)) : 0)) * (1 - sitK) * (m.plant === false || window.__plantQA === 0 ? 0 : 1);
1149      st.plantW = want > (st.plantW ?? 1) || m.air || (full && act.def?.unplant >= 10) ? want : damp(st.plantW ?? 1, want, 14, dt);   // let go of the ground smoothly (a jump lets go at once)
1150      plantFeet(dt, st.plantW); }
1151    // ----- toe-off: the heel comes up first and the foot rolls over the toes -----
1152    // (the clip lifts the trailing foot flat, toes and all, 5 cm off the ground)
1153    { const gw = (loco.walk.w + loco.run.w + loco.sprint.w) * (1 - st.air) * (1 - (full ? aw : 0)) * (1 - sitK);
1154      if (gw > .02 && window.__toeQA !== 0) {
1155        iv.inv.copy(root.matrixWorld).invert();
1156        B.hips.getWorldPosition(vA); const hipZ = vA.applyMatrix4(iv.inv).z;
1157        const toeRest = ankleY * .16;
1158        for (const leg of [legs.L, legs.R]) {
1159          if (!leg.foot || !leg.toe) continue;
1160          leg.foot.getWorldPosition(iv.s); leg.toe.getWorldPosition(iv.e);
1161          vLocal.copy(iv.s).applyMatrix4(iv.inv); const fz = vLocal.z, ankH = vLocal.y - ankleY, toeH = iv.w.copy(iv.e).applyMatrix4(iv.inv).y;
1162          iv.d.copy(iv.e).sub(iv.s); const len = iv.d.length();
1163          const pitch = Math.asin(clamp(iv.d.y / len, -1, 1));
1164          if (leg.down) leg.flat = leg.flat === undefined ? pitch : leg.flat + (pitch - leg.flat) * .1;   // the foot's angle standing flat
1165          // toe-off: only the trailing foot, only while it is leaving the ground
1166          const trail = clamp((hipZ - fz - .02) / .08, 0, 1);
1167          const behind = trail * clamp(1 - (ankH - .035) / .035, 0, 1), lift = toeH - toeRest;
1168          let np = pitch;
1169          if (behind > .01 && lift > .002) np += (Math.max(pitch - .6, Math.asin(clamp((iv.d.y - lift) / len, -1, 1))) - pitch) * behind * gw;   // at most ~35° more
1170          // swing: the clip points the toes down 30-40°; people bring the ankle
1171          // back to neutral so the toes clear the ground (at most 10° down)
1172          const swing = (1 - trail) * clamp((ankH - .01) / .02, 0, 1) * (leg.down ? 0 : 1);
1173          if (swing > .01 && leg.flat !== undefined) np += (Math.max(pitch, leg.flat - .17) - pitch) * swing * gw * .85;
1174          leg.toeOff = damp(leg.toeOff || 0, np - pitch, 22, dt); np = pitch + leg.toeOff;   // eased in and out, never a snap
1175          if (Math.abs(np - pitch) < 1e-3) continue;
1176          iv.t.set(iv.d.x, 0, iv.d.z).normalize();
1177          iv.p.copy(iv.t).multiplyScalar(Math.cos(np)); iv.p.y = Math.sin(np);
1178          rotateBoneWorld(leg.foot, iv.d, iv.p);
1179        }
1180      } }
1181
1182    // ----- head yaw: the torso twists under a steady head (no side-to-side wag) -----
1183    if (B.head && window.__stabQA !== 0) {
1184      B.head.getWorldQuaternion(qB);
1185      const f = vA.set(0, 0, 1).applyQuaternion(qB).applyQuaternion(invRootQ);
1186      const cur = Math.atan2(f.x, f.z), rest = st.headYawRest ?? (st.headYawRest = cur);
1187      let err = (rest + (st.headYawWant || 0)) - cur; while (err > Math.PI) err -= 2 * Math.PI; while (err < -Math.PI) err += 2 * Math.PI;
1188      const k = .85 * (1 - (full ? aw : 0));
1189      turnWorld(B.neck, axY, err * k * .4); turnWorld(B.head, axY, err * k * .6);
1190    }
1191    // ----- head roll: keep the eyes level (apart from a deliberate head tilt) -----
1192    if (B.head && window.__stabQA !== 0) {
1193      B.head.getWorldQuaternion(qB);
1194      const fwdH = vA.set(0, 0, 1).applyQuaternion(qB), rightH = vB.set(1, 0, 0).applyQuaternion(qB);
1195      const roll = Math.asin(clamp(rightH.y, -1, 1)) - (st.headRollRest ?? (st.headRollRest = Math.asin(clamp(rightH.y, -1, 1))));
1196      const keep = (st.tilt?.v || 0);                                      // the cute head tilt stays
1197      const k = .7 * (1 - (full ? aw : 0)) * (1 - st.air);
1198      turnWorld(B.head, fwdH.normalize(), -roll * k * (Math.abs(keep) > .02 ? .3 : 1));   // rotating about +forward raises the right side
1199    }
1200
1201    // ----- secondary motion: springs driven by the head's acceleration -----
1202    if (B.head) {
1203      B.head.getWorldPosition(vA);
1204      if (!st.headPrev) st.headPrev = vA.clone();
1205      vB.copy(vA).sub(st.headPrev).divideScalar(Math.max(dt, 1e-3)); st.headPrev.copy(vA);
1206      st.headAcc.copy(vB).sub(st.headVel).divideScalar(Math.max(dt, 1e-3)); st.headVel.lerp(vB, .5);
1207      const acc = st.headAcc.clone().applyQuaternion(invRootQ), vel = st.headVel.clone().applyQuaternion(invRootQ);
1208      // hair lags behind: sideways and back/forward; also pushed back by speed (air drag)
1209      // the same breeze that sways the grass lifts her hair a little
1210      st.windT = (st.windT || 0) + dt; const breeze = (Math.sin(st.windT * 1.7) * .5 + Math.sin(st.windT * 2.9 + 1.3) * .2) * .12;
1211      const hx = spring(st.hairX, clamp(-acc.x * .02 - vel.x * .12 + breeze, -1, 1), 60, 7, dt);
1212      const hz = spring(st.hairZ, clamp(-acc.z * .015 + Math.max(0, vel.z) * .22 + (m.vy || 0) * -.05, -.6, 1), 50, 6, dt);
1213      const tx = spring(st.tailX, clamp(-acc.x * .012 - vel.x * .08 - st.yawRate * speed * .05 + breeze * .6, -.7, .7), 34, 4.5, dt);
1214      const tz = spring(st.tailZ, clamp(-acc.z * .01 + vel.z * .12 - (m.vy || 0) * .12 + acc.y * -.004, -.6, .8), 30, 4, dt);
1215      if (hairMesh) {
1216        const inf = hairMesh.morphTargetInfluences, iL = morphI(hairMesh, 'Hair_Sway_L'), iR = morphI(hairMesh, 'Hair_Sway_R'), bL = morphI(hairMesh, 'Hair_Back_L'), bR = morphI(hairMesh, 'Hair_Back_R');
1217        inf[iL] = clamp(inf[iL] + Math.max(0, hx), 0, 1); inf[iR] = clamp(inf[iR] + Math.max(0, -hx), 0, 1);
1218        inf[bL] = clamp(inf[bL] + hz, 0, 1); inf[bR] = clamp(inf[bR] + hz, 0, 1);
1219      }
1220      // hair tails swing from their roots
1221      turnWorld(B.tailL, axZ, tx * .9); turnWorld(B.tailR, axZ, tx * .9);
1222      turnWorld(B.tailL, axX, tz * .8); turnWorld(B.tailR, axX, tz * .8);
1223      st.tails = [tx, tz]; st.hair = [hx, hz];
1224      // bag: a heavier, slower pendulum driven by the same motion
1225      const bx = spring(st.bagX || (st.bagX = [0, 0]), clamp(-acc.z * .012 + vel.z * .05 - (m.vy || 0) * .05, -.45, .45), 26, 3.2, dt);
1226      const bz = spring(st.bagZ || (st.bagZ = [0, 0]), clamp(acc.x * .01 + vel.x * .05 + st.yawRate * speed * .06, -.2, .45), 26, 3.2, dt);
1227      bagU.value.set(window.__bagQA?.[0] ?? -bx, window.__bagQA?.[1] ?? Math.max(-.15, bz));
1228    }
1229    if (hemMesh) {
1230      const inf = hemMesh.morphTargetInfluences, iS = morphI(hemMesh, 'Hem_Sway'), iL = morphI(hemMesh, 'Hem_Lift');
1231      const sw = spring(st.hem, clamp(st.yawRate * speed * .12 + st.bank * .6, -1, 1), 40, 5, dt);
1232      const li = spring(st.lift, clamp(speed * .07 + st.air * .6 + Math.max(0, -(m.vy || 0)) * .08, 0, 1), 45, 6, dt);
1233      inf[iS] = clamp(inf[iS] + Math.abs(sw), 0, 1); inf[iL] = clamp(inf[iL] + li, 0, 1);
1234    }
1235  }
1236
1237  return {
1238    root, hero, clips, mixer, B, gaitSpeed, moveSpeed, loco, st, act, face, legs, eyeU, suckU, bagU, get ankleY() { return ankleY; },
1239    setupBlink, update, play, stop, busy, blocksMove,
1240    events,   // footsteps since the caller last emptied this list
1241    land(impact) { st.landS[1] -= clamp(impact, 0, 9) * .32; },
1242    lookAt(p) { st.lookTarget = p ? (st.lookTarget || new T.Vector3()).copy(p) : null; },
1243    get actionKey() { return busy() ? act.key : null; },
1244    get holdCentre() { return st.holdS > .5 ? st.holdC : null; },   // where the hugged ball sits
1245    // Put a hand at a world point (after update): arm IK, elbow down and out.
1246    // side +1 = her left, -1 = her right. Optional fingers / thumb-side
1247    // directions (world) turn the hand, e.g. to grip a knife handle.
1248    reach(side, target, w = 1, {fingers = null, thumb = null} = {}) {
1249      const arm = side > 0 ? B.armL : B.armR, fore = side > 0 ? B.foreL : B.foreR, hand = side > 0 ? B.handL : B.handR, mid = side > 0 ? B.midL : B.midR;
1250      if (!arm || !fore || !hand || w <= 0) return;
1251      const tgt = hand.getWorldPosition(new T.Vector3()).lerp(target, w);
1252      solveLeg({up: arm, knee: fore, foot: hand}, tgt, new T.Vector3(side * .7, -1, -.4).applyQuaternion(rootQ).normalize(), 1 - w);
1253      if (fingers && mid) aimBone(hand, mid, fingers.clone().normalize(), w);
1254      if (thumb && mid) {
1255        const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name));
1256        if (idx && pin) {
1257          const h = hand.getWorldPosition(new T.Vector3()), f = mid.getWorldPosition(new T.Vector3()).sub(h).normalize();
1258          const ip = idx.getWorldPosition(new T.Vector3()).sub(pin.getWorldPosition(new T.Vector3())); ip.addScaledVector(f, -ip.dot(f)).normalize();
1259          const want = thumb.clone().addScaledVector(f, -thumb.dot(f)); if (want.lengthSq() < 1e-6) return; want.normalize();
1260          turnWorld(hand, f, Math.atan2(new T.Vector3().crossVectors(ip, want).dot(f), ip.dot(want)) * w);
1261        }
1262      }
1263      hand.userData.wq?.copy(hand.getWorldQuaternion(new T.Quaternion()));   // the wrist limiter starts from here next frame
1264    },
1265  };
1266}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.