metaverse-avatar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/ASSET_LICENSES.md +122 -0
  2. package/Avatar.js +860 -0
  3. package/LICENSE.md +21 -0
  4. package/README.md +407 -0
  5. package/anims/UAL1_Standard.glb +0 -0
  6. package/anims/UAL2_Standard.glb +0 -0
  7. package/anims/pirouette.bvh +867 -0
  8. package/attachments.js +388 -0
  9. package/avatarManager.js +110 -0
  10. package/blink.js +58 -0
  11. package/bvh.js +110 -0
  12. package/gltfAnim.js +271 -0
  13. package/index.js +61 -0
  14. package/licenses/AGPL-3.0.txt +661 -0
  15. package/models/body.glb +0 -0
  16. package/models/eyes.glb +0 -0
  17. package/models/feet.glb +0 -0
  18. package/models/hands.glb +0 -0
  19. package/models/head.glb +0 -0
  20. package/models/textures/android_face.png +0 -0
  21. package/models/textures/android_face_ao.jpg +0 -0
  22. package/models/textures/android_face_metallic.jpg +0 -0
  23. package/models/textures/android_face_normal.jpg +0 -0
  24. package/models/textures/android_face_roughness.jpg +0 -0
  25. package/models/textures/android_lower.png +0 -0
  26. package/models/textures/android_lower_ao.jpg +0 -0
  27. package/models/textures/android_lower_metallic.jpg +0 -0
  28. package/models/textures/android_lower_normal.jpg +0 -0
  29. package/models/textures/android_lower_roughness.jpg +0 -0
  30. package/models/textures/android_upper.png +0 -0
  31. package/models/textures/android_upper_ao.jpg +0 -0
  32. package/models/textures/android_upper_metallic.jpg +0 -0
  33. package/models/textures/android_upper_normal.jpg +0 -0
  34. package/models/textures/android_upper_roughness.jpg +0 -0
  35. package/models/textures/blue_eyes.png +0 -0
  36. package/models/textures/layers/cute_pants.png +0 -0
  37. package/models/textures/layers/cute_shirt.png +0 -0
  38. package/nipple.js +218 -0
  39. package/package.json +48 -0
  40. package/pbr.js +225 -0
  41. package/physics.js +313 -0
  42. package/skeleton.js +70 -0
  43. package/sliders.js +590 -0
  44. package/speech.js +130 -0
  45. package/visemes.js +66 -0
  46. package/voice.js +75 -0
package/Avatar.js ADDED
@@ -0,0 +1,860 @@
1
+ import * as THREE from 'three';
2
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
+ import { computeBoneAdjustments } from './sliders.js';
4
+ import { buildNippleMorph, applyNippleMorph } from './nipple.js';
5
+ import { SoftBodyPhysics } from './physics.js';
6
+ import { PBRMaterialStack } from './pbr.js';
7
+ import { Blinker } from './blink.js';
8
+ import { VoiceMouth } from './voice.js';
9
+ import { SpeechMouth } from './speech.js';
10
+ import { Attachments } from './attachments.js';
11
+
12
+ const SKIN = new THREE.MeshStandardMaterial({
13
+ color: 0xd9a78b,
14
+ roughness: 0.6,
15
+ metalness: 0.0,
16
+ side: THREE.DoubleSide, // hides the neck-seam backfaces where head and body overlap
17
+ });
18
+
19
+ const EYE = new THREE.MeshStandardMaterial({
20
+ color: 0xf2f2f5,
21
+ roughness: 0.25,
22
+ metalness: 0.0,
23
+ });
24
+
25
+ // Ruth avatars are textured per region (head / upper incl. hands / lower incl.
26
+ // feet / eyes); the dae material slot names tell us which region each face
27
+ // group belongs to. Default MIT-licensed maps in models/textures/; replace via drop slots.
28
+ // Each region seeds the matching PBR channels (albedo + normal/roughness/
29
+ // metallic/ao where shipped); the eyes only have an albedo map.
30
+ const DEFAULT_SKIN = {
31
+ face: {
32
+ albedo: 'textures/android_face.png',
33
+ normal: 'textures/android_face_normal.jpg',
34
+ roughness: 'textures/android_face_roughness.jpg',
35
+ metallic: 'textures/android_face_metallic.jpg',
36
+ ao: 'textures/android_face_ao.jpg',
37
+ },
38
+ upper: {
39
+ albedo: 'textures/android_upper.png',
40
+ normal: 'textures/android_upper_normal.jpg',
41
+ roughness: 'textures/android_upper_roughness.jpg',
42
+ metallic: 'textures/android_upper_metallic.jpg',
43
+ ao: 'textures/android_upper_ao.jpg',
44
+ },
45
+ lower: {
46
+ albedo: 'textures/android_lower.png',
47
+ normal: 'textures/android_lower_normal.jpg',
48
+ roughness: 'textures/android_lower_roughness.jpg',
49
+ metallic: 'textures/android_lower_metallic.jpg',
50
+ ao: 'textures/android_lower_ao.jpg',
51
+ },
52
+ eyes: {
53
+ albedo: 'textures/blue_eyes.png',
54
+ },
55
+ };
56
+
57
+ const TEXTURE_SIZE = 1024;
58
+
59
+ // Eye look-at: clamp how far the eyeballs can swing from rest so they never
60
+ // roll back into the skull when the target is behind / extreme.
61
+ const EYE_LOOK_MAX_YAW = 0.55;
62
+ const EYE_LOOK_MAX_PITCH = 0.4;
63
+ const _lookM4 = new THREE.Matrix4();
64
+ const _lookV1 = new THREE.Vector3();
65
+ const _lookV2 = new THREE.Vector3();
66
+ const _lookQ0 = new THREE.Quaternion();
67
+ const _lookQ1 = new THREE.Quaternion();
68
+ const _lookE = new THREE.Euler();
69
+ const _EYE_FWD = new THREE.Vector3(1, 0, 0);
70
+
71
+ // Regions that accept stacked clothing layers (face/eyes are skin-only).
72
+ export const LAYERED_REGIONS = ['upper', 'lower'];
73
+
74
+ // Per-region PBR stack defaults. Skin tone base color shows through when an
75
+ // albedo map is cleared; eyes start glossier than skin.
76
+ const REGION_DEFAULTS = {
77
+ face: { layered: false, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
78
+ upper: { layered: true, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
79
+ lower: { layered: true, baseColor: 0xd9a78b, roughness: 0.55, metalness: 0.0 },
80
+ eyes: { layered: false, baseColor: 0xf2f2f5, roughness: 0.25, metalness: 0.0 },
81
+ };
82
+ // Global roughness/metalness sliders drive the skin regions (not the eyes).
83
+ const SKIN_REGIONS = ['face', 'upper', 'lower'];
84
+
85
+ function loadImage(url) {
86
+ return new Promise((resolve, reject) => {
87
+ const img = new Image();
88
+ img.crossOrigin = 'anonymous';
89
+ img.onload = () => resolve(img);
90
+ img.onerror = () => reject(new Error('failed to load image: ' + url));
91
+ img.src = url;
92
+ });
93
+ }
94
+
95
+ function regionForMaterial(materialName) {
96
+ const n = materialName.toLowerCase();
97
+ if (n.includes('eye')) return 'eyes';
98
+ if (n.includes('head')) return 'face';
99
+ if (n.includes('upper') || n.includes('hand')) return 'upper';
100
+ return 'lower'; // mat_body_lower, mat_feet_*
101
+ }
102
+
103
+ // The Ruth2 RC3 avatar is split into separate rigged glTF part files (converted
104
+ // from the original Collada). Each file carries its own copy of the (relevant
105
+ // subset of the) avatar skeleton, so we keep one skeleton per part and drive them
106
+ // all in sync by bone name.
107
+ // Default part files (override per-call via load(basePath, { parts })). The .glb
108
+ // exports carry the Z-up Ruth armature; pass your own .glb/.gltf URLs to swap
109
+ // meshes (same rig → same sliders/physics/anim).
110
+ const DEFAULT_PARTS = {
111
+ body: 'body.glb', // Release3_BothLowerUpper_15
112
+ hands: 'hands.glb', // Release3_Hands_15 (Bento fingers)
113
+ feet: 'feet.glb', // Release3_FlatFeet_15
114
+ head: 'head.glb', // Ruth2v4Head (RC3 has no head; the v4 head fits the RC3 body)
115
+ eyes: 'eyes.glb', // Ruth2v4Eyeballs
116
+ };
117
+
118
+ const _gltfLoader = new GLTFLoader();
119
+
120
+ // Load one part file (glTF/GLB). The exports ship the armature collapsed (bind
121
+ // pose only in the inverse-bind matrices), so the caller recovers it with
122
+ // poseFromBind.
123
+ async function loadPartScene(url) {
124
+ return (await _gltfLoader.loadAsync(url)).scene;
125
+ }
126
+
127
+ // Resolve a part/texture entry against basePath. An absolute URL — a scheme
128
+ // (https:, data:, blob:…), protocol-relative (//…), or root-relative (/…) — is
129
+ // used as-is, so a bundler/CDN can hand back fingerprinted per-file URLs (e.g.
130
+ // `import bodyUrl from './body.glb'`). A plain relative name joins basePath.
131
+ const isAbsoluteUrl = (s) => /^([a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(s);
132
+ const resolveUrl = (basePath, file) => (isAbsoluteUrl(file) ? file : basePath + file);
133
+
134
+ // Like THREE.Skeleton.pose(), but across all of a part's skins at once and
135
+ // ignoring placeholder bind matrices. The exports ship the armature collapsed
136
+ // (the real rest pose lives only in the inverse-bind matrices), and a part with
137
+ // multiple skins (the v4 eyeballs) puts every bone in every skeleton while only
138
+ // the mesh's own joints get real inverse binds — the rest are identity, so
139
+ // calling skeleton.pose() per mesh would snap those bones to the origin.
140
+ function poseFromBind(root) {
141
+ const identity = new THREE.Matrix4();
142
+ const bindWorld = new Map(); // Bone -> bind-pose world matrix
143
+ root.updateMatrixWorld(true);
144
+ root.traverse((obj) => {
145
+ if (!obj.isSkinnedMesh) return;
146
+ obj.skeleton.bones.forEach((bone, i) => {
147
+ const inv = obj.skeleton.boneInverses[i];
148
+ if (bindWorld.has(bone) || inv.equals(identity)) return;
149
+ bindWorld.set(bone, inv.clone().invert());
150
+ });
151
+ });
152
+ const local = new THREE.Matrix4();
153
+ const parentInv = new THREE.Matrix4();
154
+ for (const [bone, world] of bindWorld) {
155
+ local.copy(world);
156
+ if (bone.parent && bone.parent.isBone) {
157
+ parentInv.copy(bindWorld.get(bone.parent) ?? bone.parent.matrixWorld).invert();
158
+ local.premultiply(parentInv);
159
+ }
160
+ local.decompose(bone.position, bone.quaternion, bone.scale);
161
+ }
162
+ root.updateMatrixWorld(true);
163
+ }
164
+
165
+ // A fully self-contained, independently-controllable avatar. Each `new Avatar()`
166
+ // owns its own scene graph (`group`), skeleton, materials, animation state, and
167
+ // the per-avatar capabilities — pec/glute physics, procedural blinking,
168
+ // microphone + TTS lip-sync, prop attachments, and eye look-at. Nothing is
169
+ // shared between instances, so two avatars can be loaded into the same scene and
170
+ // driven completely separately:
171
+ //
172
+ // const a = await new Avatar().load('models/');
173
+ // const b = await new Avatar().load('models/');
174
+ // scene.add(a.group, b.group);
175
+ // // ...each frame:
176
+ // a.update(dt); b.update(dt);
177
+ //
178
+ // `update(dt)` advances everything the avatar does on its own (animation,
179
+ // physics, blinking, lip-sync, look-at). Locomotion is intentionally NOT part
180
+ // of the avatar — how a figure moves around is the game's concern. Move it by
181
+ // driving `avatar.group` + the animation methods (playClip/crossFadeTo/…)
182
+ // directly, or copy the standalone `Locomotion` helper (examples/common/locomotion.js).
183
+ export class Avatar {
184
+ constructor() {
185
+ this.group = new THREE.Group();
186
+ this.parts = {}; // name -> { root, bones: Map<name, Bone>, rest: Map<name, {p,q,s}>, mixer, action }
187
+ this.pelvisRestZ = 1.067; // overwritten from the loaded rig
188
+ this.clip = null;
189
+ this.paused = false;
190
+ this.timeScale = 1;
191
+ this._partClips = new Map(); // clip -> Map(part -> per-part AnimationClip|null), cached
192
+ this._fades = []; // actions ramping out; { action, mixer, clip, t } — stopped at t<=0
193
+ this._regions = {}; // region -> PBRMaterialStack
194
+ this._textured = true;
195
+ this.pecPhysics = new SoftBodyPhysics(this);
196
+ // Glute jiggle/sag — single BUTT collision volume, sag aimed by the pelvis.
197
+ this.glutePhysics = new SoftBodyPhysics(this, { bones: ['BUTT'], trackBones: ['mPelvis'] });
198
+
199
+ // ---- composed per-avatar capabilities ----
200
+ // Each takes this avatar and holds its own state, so they're independent
201
+ // per instance. update() ticks them every frame.
202
+ this.blinker = new Blinker(this); // procedural eye blinks
203
+ this.voice = new VoiceMouth(this); // microphone-driven jaw
204
+ this.speech = new SpeechMouth(this); // TTS-clip lip-sync (visemes)
205
+ this.attachments = new Attachments(this); // props parented to bones
206
+
207
+ // Eye look-at — each avatar tracks its own world-space target, so several
208
+ // avatars can each gaze somewhere different. update() aims the eyeballs.
209
+ this.lookAt = { enabled: false, target: new THREE.Vector3(), _was: false };
210
+ }
211
+
212
+ async load(basePath, { parts = DEFAULT_PARTS } = {}) {
213
+ if (typeof basePath !== 'string' || !basePath) {
214
+ throw new Error(
215
+ 'Avatar.load(basePath): basePath is required — pass the URL/path to the ' +
216
+ "directory holding the model files (e.g. 'models/', '../../models/', or " +
217
+ "new URL('models/', import.meta.url).href). It must end with a slash."
218
+ );
219
+ }
220
+ this._basePath = basePath;
221
+
222
+ // One PBR material stack per region; seed each with its default albedo.
223
+ for (const [region, cfg] of Object.entries(REGION_DEFAULTS)) {
224
+ this._regions[region] = new PBRMaterialStack({ size: TEXTURE_SIZE, ...cfg });
225
+ }
226
+ await Promise.all(
227
+ Object.entries(DEFAULT_SKIN).flatMap(([region, channels]) =>
228
+ Object.entries(channels).map(async ([channel, file]) => {
229
+ this._regions[region]?.setSkinMap(channel, await loadImage(resolveUrl(basePath, file)));
230
+ })
231
+ )
232
+ );
233
+
234
+ const loads = Object.entries(parts).map(async ([name, file]) => {
235
+ const url = resolveUrl(basePath, file);
236
+ const root = await loadPartScene(url);
237
+ root.updateMatrixWorld(true);
238
+ root.traverse((obj) => {
239
+ if (obj.isSkinnedMesh) {
240
+ // aoMap samples the second UV set; reuse the primary UVs so AO
241
+ // works without a dedicated lightmap channel.
242
+ const uv = obj.geometry.getAttribute('uv');
243
+ if (uv && !obj.geometry.getAttribute('uv1')) obj.geometry.setAttribute('uv1', uv);
244
+ const orig = Array.isArray(obj.material) ? obj.material : [obj.material];
245
+ const textured = orig.map((m) => this._materialForRegion(regionForMaterial(m.name || '')));
246
+ const plain = orig.map(() => (name === 'eyes' ? EYE : SKIN));
247
+ obj.userData.materialSets = {
248
+ textured: Array.isArray(obj.material) ? textured : textured[0],
249
+ plain: Array.isArray(obj.material) ? plain : plain[0],
250
+ };
251
+ obj.material = obj.userData.materialSets.textured;
252
+ obj.castShadow = true;
253
+ obj.receiveShadow = true;
254
+ // Bone-scale sliders and BVH clips move verts well outside the
255
+ // static bounding box; never let three.js cull the avatar away.
256
+ obj.frustumCulled = false;
257
+ }
258
+ });
259
+ // The exports' visual-scene node transforms don't match the skin bind
260
+ // pose; recover the true rest pose from the inverse bind matrices
261
+ // before we capture it below.
262
+ poseFromBind(root);
263
+ // Stand the rig up. The Ruth armature is Z-up in bone-local space, and our
264
+ // glTF parts are exported Z-up (Blender export_yup=false) to keep those
265
+ // bone-local rests — so they arrive lying down. Detect that (the rig's up,
266
+ // local +Z, still pointing at world +Z) and rotate the root -90°X. Bone-local
267
+ // rests — and therefore the sliders/physics/retarget — are unaffected.
268
+ const rigUp = new THREE.Vector3(0, 0, 1).transformDirection(root.matrixWorld);
269
+ if (rigUp.z > 0.7) { root.rotateX(-Math.PI / 2); root.updateMatrixWorld(true); }
270
+ const bones = new Map();
271
+ const rest = new Map();
272
+ root.traverse((obj) => {
273
+ if (obj.isBone) {
274
+ if (bones.has(obj.name)) return;
275
+ bones.set(obj.name, obj);
276
+ rest.set(obj.name, {
277
+ p: obj.position.clone(),
278
+ q: obj.quaternion.clone(),
279
+ s: obj.scale.clone(),
280
+ });
281
+ }
282
+ });
283
+ this.parts[name] = { root, bones, rest, mixer: null, action: null };
284
+ this.group.add(root);
285
+ });
286
+ await Promise.all(loads);
287
+
288
+ const pelvis = this.parts.body.rest.get('mPelvis');
289
+ if (pelvis) this.pelvisRestZ = pelvis.p.z;
290
+
291
+ // Nipple morph: locate the body skinned mesh and precompute the apex/falloff
292
+ // (no-op on rigs without pec-weighted geometry). Driven by the 'nipple' slider.
293
+ this.parts.body.root.traverse((obj) => {
294
+ if (obj.isSkinnedMesh && !this._nippleMorph) this._nippleMorph = buildNippleMorph(obj);
295
+ });
296
+
297
+ this.graftHeadParts();
298
+ this.pecPhysics.captureBasePositions();
299
+ this.glutePhysics.captureBasePositions();
300
+ return this;
301
+ }
302
+
303
+ // The Ruth2 v4 head and eyeball exports have flat skeletons: their bones
304
+ // (NECK, HEAD, mFaceRoot / mEyeLeft, mEyeRight) hang directly off the
305
+ // armature root with no spine chain, so on their own they can't follow
306
+ // the body. Graft them into the body's skeleton instead: synthesize the
307
+ // missing mHead bone under the body's mNeck (Ruth skeleton offset), then
308
+ // re-parent each head/eye root bone into the chain. attach() preserves
309
+ // world transforms, and the SkinnedMeshes keep referencing the same Bone
310
+ // objects, so the skins are unaffected — they just inherit body motion.
311
+ graftHeadParts() {
312
+ const body = this.parts.body;
313
+ const bodyNeck = body?.bones.get('mNeck');
314
+ if (!bodyNeck) return;
315
+
316
+ const mHead = new THREE.Bone();
317
+ mHead.name = 'mHead';
318
+ bodyNeck.add(mHead);
319
+ mHead.position.set(0, 0, 0.076); // avatar_skeleton.xml: mHead offset from mNeck
320
+ body.bones.set('mHead', mHead);
321
+ body.rest.set('mHead', {
322
+ p: mHead.position.clone(),
323
+ q: mHead.quaternion.clone(),
324
+ s: mHead.scale.clone(),
325
+ });
326
+
327
+ this.group.updateMatrixWorld(true);
328
+ for (const name of ['head', 'eyes']) {
329
+ const part = this.parts[name];
330
+ if (!part) continue;
331
+ part.grafted = true;
332
+ for (const bone of part.bones.values()) {
333
+ if (bone.parent && bone.parent.isBone) continue; // only armature-root bones
334
+ (bone.name === 'NECK' ? bodyNeck : mHead).attach(bone);
335
+ }
336
+ // re-capture rest transforms: attach() rewrote the root bones' locals
337
+ for (const [boneName, bone] of part.bones) {
338
+ part.rest.set(boneName, {
339
+ p: bone.position.clone(),
340
+ q: bone.quaternion.clone(),
341
+ s: bone.scale.clone(),
342
+ });
343
+ }
344
+ }
345
+ }
346
+
347
+ setPartVisible(name, visible) {
348
+ const part = this.parts[name];
349
+ if (part) part.root.visible = visible;
350
+ }
351
+
352
+ setTextured(textured) {
353
+ this._textured = textured;
354
+ this._applySurfaceMaterials();
355
+ }
356
+
357
+ // ---- PBR textures --------------------------------------------------
358
+ // region is one of 'face' | 'upper' | 'lower' | 'eyes'; channel is one of
359
+ // 'albedo' | 'normal' | 'roughness' | 'metallic' | 'ao'. Clothing layers
360
+ // (upper/lower only) stack on top of the skin, masked by their albedo.
361
+
362
+ getRegions() {
363
+ return Object.keys(this._regions);
364
+ }
365
+
366
+ getDefaultSkinLabel(region) {
367
+ const file = DEFAULT_SKIN[region]?.albedo;
368
+ return file ? file.split('/').pop() : '';
369
+ }
370
+
371
+ setSkinMap(region, channel, img) {
372
+ this._regions[region]?.setSkinMap(channel, img ?? null);
373
+ }
374
+
375
+ getSkinMap(region, channel) {
376
+ return this._regions[region]?.skin[channel] ?? null;
377
+ }
378
+
379
+ getClothingLayers(region) {
380
+ return this._regions[region]?.layers ?? [];
381
+ }
382
+
383
+ addClothingLayer(region) {
384
+ return this._regions[region]?.addLayer() ?? -1;
385
+ }
386
+
387
+ removeClothingLayer(region, i) {
388
+ this._regions[region]?.removeLayer(i);
389
+ }
390
+
391
+ moveClothingLayer(region, i, dir) {
392
+ this._regions[region]?.moveLayer(i, dir);
393
+ }
394
+
395
+ setLayerMap(region, i, channel, img) {
396
+ this._regions[region]?.setLayerMap(i, channel, img ?? null);
397
+ }
398
+
399
+ getLayerMap(region, i, channel) {
400
+ return this._regions[region]?.layers[i]?.maps[channel] ?? null;
401
+ }
402
+
403
+ setLayerVisible(region, i, visible) {
404
+ this._regions[region]?.setLayerVisible(i, visible);
405
+ }
406
+
407
+ // Roughness/metalness as a constant value where a region has no such map.
408
+ setGlobalRoughness(v) {
409
+ for (const r of SKIN_REGIONS) this._regions[r]?.setGlobalRoughness(v);
410
+ }
411
+
412
+ setGlobalMetalness(v) {
413
+ for (const r of SKIN_REGIONS) this._regions[r]?.setGlobalMetalness(v);
414
+ }
415
+
416
+ _materialForRegion(region) {
417
+ return this._regions[region]?.material;
418
+ }
419
+
420
+ // ---- lip sync ------------------------------------------------------
421
+
422
+ // Open the jaw, t in [0, 1]. Rotates mFaceJaw about the same axis the
423
+ // "Mouth Open" shape slider uses, relative to its captured rest pose.
424
+ setMouthOpen(t) {
425
+ t = THREE.MathUtils.clamp(t, 0, 1);
426
+ if (!this._jaw) {
427
+ for (const part of Object.values(this.parts)) {
428
+ const bone = part.bones.get('mFaceJaw');
429
+ if (bone) { this._jaw = { bone, rest: part.rest.get('mFaceJaw') }; break; }
430
+ }
431
+ this._jawEuler = new THREE.Euler();
432
+ this._jawQuat = new THREE.Quaternion();
433
+ }
434
+ if (!this._jaw?.bone || !this._jaw.rest) return;
435
+ this._jawEuler.set(0, 0.6 * t, 0);
436
+ this._jaw.bone.quaternion.copy(this._jaw.rest.q).multiply(this._jawQuat.setFromEuler(this._jawEuler));
437
+ }
438
+
439
+ // Blink, t in [0, 1]: 0 = eyes open (rest), 1 = fully closed. Rotates the
440
+ // Bento eyelid bones with the same Y-axis amounts the "Eye Closed" shape
441
+ // sliders use (upper lids 0.55, lower lids -0.15), relative to each lid's
442
+ // captured rest pose — same approach as setMouthOpen.
443
+ setBlink(t) {
444
+ t = THREE.MathUtils.clamp(t, 0, 1);
445
+ if (!this._lids) {
446
+ this._lids = [];
447
+ this._blinkEuler = new THREE.Euler();
448
+ this._blinkQuat = new THREE.Quaternion();
449
+ const spec = [
450
+ ['mFaceEyeLidUpperLeft', 0.55], ['mFaceEyeLidLowerLeft', -0.15],
451
+ ['mFaceEyeLidUpperRight', 0.55], ['mFaceEyeLidLowerRight', -0.15],
452
+ ];
453
+ for (const [name, ry] of spec) {
454
+ for (const part of Object.values(this.parts)) {
455
+ const bone = part.bones.get(name);
456
+ if (bone) { this._lids.push({ bone, rest: part.rest.get(name), ry }); break; }
457
+ }
458
+ }
459
+ }
460
+ for (const lid of this._lids) {
461
+ if (!lid.bone || !lid.rest) continue;
462
+ this._blinkEuler.set(0, lid.ry * t, 0);
463
+ lid.bone.quaternion.copy(lid.rest.q).multiply(this._blinkQuat.setFromEuler(this._blinkEuler));
464
+ }
465
+ }
466
+
467
+ // Viseme mouth shaping for speech lip-sync. Controls are each 0..1:
468
+ // open — jaw drop (vowels)
469
+ // round — lip pucker / protrude (o, u, w)
470
+ // wide — lip spread (e, i)
471
+ // Magnitudes reuse the matching shape sliders (mouth_open jaw rot 0.45,
472
+ // mouth_width corner ±0.008, lip protrusion lips +0.005), applied relative to
473
+ // each bone's rest — so all-zero restores the resting (closed) mouth.
474
+ setMouth({ open = 0, round = 0, wide = 0 } = {}) {
475
+ open = THREE.MathUtils.clamp(open, 0, 1);
476
+ round = THREE.MathUtils.clamp(round, 0, 1);
477
+ wide = THREE.MathUtils.clamp(wide, 0, 1);
478
+ if (!this._mouth) {
479
+ this._mouthEuler = new THREE.Euler();
480
+ this._mouthQuat = new THREE.Quaternion();
481
+ const find = (name) => {
482
+ for (const part of Object.values(this.parts)) {
483
+ const bone = part.bones.get(name);
484
+ if (bone) return { bone, rest: part.rest.get(name) };
485
+ }
486
+ return null;
487
+ };
488
+ this._mouth = { jaw: find('mFaceJaw'), corners: [], lips: [] };
489
+ // corner sign: +1 = left lip corner (moves +Y to widen), -1 = right
490
+ for (const [name, sign] of [['mFaceLipCornerLeft', 1], ['mFaceLipCornerRight', -1]]) {
491
+ const f = find(name);
492
+ if (f) this._mouth.corners.push({ ...f, sign });
493
+ }
494
+ for (const name of ['mFaceLipUpperLeft', 'mFaceLipUpperRight', 'mFaceLipUpperCenter',
495
+ 'mFaceLipLowerLeft', 'mFaceLipLowerRight', 'mFaceLipLowerCenter']) {
496
+ const f = find(name);
497
+ if (f) this._mouth.lips.push(f);
498
+ }
499
+ }
500
+ const m = this._mouth;
501
+ if (m.jaw?.bone && m.jaw.rest) {
502
+ this._mouthEuler.set(0, 0.5 * open, 0);
503
+ m.jaw.bone.quaternion.copy(m.jaw.rest.q).multiply(this._mouthQuat.setFromEuler(this._mouthEuler));
504
+ }
505
+ for (const c of m.corners) {
506
+ if (!c.rest) continue;
507
+ // widen on `wide`, pull in + forward on `round`
508
+ const y = c.sign * (0.008 * wide - 0.006 * round);
509
+ c.bone.position.set(c.rest.p.x + 0.004 * round, c.rest.p.y + y, c.rest.p.z);
510
+ }
511
+ for (const l of m.lips) {
512
+ if (!l.rest) continue;
513
+ l.bone.position.set(l.rest.p.x + 0.005 * round, l.rest.p.y, l.rest.p.z); // protrude
514
+ }
515
+ }
516
+
517
+ _applySurfaceMaterials() {
518
+ for (const part of Object.values(this.parts)) {
519
+ part.root.traverse((obj) => {
520
+ if (!obj.isSkinnedMesh || !obj.userData.materialSets) return;
521
+ obj.material = this._textured
522
+ ? obj.userData.materialSets.textured
523
+ : obj.userData.materialSets.plain;
524
+ });
525
+ }
526
+ }
527
+
528
+ // ---- shape sliders -------------------------------------------------
529
+
530
+ applyShape(state) {
531
+ const adj = computeBoneAdjustments(state);
532
+ const height = state.height ?? 0;
533
+ this.group.scale.setScalar(1 + 0.15 * height);
534
+
535
+ const euler = new THREE.Euler();
536
+ const q = new THREE.Quaternion();
537
+ for (const part of Object.values(this.parts)) {
538
+ for (const [name, bone] of part.bones) {
539
+ const rest = part.rest.get(name);
540
+ if (!rest) continue;
541
+ const a = adj.get(name);
542
+ if (a) {
543
+ bone.scale.set(rest.s.x * a.scale[0], rest.s.y * a.scale[1], rest.s.z * a.scale[2]);
544
+ bone.position.set(rest.p.x + a.offset[0], rest.p.y + a.offset[1], rest.p.z + a.offset[2]);
545
+ // rotation sliders (jaw etc.) — pose-like sliders on un-animated face bones
546
+ if (a.rot[0] || a.rot[1] || a.rot[2] || this._rotBones?.has(name)) {
547
+ euler.set(a.rot[0], a.rot[1], a.rot[2]);
548
+ bone.quaternion.copy(rest.q).multiply(q.setFromEuler(euler));
549
+ (this._rotBones ??= new Set()).add(name);
550
+ }
551
+ } else {
552
+ bone.scale.copy(rest.s);
553
+ bone.position.copy(rest.p);
554
+ }
555
+ }
556
+ }
557
+ applyNippleMorph(this._nippleMorph, state.nipple ?? 0);
558
+
559
+ this.pecPhysics.captureBasePositions();
560
+ this.glutePhysics.captureBasePositions();
561
+ }
562
+
563
+ // ---- animation -----------------------------------------------------
564
+
565
+ // Play a single clip as the whole stack (replaces any layers). Retained as
566
+ // the simple entry point used by the BVH/glTF play paths and MCP.
567
+ playClip(clip) {
568
+ this.setLayerStack([{ id: 'base', clip, loop: true }]);
569
+ }
570
+
571
+ // Set the animation layer stack. `layers` is ordered HIGHEST priority first
572
+ // (index 0 = topmost); each is { id, clip, loop }. Priority is resolved
573
+ // per track: the highest layer that animates a given bone owns it, and
574
+ // lower layers only fill bones the ones above them leave untouched. So a
575
+ // hand-only clip on top overrides just the hand while a full-body clip
576
+ // below drives everything else.
577
+ setLayerStack(layers) {
578
+ this._teardownLayers();
579
+ this.layers = (layers ?? []).filter((l) => l && l.clip);
580
+
581
+ // Per-track ownership: first (highest-priority) layer to claim a track
582
+ // name wins; lower layers' copies of that track are dropped so no two
583
+ // actions ever target the same bone (avoids three.js weight-blending).
584
+ const owner = new Map(); // track.name -> layer index
585
+ this.layers.forEach((layer, i) => {
586
+ for (const track of layer.clip.tracks) {
587
+ if (!owner.has(track.name)) owner.set(track.name, i);
588
+ }
589
+ });
590
+
591
+ this.layers.forEach((layer, i) => {
592
+ for (const part of Object.values(this.parts)) {
593
+ if (part.grafted) continue; // bones live in the body's tree
594
+ const tracks = layer.clip.tracks.filter(
595
+ (t) => owner.get(t.name) === i && part.bones.has(t.name.split('.')[0]),
596
+ );
597
+ if (tracks.length === 0) continue;
598
+ part.mixer ??= new THREE.AnimationMixer(part.root);
599
+ const partClip = new THREE.AnimationClip(`${layer.clip.name}#${layer.id}`, layer.clip.duration, tracks);
600
+ const action = part.mixer.clipAction(partClip);
601
+ action.setLoop(layer.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
602
+ action.clampWhenFinished = layer.loop === false;
603
+ action.reset();
604
+ action.setEffectiveWeight(1);
605
+ action.play();
606
+ (part.actions ??= []).push(action);
607
+ }
608
+ });
609
+
610
+ this.clip = this.layers.length ? this.layers[0].clip : null;
611
+ this.setPaused(false);
612
+ this.setSpeed(this.timeScale);
613
+ }
614
+
615
+ // Per-part sub-clip for `clip` (only the tracks whose bones live on `part`),
616
+ // cached so repeated transitions reuse the same AnimationClip — and therefore
617
+ // the same cached action — instead of leaking a new action into the mixer.
618
+ _subClip(part, clip) {
619
+ let perClip = this._partClips.get(clip);
620
+ if (!perClip) { perClip = new Map(); this._partClips.set(clip, perClip); }
621
+ if (!perClip.has(part)) {
622
+ const tracks = clip.tracks.filter((t) => part.bones.has(t.name.split('.')[0]));
623
+ perClip.set(part, tracks.length
624
+ ? new THREE.AnimationClip(`${clip.name}#${part.root?.name || 'p'}`, clip.duration, tracks)
625
+ : null);
626
+ }
627
+ return perClip.get(part);
628
+ }
629
+
630
+ // Smoothly blend from whatever is playing to `clip` over `duration` seconds,
631
+ // instead of the hard cut setLayerStack does. Each part fades its current
632
+ // action(s) out while the new one fades in; the mixer slerp-blends the
633
+ // overlap. Falls back to a hard play when nothing is playing yet.
634
+ crossFadeTo(clip, duration = 0.25, loop = true) {
635
+ if (!clip) return;
636
+ if (!this.clip || duration <= 0) { this.playClip(clip); return; }
637
+ if (clip === this.clip) return;
638
+ for (const part of Object.values(this.parts)) {
639
+ if (part.grafted) continue;
640
+ const partClip = this._subClip(part, clip);
641
+ if (!partClip) continue;
642
+ part.mixer ??= new THREE.AnimationMixer(part.root);
643
+ const action = part.mixer.clipAction(partClip);
644
+ // if we're fading back to a clip that's still fading out, reclaim its
645
+ // action instead of letting the queued stop kill it.
646
+ this._fades = this._fades.filter((f) => f.action !== action);
647
+ action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
648
+ action.clampWhenFinished = !loop;
649
+ action.reset();
650
+ action.play();
651
+ action.fadeIn(duration);
652
+ for (const old of part.actions ?? []) {
653
+ if (old === action) continue;
654
+ old.fadeOut(duration);
655
+ this._fades.push({ action: old, mixer: part.mixer, clip: old.getClip(), t: duration });
656
+ }
657
+ part.actions = [action];
658
+ }
659
+ this.clip = clip;
660
+ this.layers = [{ id: 'base', clip, loop }];
661
+ this.setPaused(false);
662
+ this.setSpeed(this.timeScale);
663
+ }
664
+
665
+ stop() {
666
+ this._teardownLayers();
667
+ this.clip = null;
668
+ }
669
+
670
+ _teardownLayers() {
671
+ this._fades.length = 0;
672
+ this._partClips.clear(); // cached sub-clips reference the about-to-die mixers
673
+ for (const part of Object.values(this.parts)) {
674
+ if (part.mixer) {
675
+ part.mixer.stopAllAction();
676
+ part.mixer.uncacheRoot(part.root); // drop cached clips so rebuilds don't leak
677
+ }
678
+ part.mixer = null;
679
+ part.action = null;
680
+ part.actions = [];
681
+ }
682
+ this.layers = [];
683
+ this.pecPhysics.reset();
684
+ this.glutePhysics.reset();
685
+ // restore rest rotations (positions/scales are owned by the sliders);
686
+ // the pelvis position is pose, not shape, so a clip's root motion must be
687
+ // undone here too or the figure stays where the animation last left it.
688
+ for (const part of Object.values(this.parts)) {
689
+ for (const [name, bone] of part.bones) {
690
+ const rest = part.rest.get(name);
691
+ if (!rest) continue;
692
+ bone.quaternion.copy(rest.q);
693
+ if (name === 'mPelvis') bone.position.copy(rest.p);
694
+ }
695
+ }
696
+ }
697
+
698
+ setPaused(paused) {
699
+ this.paused = paused;
700
+ }
701
+
702
+ setSpeed(s) {
703
+ this.timeScale = s;
704
+ for (const part of Object.values(this.parts)) {
705
+ if (part.mixer) part.mixer.timeScale = s;
706
+ }
707
+ }
708
+
709
+ get playing() {
710
+ return this.clip !== null;
711
+ }
712
+
713
+ update(dt) {
714
+ if (this.paused) return;
715
+ for (const part of Object.values(this.parts)) {
716
+ if (part.mixer) part.mixer.update(dt);
717
+ }
718
+ // retire actions that have finished fading out so they stop consuming the
719
+ // mixer (and free their cached clip) once their weight has reached zero.
720
+ if (this._fades.length) {
721
+ for (const f of this._fades) f.t -= dt;
722
+ this._fades = this._fades.filter((f) => {
723
+ if (f.t > 0) return true;
724
+ f.action.stop();
725
+ f.mixer.uncacheAction(f.clip);
726
+ return false;
727
+ });
728
+ }
729
+ this.pecPhysics.update(dt);
730
+ this.glutePhysics.update(dt);
731
+
732
+ // Expressive systems run on top of the resolved pose each frame. They are
733
+ // no-ops while inactive/disabled.
734
+ this.blinker.update(dt);
735
+ this.voice.update(dt); // microphone jaw
736
+ this.speech.update(dt); // TTS playback jaw / visemes
737
+ this._applyLookAt(); // aim the eyeballs at the look-at target
738
+ }
739
+
740
+ // ---- eye look-at ---------------------------------------------------
741
+
742
+ // Aim/track a world-space point with the eyes, and/or toggle tracking. All
743
+ // fields optional. Enabling with no target yet seeds one in front of the head.
744
+ setLookAt({ enabled, x, y, z } = {}) {
745
+ const la = this.lookAt;
746
+ if (typeof x === 'number') la.target.x = x;
747
+ if (typeof y === 'number') la.target.y = y;
748
+ if (typeof z === 'number') la.target.z = z;
749
+ if (typeof enabled === 'boolean') {
750
+ if (enabled && la.target.lengthSq() === 0) this._seedLookTarget();
751
+ la.enabled = enabled;
752
+ }
753
+ return this.getLookAt();
754
+ }
755
+
756
+ getLookAt() {
757
+ return { enabled: this.lookAt.enabled, position: this.lookAt.target.toArray() };
758
+ }
759
+
760
+ // Default look-at point: ~1 m in front of the head (matches the editor handle).
761
+ _seedLookTarget() {
762
+ const head = this._findBone('mHead');
763
+ if (!head) return;
764
+ this.group.updateMatrixWorld(true);
765
+ head.getWorldPosition(this.lookAt.target);
766
+ this.lookAt.target.x += 1.0;
767
+ this.lookAt.target.z += 0.08;
768
+ }
769
+
770
+ _findBone(name) {
771
+ for (const part of Object.values(this.parts)) {
772
+ const b = part.bones.get(name);
773
+ if (b) return b;
774
+ }
775
+ return null;
776
+ }
777
+
778
+ _applyLookAt() {
779
+ const la = this.lookAt;
780
+ const part = this.parts.eyes;
781
+ if (!part) return;
782
+ if (la.enabled) {
783
+ this.group.updateMatrixWorld(true);
784
+ for (const name of ['mEyeLeft', 'mEyeRight']) {
785
+ const bone = part.bones.get(name);
786
+ const rest = part.rest.get(name);
787
+ if (bone && rest) this._aimEyeAt(bone, rest, la.target);
788
+ }
789
+ la._was = true;
790
+ } else if (la._was) {
791
+ // tracking just turned off — return the eyes to rest once
792
+ for (const name of ['mEyeLeft', 'mEyeRight']) {
793
+ const bone = part.bones.get(name);
794
+ const rest = part.rest.get(name);
795
+ if (bone && rest) bone.quaternion.copy(rest.q);
796
+ }
797
+ la._was = false;
798
+ }
799
+ }
800
+
801
+ // Rotate an eye bone from rest so its local +X (forward) points at the target.
802
+ _aimEyeAt(bone, rest, targetWorld) {
803
+ if (!bone.parent) return;
804
+ bone.parent.updateMatrixWorld(true);
805
+ _lookM4.copy(bone.parent.matrixWorld).invert();
806
+ _lookV1.copy(targetWorld).applyMatrix4(_lookM4); // target in the bone's parent frame
807
+ _lookV2.copy(_lookV1).sub(rest.p);
808
+ const len = _lookV2.length();
809
+ if (len < 1e-6) return;
810
+ _lookV2.divideScalar(len);
811
+ _lookQ0.setFromUnitVectors(_EYE_FWD, _lookV2);
812
+ _lookE.setFromQuaternion(_lookQ0, 'YXZ');
813
+ _lookE.x = 0;
814
+ _lookE.y = THREE.MathUtils.clamp(_lookE.y, -EYE_LOOK_MAX_YAW, EYE_LOOK_MAX_YAW);
815
+ _lookE.z = THREE.MathUtils.clamp(_lookE.z, -EYE_LOOK_MAX_PITCH, EYE_LOOK_MAX_PITCH);
816
+ bone.quaternion.copy(rest.q).multiply(_lookQ1.setFromEuler(_lookE));
817
+ }
818
+
819
+ // ---- capability facades -------------------------------------------
820
+ // Thin convenience wrappers over the composed members, so common actions
821
+ // read as avatar verbs. The members themselves stay public for full control.
822
+
823
+ // Microphone lip-sync.
824
+ startMic() { return this.voice.start(); }
825
+ stopMic() { this.voice.stop(); }
826
+
827
+ // Play a TTS audio clip and lip-sync to it (per-viseme if visemeUrl given).
828
+ speak(url, visemeUrl) { return this.speech.play(url, visemeUrl); }
829
+ stopSpeaking() { this.speech.stop(); }
830
+
831
+ // Procedural blinking.
832
+ setBlinking(on) { this.blinker.setEnabled(on); }
833
+ blinkNow() { this.blinker.blinkNow(); }
834
+
835
+ // Attach a prop to a bone (built-in factory or loaded GLB file).
836
+ attachBuiltin(name, boneName, factory, offset) {
837
+ return this.attachments.attachBuiltin(name, boneName, factory, offset);
838
+ }
839
+ attachFile(file, boneName, offset) {
840
+ return this.attachments.attachFile(file, boneName, offset);
841
+ }
842
+
843
+ // Free every GPU resource this avatar owns. After dispose() the instance is
844
+ // dead; remove its group from the scene first.
845
+ dispose() {
846
+ this.stop();
847
+ this.voice.stop();
848
+ this.speech.stop();
849
+ this.attachments.clear();
850
+ for (const region of Object.values(this._regions)) region.dispose?.();
851
+ this.group.traverse((obj) => {
852
+ if (obj.isSkinnedMesh || obj.isMesh) obj.geometry?.dispose?.();
853
+ });
854
+ this.group.removeFromParent();
855
+ }
856
+ }
857
+
858
+ // Deprecated alias — the class was renamed RuthAvatar → Avatar when the
859
+ // per-avatar capabilities were folded in. Kept so existing imports keep working.
860
+ export { Avatar as RuthAvatar };