@lovelace_lol/loom3 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1667 @@
1
+ # Loom3
2
+
3
+ The missing character controller for Three.js, allowing you to bring humanoid and animal characters to life. Loom3 is based on the Facial Action Coding System (FACS) as the basis of its mappings, providing a morph and bone mapping library for controlling high-definition 3D characters in Three.js.
4
+
5
+ Loom3 provides mappings that connect [Facial Action Coding System (FACS)](https://en.wikipedia.org/wiki/Facial_Action_Coding_System) Action Units to the morph targets and bone transforms found in Character Creator 4 (CC4) characters. Instead of manually figuring out which blend shapes correspond to which facial movements, you can simply say `setAU(12, 0.8)` and the library handles the rest.
6
+
7
+ > **Note:** If you previously used the `loomlarge` npm package, it has been renamed to `loom3`.
8
+
9
+ > **Screenshot placeholder:** Add a hero image showing a character with facial expressions controlled by Loom3
10
+
11
+ ---
12
+
13
+ ## Table of Contents
14
+
15
+ 1. [Installation & Setup](#1-installation--setup)
16
+ 2. [Using Presets](#2-using-presets)
17
+ 3. [Getting to Know Your Character](#3-getting-to-know-your-character)
18
+ 4. [Extending & Custom Presets](#4-extending--custom-presets)
19
+ 5. [Creating Skeletal Animation Presets](#5-creating-skeletal-animation-presets)
20
+ 6. [Action Unit Control](#6-action-unit-control)
21
+ 7. [Mix Weight System](#7-mix-weight-system)
22
+ 8. [Composite Rotation System](#8-composite-rotation-system)
23
+ 9. [Continuum Pairs](#9-continuum-pairs)
24
+ 10. [Direct Morph Control](#10-direct-morph-control)
25
+ 11. [Viseme System](#11-viseme-system)
26
+ 12. [Transition System](#12-transition-system)
27
+ 13. [Playback & State Control](#13-playback--state-control)
28
+ 14. [Hair Physics](#14-hair-physics)
29
+ 15. [Baked Animations](#15-baked-animations)
30
+
31
+ ---
32
+
33
+ ## 1. Installation & Setup
34
+
35
+ > **Screenshot placeholder:** Add a screenshot of a project structure with Loom3 installed
36
+
37
+ ### Install the package
38
+
39
+ ```bash
40
+ npm install loom3
41
+ ```
42
+
43
+ ### Peer dependency
44
+
45
+ Loom3 requires Three.js as a peer dependency:
46
+
47
+ ```bash
48
+ npm install three
49
+ ```
50
+
51
+ ### Basic setup
52
+
53
+ ```typescript
54
+ import * as THREE from 'three';
55
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
56
+ import { Loom3, collectMorphMeshes, CC4_PRESET } from 'loom3';
57
+
58
+ // 1. Create the Loom3 controller with a preset
59
+ const loom = new Loom3({ profile: CC4_PRESET });
60
+
61
+ // 2. Set up your Three.js scene
62
+ const scene = new THREE.Scene();
63
+ const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 100);
64
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
65
+ renderer.setSize(window.innerWidth, window.innerHeight);
66
+ document.body.appendChild(renderer.domElement);
67
+
68
+ // 3. Load your character model
69
+ const loader = new GLTFLoader();
70
+ loader.load('/character.glb', (gltf) => {
71
+ scene.add(gltf.scene);
72
+
73
+ // 4. Collect all meshes that have morph targets
74
+ const meshes = collectMorphMeshes(gltf.scene);
75
+
76
+ // 5. Initialize Loom3 with the meshes and model
77
+ loom.onReady({ meshes, model: gltf.scene });
78
+ });
79
+
80
+ // 6. In your animation loop, call loom.update(deltaSeconds)
81
+ // This drives all transitions and animations
82
+ ```
83
+
84
+ If you’re implementing a custom renderer, target the `LoomLarge` interface exported from `loom3`.
85
+
86
+ ### Quick start examples
87
+
88
+ Once your character is loaded, you can control facial expressions immediately:
89
+
90
+ ```typescript
91
+ // Make the character smile
92
+ loom.setAU(12, 0.8);
93
+
94
+ // Raise eyebrows
95
+ loom.setAU(1, 0.6);
96
+ loom.setAU(2, 0.6);
97
+
98
+ // Blink
99
+ loom.setAU(45, 1.0);
100
+
101
+ // Open jaw
102
+ loom.setAU(26, 0.5);
103
+
104
+ // Turn head left
105
+ loom.setAU(51, 0.4);
106
+
107
+ // Look up
108
+ loom.setAU(63, 0.6);
109
+ ```
110
+
111
+ Animate smoothly with transitions:
112
+
113
+ ```typescript
114
+ // Smile over 200ms
115
+ await loom.transitionAU(12, 0.8, 200).promise;
116
+
117
+ // Then fade back to neutral
118
+ await loom.transitionAU(12, 0, 300).promise;
119
+ ```
120
+
121
+ ### The `collectMorphMeshes` helper
122
+
123
+ This utility function traverses a Three.js scene and returns all meshes that have `morphTargetInfluences` (i.e., blend shapes). It's the recommended way to gather meshes for Loom3:
124
+
125
+ ```typescript
126
+ import { collectMorphMeshes } from 'loom3';
127
+
128
+ const meshes = collectMorphMeshes(gltf.scene);
129
+ // Returns: Array of THREE.Mesh objects with morph targets
130
+ ```
131
+
132
+ > **Screenshot placeholder:** Add a screenshot of a loaded character in the Three.js scene
133
+
134
+ ---
135
+
136
+ ## 2. Using Presets
137
+
138
+ > **Screenshot placeholder:** Add a diagram showing how presets connect AUs to morphs and bones
139
+
140
+ Presets define how FACS Action Units map to your character's morph targets and bones. Loom3 ships with `CC4_PRESET` for Character Creator 4 characters.
141
+
142
+ ### What's in a preset?
143
+
144
+ ```typescript
145
+ import { CC4_PRESET } from 'loom3';
146
+
147
+ // CC4_PRESET contains:
148
+ {
149
+ auToMorphs: {
150
+ // AU number → morph target names split by side
151
+ 1: { left: ['Brow_Raise_Inner_L'], right: ['Brow_Raise_Inner_R'], center: [] },
152
+ 12: { left: ['Mouth_Smile_L'], right: ['Mouth_Smile_R'], center: [] },
153
+ 45: { left: ['Eye_Blink_L'], right: ['Eye_Blink_R'], center: [] },
154
+ // ... 87 AUs total
155
+ },
156
+
157
+ auToBones: {
158
+ // AU number → array of bone bindings
159
+ 51: [{ node: 'HEAD', channel: 'ry', scale: -1, maxDegrees: 30 }],
160
+ 61: [{ node: 'EYE_L', channel: 'rz', scale: 1, maxDegrees: 25 }],
161
+ // ... 32 bone bindings
162
+ },
163
+
164
+ boneNodes: {
165
+ // Logical bone name → actual node name in skeleton
166
+ 'HEAD': 'CC_Base_Head',
167
+ 'JAW': 'CC_Base_JawRoot',
168
+ 'EYE_L': 'CC_Base_L_Eye',
169
+ 'EYE_R': 'CC_Base_R_Eye',
170
+ 'TONGUE': 'CC_Base_Tongue01',
171
+ },
172
+
173
+ visemeKeys: [
174
+ // 15 viseme morph names for lip-sync
175
+ 'V_EE', 'V_Er', 'V_IH', 'V_Ah', 'V_Oh',
176
+ 'V_W_OO', 'V_S_Z', 'V_Ch_J', 'V_F_V', 'V_TH',
177
+ 'V_T_L_D_N', 'V_B_M_P', 'V_K_G_H_NG', 'V_AE', 'V_R'
178
+ ],
179
+
180
+ morphToMesh: {
181
+ // Routes morph categories to specific meshes
182
+ 'face': ['CC_Base_Body'],
183
+ 'tongue': ['CC_Base_Tongue'],
184
+ 'eye': ['CC_Base_EyeOcclusion_L', 'CC_Base_EyeOcclusion_R'],
185
+ },
186
+
187
+ auMixDefaults: {
188
+ // Default morph/bone blend weights (0 = morph, 1 = bone)
189
+ 26: 0.5, // Jaw drop: 50% morph, 50% bone
190
+ 51: 0.7, // Head turn: 70% bone
191
+ },
192
+
193
+ auInfo: {
194
+ // Metadata about each AU
195
+ '12': {
196
+ name: 'Lip Corner Puller',
197
+ muscularBasis: 'zygomaticus major',
198
+ faceArea: 'Lower',
199
+ facePart: 'Mouth',
200
+ },
201
+ // ...
202
+ }
203
+ }
204
+ ```
205
+
206
+ ### Passing a preset to Loom3
207
+
208
+ ```typescript
209
+ import { Loom3, CC4_PRESET } from 'loom3';
210
+
211
+ const loom = new Loom3({ profile: CC4_PRESET });
212
+ ```
213
+
214
+ You can also resolve presets by name and apply overrides without cloning the full preset:
215
+
216
+ ```typescript
217
+ import { Loom3 } from 'loom3';
218
+
219
+ const loom = new Loom3({
220
+ presetType: 'cc4',
221
+ profile: {
222
+ auToMorphs: {
223
+ 12: { left: ['MySmile_Left'], right: ['MySmile_Right'], center: [] },
224
+ },
225
+ },
226
+ });
227
+ ```
228
+
229
+ ### Profiles (preset overrides)
230
+
231
+ A **profile** is a partial override object that extends a base preset. Use it to customize a single character without copying the full preset:
232
+
233
+ ```typescript
234
+ import type { Profile } from 'loom3';
235
+ import { Loom3 } from 'loom3';
236
+
237
+ const DAISY_PROFILE: Profile = {
238
+ morphToMesh: { face: ['Object_9'] },
239
+ annotationRegions: [
240
+ { name: 'face', bones: ['CC_Base_Head'] },
241
+ ],
242
+ };
243
+
244
+ const loom = new Loom3({
245
+ presetType: 'cc4',
246
+ profile: DAISY_PROFILE,
247
+ });
248
+ ```
249
+
250
+ > **Screenshot placeholder:** Add a screenshot showing the preset being applied to a character
251
+
252
+ ---
253
+
254
+ ## 3. Getting to Know Your Character
255
+
256
+ > **Screenshot placeholder:** Add a screenshot of the console output showing mesh and morph target information
257
+
258
+ Before customizing presets or extending mappings, it's helpful to understand what's actually in your character model. Loom3 provides several methods to inspect meshes, morph targets, and bones.
259
+
260
+ ### Listing meshes
261
+
262
+ Get all meshes in your character with their visibility and morph target counts:
263
+
264
+ ```typescript
265
+ const meshes = loom.getMeshList();
266
+ console.log(meshes);
267
+ // [
268
+ // { name: 'CC_Base_Body', visible: true, morphCount: 142 },
269
+ // { name: 'CC_Base_Tongue', visible: true, morphCount: 12 },
270
+ // { name: 'CC_Base_EyeOcclusion_L', visible: true, morphCount: 8 },
271
+ // { name: 'CC_Base_EyeOcclusion_R', visible: true, morphCount: 8 },
272
+ // { name: 'Male_Bushy_1', visible: true, morphCount: 142 },
273
+ // ...
274
+ // ]
275
+ ```
276
+
277
+ ### Listing morph targets
278
+
279
+ Get all morph target names grouped by mesh:
280
+
281
+ ```typescript
282
+ const morphs = loom.getMorphTargets();
283
+ console.log(morphs);
284
+ // {
285
+ // 'CC_Base_Body': [
286
+ // 'A01_Brow_Inner_Up', 'A02_Brow_Down_Left', 'A02_Brow_Down_Right',
287
+ // 'A04_Brow_Outer_Up_Left', 'A04_Brow_Outer_Up_Right',
288
+ // 'Mouth_Smile_L', 'Mouth_Smile_R', 'Eye_Blink_L', 'Eye_Blink_R',
289
+ // ...
290
+ // ],
291
+ // 'CC_Base_Tongue': [
292
+ // 'V_Tongue_Out', 'V_Tongue_Up', 'V_Tongue_Down', ...
293
+ // ],
294
+ // ...
295
+ // }
296
+ ```
297
+
298
+ This is invaluable when creating custom presets—you need to know the exact morph target names your character uses.
299
+
300
+ ### Listing bones
301
+
302
+ Get all resolved bones with their current positions and rotations (in degrees):
303
+
304
+ ```typescript
305
+ const bones = loom.getBones();
306
+ console.log(bones);
307
+ // {
308
+ // 'HEAD': { position: [0, 156.2, 0], rotation: [0, 0, 0] },
309
+ // 'JAW': { position: [0, 154.1, 2.3], rotation: [0, 0, 0] },
310
+ // 'EYE_L': { position: [-3.2, 160.5, 8.1], rotation: [0, 0, 0] },
311
+ // 'EYE_R': { position: [3.2, 160.5, 8.1], rotation: [0, 0, 0] },
312
+ // 'TONGUE': { position: [0, 152.3, 1.8], rotation: [0, 0, 0] },
313
+ // }
314
+ ```
315
+
316
+ ### Validation & analysis
317
+
318
+ Loom3 includes validation and analysis helpers so you can verify presets against a model and generate corrections:
319
+
320
+ ```typescript
321
+ import {
322
+ extractModelData,
323
+ analyzeModel,
324
+ validateMappings,
325
+ generateMappingCorrections,
326
+ resolvePreset,
327
+ } from 'loom3';
328
+
329
+ const preset = resolvePreset('cc4');
330
+ const modelData = extractModelData({ model, meshes, animations });
331
+ const analysis = analyzeModel(modelData, { preset });
332
+ const validation = validateMappings(modelData, preset);
333
+ const corrections = generateMappingCorrections(modelData, preset);
334
+ ```
335
+
336
+ Use these helpers to:
337
+ - Find missing morphs/bones and mesh mismatches
338
+ - Score preset compatibility
339
+ - Suggest corrections before you ship a profile
340
+
341
+ ### Controlling mesh visibility
342
+
343
+ Hide or show individual meshes:
344
+
345
+ ```typescript
346
+ // Hide hair mesh
347
+ loom.setMeshVisible('Side_part_wavy_1', false);
348
+
349
+ // Show it again
350
+ loom.setMeshVisible('Side_part_wavy_1', true);
351
+ ```
352
+
353
+ ### Adjusting material properties
354
+
355
+ Fine-tune render order, transparency, and blending for each mesh:
356
+
357
+ ```typescript
358
+ // Get current material config
359
+ const config = loom.getMeshMaterialConfig('CC_Base_Body');
360
+ console.log(config);
361
+ // {
362
+ // renderOrder: 0,
363
+ // transparent: false,
364
+ // opacity: 1,
365
+ // depthWrite: true,
366
+ // depthTest: true,
367
+ // blending: 'Normal'
368
+ // }
369
+
370
+ // Set custom material config
371
+ loom.setMeshMaterialConfig('CC_Base_EyeOcclusion_L', {
372
+ renderOrder: 10,
373
+ transparent: true,
374
+ opacity: 0.8,
375
+ blending: 'Normal' // 'Normal', 'Additive', 'Subtractive', 'Multiply', 'None'
376
+ });
377
+ ```
378
+
379
+ This is especially useful for:
380
+ - Fixing render order issues (eyebrows behind hair, etc.)
381
+ - Making meshes semi-transparent for debugging
382
+ - Adjusting blending modes for special effects
383
+
384
+ > **Screenshot placeholder:** Add before/after screenshots showing render order adjustments
385
+
386
+ ---
387
+
388
+ ## 4. Extending & Custom Presets
389
+
390
+ > **Screenshot placeholder:** Add a diagram showing preset inheritance/extension
391
+
392
+ ### Extending an existing preset
393
+
394
+ Use `mergePreset` to override specific mappings while keeping the rest:
395
+
396
+ ```typescript
397
+ import { CC4_PRESET, mergePreset } from 'loom3';
398
+
399
+ const MY_PRESET = mergePreset(CC4_PRESET, {
400
+
401
+ // Override AU12 (smile) with custom morph names
402
+ auToMorphs: {
403
+ 12: { left: ['MySmile_Left'], right: ['MySmile_Right'], center: [] },
404
+ },
405
+
406
+ // Add a new bone binding
407
+ auToBones: {
408
+ 99: [{ node: 'CUSTOM_BONE', channel: 'ry', scale: 1, maxDegrees: 45 }],
409
+ },
410
+
411
+ // Update bone node paths
412
+ boneNodes: {
413
+ 'CUSTOM_BONE': 'MyRig_CustomBone',
414
+ },
415
+ });
416
+
417
+ const loom = new Loom3({ profile: MY_PRESET });
418
+ ```
419
+
420
+ ### Creating a preset from scratch
421
+
422
+ ```typescript
423
+ import { Profile } from 'loom3';
424
+
425
+ const CUSTOM_PRESET: Profile = {
426
+ auToMorphs: {
427
+ 1: { left: ['brow_inner_up_L'], right: ['brow_inner_up_R'], center: [] },
428
+ 2: { left: ['brow_outer_up_L'], right: ['brow_outer_up_R'], center: [] },
429
+ 12: { left: ['mouth_smile_L'], right: ['mouth_smile_R'], center: [] },
430
+ 45: { left: ['eye_blink_L'], right: ['eye_blink_R'], center: [] },
431
+ },
432
+
433
+ auToBones: {
434
+ 51: [{ node: 'HEAD', channel: 'ry', scale: -1, maxDegrees: 30 }],
435
+ 52: [{ node: 'HEAD', channel: 'ry', scale: 1, maxDegrees: 30 }],
436
+ },
437
+
438
+ boneNodes: {
439
+ 'HEAD': 'head_bone',
440
+ 'JAW': 'jaw_bone',
441
+ },
442
+
443
+ visemeKeys: ['aa', 'ee', 'ih', 'oh', 'oo'],
444
+
445
+ morphToMesh: {
446
+ 'face': ['body_mesh'],
447
+ },
448
+ };
449
+ ```
450
+
451
+ ### Changing presets at runtime
452
+
453
+ ```typescript
454
+ // Switch to a different preset
455
+ loom.setProfile(ANOTHER_PRESET);
456
+
457
+ // Get current mappings
458
+ const current = loom.getProfile();
459
+ ```
460
+
461
+ > **Screenshot placeholder:** Add a screenshot showing custom preset in action
462
+
463
+ ---
464
+
465
+ ## 5. Creating Skeletal Animation Presets
466
+
467
+ > **Screenshot placeholder:** Add a screenshot showing the fish model with labeled bones
468
+
469
+ Loom3 isn't limited to humanoid characters with morph targets. You can create presets for any 3D model that uses skeletal animation, such as fish, animals, or fantasy creatures. This section explains how to create a preset for a betta fish model that has no morph targets—only bone-driven animation.
470
+
471
+ ### Understanding skeletal-only models
472
+
473
+ Some models (like fish) rely entirely on bone rotations for animation:
474
+ - **No morph targets:** All movement is skeletal
475
+ - **Hierarchical bones:** Fins and body parts follow parent rotations
476
+ - **Custom "Action Units":** Instead of FACS AUs, you define model-specific actions
477
+
478
+ ### Example: Betta Fish Preset
479
+
480
+ Here's a complete example of a preset for a betta fish:
481
+
482
+ ```typescript
483
+ import type { BoneBinding, AUInfo, CompositeRotation } from 'loom3';
484
+
485
+ // Define semantic bone mappings
486
+ export const FISH_BONE_NODES = {
487
+ ROOT: 'Armature_rootJoint',
488
+ BODY_ROOT: 'Bone_Armature',
489
+ HEAD: 'Bone001_Armature',
490
+ BODY_FRONT: 'Bone002_Armature',
491
+ BODY_MID: 'Bone003_Armature',
492
+ BODY_BACK: 'Bone004_Armature',
493
+ TAIL_BASE: 'Bone005_Armature',
494
+
495
+ // Pectoral fins (side fins)
496
+ PECTORAL_L: 'Bone046_Armature',
497
+ PECTORAL_R: 'Bone047_Armature',
498
+
499
+ // Dorsal fin (top fin)
500
+ DORSAL_ROOT: 'Bone006_Armature',
501
+
502
+ // Eyes (single mesh for both)
503
+ EYE_L: 'EYES_0',
504
+ EYE_R: 'EYES_0',
505
+ } as const;
506
+
507
+ // Define custom fish actions (analogous to FACS AUs)
508
+ export enum FishAction {
509
+ // Body orientation
510
+ TURN_LEFT = 2,
511
+ TURN_RIGHT = 3,
512
+ PITCH_UP = 4,
513
+ PITCH_DOWN = 5,
514
+ ROLL_LEFT = 6,
515
+ ROLL_RIGHT = 7,
516
+
517
+ // Tail movements
518
+ TAIL_SWEEP_LEFT = 12,
519
+ TAIL_SWEEP_RIGHT = 13,
520
+ TAIL_FIN_SPREAD = 14,
521
+ TAIL_FIN_CLOSE = 15,
522
+
523
+ // Pectoral fins
524
+ PECTORAL_L_UP = 20,
525
+ PECTORAL_L_DOWN = 21,
526
+ PECTORAL_R_UP = 22,
527
+ PECTORAL_R_DOWN = 23,
528
+
529
+ // Eye rotation
530
+ EYE_LEFT = 61,
531
+ EYE_RIGHT = 62,
532
+ EYE_UP = 63,
533
+ EYE_DOWN = 64,
534
+ }
535
+ ```
536
+
537
+ ### Defining bone bindings for movement
538
+
539
+ Map each action to bone rotations:
540
+
541
+ ```typescript
542
+ export const FISH_BONE_BINDINGS: Record<number, BoneBinding[]> = {
543
+ // Turn the fish left - affects head, front body, and mid body
544
+ [FishAction.TURN_LEFT]: [
545
+ { node: 'HEAD', channel: 'ry', scale: 1, maxDegrees: 30 },
546
+ { node: 'BODY_FRONT', channel: 'ry', scale: 1, maxDegrees: 14 },
547
+ { node: 'BODY_MID', channel: 'ry', scale: 1, maxDegrees: 5 },
548
+ ],
549
+
550
+ // Tail sweep left - cascading motion through tail bones
551
+ [FishAction.TAIL_SWEEP_LEFT]: [
552
+ { node: 'BODY_BACK', channel: 'rz', scale: 1, maxDegrees: 15 },
553
+ { node: 'TAIL_BASE', channel: 'rz', scale: 1, maxDegrees: 30 },
554
+ { node: 'TAIL_TOP', channel: 'rz', scale: 1, maxDegrees: 20 },
555
+ { node: 'TAIL_MID', channel: 'rz', scale: 1, maxDegrees: 20 },
556
+ ],
557
+
558
+ // Pectoral fin movements
559
+ [FishAction.PECTORAL_L_UP]: [
560
+ { node: 'PECTORAL_L', channel: 'rz', scale: 1, maxDegrees: 40 },
561
+ { node: 'PECTORAL_L_MID', channel: 'rz', scale: 1, maxDegrees: 20 },
562
+ ],
563
+
564
+ // Eye rotation
565
+ [FishAction.EYE_LEFT]: [
566
+ { node: 'EYE_L', channel: 'ry', scale: 1, maxDegrees: 25 },
567
+ ],
568
+ };
569
+ ```
570
+
571
+ ### Composite rotations for multi-axis control
572
+
573
+ Define how multiple AUs combine for smooth rotation:
574
+
575
+ ```typescript
576
+ export const FISH_COMPOSITE_ROTATIONS: CompositeRotation[] = [
577
+ {
578
+ node: 'HEAD',
579
+ pitch: {
580
+ aus: [FishAction.PITCH_UP, FishAction.PITCH_DOWN],
581
+ axis: 'rx',
582
+ negative: FishAction.PITCH_DOWN,
583
+ positive: FishAction.PITCH_UP
584
+ },
585
+ yaw: {
586
+ aus: [FishAction.TURN_LEFT, FishAction.TURN_RIGHT],
587
+ axis: 'ry',
588
+ negative: FishAction.TURN_LEFT,
589
+ positive: FishAction.TURN_RIGHT
590
+ },
591
+ roll: null,
592
+ },
593
+ {
594
+ node: 'TAIL_BASE',
595
+ pitch: null,
596
+ yaw: null,
597
+ roll: {
598
+ aus: [FishAction.TAIL_SWEEP_LEFT, FishAction.TAIL_SWEEP_RIGHT],
599
+ axis: 'rz',
600
+ negative: FishAction.TAIL_SWEEP_RIGHT,
601
+ positive: FishAction.TAIL_SWEEP_LEFT
602
+ },
603
+ },
604
+ {
605
+ node: 'EYE_L',
606
+ pitch: {
607
+ aus: [FishAction.EYE_UP, FishAction.EYE_DOWN],
608
+ axis: 'rx',
609
+ negative: FishAction.EYE_DOWN,
610
+ positive: FishAction.EYE_UP
611
+ },
612
+ yaw: {
613
+ aus: [FishAction.EYE_LEFT, FishAction.EYE_RIGHT],
614
+ axis: 'ry',
615
+ negative: FishAction.EYE_RIGHT,
616
+ positive: FishAction.EYE_LEFT
617
+ },
618
+ roll: null,
619
+ },
620
+ ];
621
+ ```
622
+
623
+ ### Action metadata for UI and debugging
624
+
625
+ ```typescript
626
+ export const FISH_AU_INFO: Record<string, AUInfo> = {
627
+ '2': { id: '2', name: 'Turn Left', facePart: 'Body Orientation' },
628
+ '3': { id: '3', name: 'Turn Right', facePart: 'Body Orientation' },
629
+ '4': { id: '4', name: 'Pitch Up', facePart: 'Body Orientation' },
630
+ '5': { id: '5', name: 'Pitch Down', facePart: 'Body Orientation' },
631
+ '12': { id: '12', name: 'Tail Sweep Left', facePart: 'Tail' },
632
+ '13': { id: '13', name: 'Tail Sweep Right', facePart: 'Tail' },
633
+ '20': { id: '20', name: 'Pectoral L Up', facePart: 'Pectoral Fins' },
634
+ '61': { id: '61', name: 'Eyes Left', facePart: 'Eyes' },
635
+ // ... more actions
636
+ };
637
+ ```
638
+
639
+ ### Continuum pairs for bidirectional sliders
640
+
641
+ ```typescript
642
+ export const FISH_CONTINUUM_PAIRS_MAP: Record<number, {
643
+ pairId: number;
644
+ isNegative: boolean;
645
+ axis: 'pitch' | 'yaw' | 'roll';
646
+ node: string;
647
+ }> = {
648
+ [FishAction.TURN_LEFT]: {
649
+ pairId: FishAction.TURN_RIGHT,
650
+ isNegative: true,
651
+ axis: 'yaw',
652
+ node: 'HEAD'
653
+ },
654
+ [FishAction.TURN_RIGHT]: {
655
+ pairId: FishAction.TURN_LEFT,
656
+ isNegative: false,
657
+ axis: 'yaw',
658
+ node: 'HEAD'
659
+ },
660
+ [FishAction.TAIL_SWEEP_LEFT]: {
661
+ pairId: FishAction.TAIL_SWEEP_RIGHT,
662
+ isNegative: true,
663
+ axis: 'roll',
664
+ node: 'TAIL_BASE'
665
+ },
666
+ // ... more pairs
667
+ };
668
+ ```
669
+
670
+ ### Creating the final preset config
671
+
672
+ ```typescript
673
+ export const FISH_AU_MAPPING_CONFIG = {
674
+ auToBones: FISH_BONE_BINDINGS,
675
+ boneNodes: FISH_BONE_NODES,
676
+ auToMorphs: {} as Record<number, { left: string[]; right: string[]; center: string[] }>, // No morph targets
677
+ morphToMesh: {} as Record<string, string[]>,
678
+ visemeKeys: [] as string[], // Fish don't speak!
679
+ auInfo: FISH_AU_INFO,
680
+ compositeRotations: FISH_COMPOSITE_ROTATIONS,
681
+ eyeMeshNodes: { LEFT: 'EYES_0', RIGHT: 'EYES_0' },
682
+ };
683
+ ```
684
+
685
+ ### Using the fish preset
686
+
687
+ ```typescript
688
+ import { Loom3 } from 'loom3';
689
+ import { FISH_AU_MAPPING_CONFIG, FishAction } from './presets/bettaFish';
690
+
691
+ const fishController = new Loom3({
692
+ profile: FISH_AU_MAPPING_CONFIG
693
+ });
694
+
695
+ // Load the fish model
696
+ loader.load('/characters/betta/scene.gltf', (gltf) => {
697
+ const meshes = collectMorphMeshes(gltf.scene); // Will be empty for fish
698
+ fishController.onReady({ meshes, model: gltf.scene });
699
+
700
+ // Control the fish!
701
+ fishController.setAU(FishAction.TURN_LEFT, 0.5); // Turn left
702
+ fishController.setAU(FishAction.TAIL_SWEEP_LEFT, 0.8); // Sweep tail
703
+ fishController.setAU(FishAction.PECTORAL_L_UP, 0.6); // Raise left fin
704
+
705
+ // Smooth transitions
706
+ await fishController.transitionAU(FishAction.TURN_RIGHT, 1.0, 500).promise;
707
+ });
708
+ ```
709
+
710
+ ### Creating swimming animations
711
+
712
+ Use continuum controls for natural swimming motion:
713
+
714
+ ```typescript
715
+ // Use setContinuum for paired actions
716
+ fishController.setContinuum(
717
+ FishAction.TURN_LEFT,
718
+ FishAction.TURN_RIGHT,
719
+ 0.3 // Slight turn right
720
+ );
721
+
722
+ // Animate swimming with oscillating tail
723
+ async function swimCycle() {
724
+ while (true) {
725
+ await fishController.transitionContinuum(
726
+ FishAction.TAIL_SWEEP_LEFT,
727
+ FishAction.TAIL_SWEEP_RIGHT,
728
+ 0.8, // Sweep right
729
+ 300
730
+ ).promise;
731
+
732
+ await fishController.transitionContinuum(
733
+ FishAction.TAIL_SWEEP_LEFT,
734
+ FishAction.TAIL_SWEEP_RIGHT,
735
+ -0.8, // Sweep left
736
+ 300
737
+ ).promise;
738
+ }
739
+ }
740
+ ```
741
+
742
+ > **Screenshot placeholder:** Add a GIF showing the fish swimming animation
743
+
744
+ ---
745
+
746
+ ## 6. Action Unit Control
747
+
748
+ > **Screenshot placeholder:** Add a screenshot showing a character with different AU values
749
+
750
+ Action Units are the core of FACS. Each AU represents a specific muscular movement of the face.
751
+
752
+ ### Setting an AU immediately
753
+
754
+ ```typescript
755
+ // Set AU12 (smile) to 80% intensity
756
+ loom.setAU(12, 0.8);
757
+
758
+ // Set AU45 (blink) to full intensity
759
+ loom.setAU(45, 1.0);
760
+
761
+ // Set to 0 to deactivate
762
+ loom.setAU(12, 0);
763
+ ```
764
+
765
+ ### Transitioning an AU over time
766
+
767
+ ```typescript
768
+ // Animate AU12 to 0.8 over 200ms
769
+ const handle = loom.transitionAU(12, 0.8, 200);
770
+
771
+ // Wait for completion
772
+ await handle.promise;
773
+
774
+ // Or chain transitions
775
+ loom.transitionAU(12, 1.0, 200).promise.then(() => {
776
+ loom.transitionAU(12, 0, 300); // Fade out
777
+ });
778
+ ```
779
+
780
+ ### Getting the current AU value
781
+
782
+ ```typescript
783
+ const smileAmount = loom.getAU(12);
784
+ console.log(`Current smile: ${smileAmount}`);
785
+ ```
786
+
787
+ ### Asymmetric control with balance
788
+
789
+ Many AUs have left and right variants (e.g., `Mouth_Smile_L` and `Mouth_Smile_R`). The `balance` parameter lets you control them independently:
790
+
791
+ ```typescript
792
+ // Balance range: -1 (left only) to +1 (right only), 0 = both equal
793
+
794
+ // Smile on both sides equally
795
+ loom.setAU(12, 0.8, 0);
796
+
797
+ // Smile only on left side
798
+ loom.setAU(12, 0.8, -1);
799
+
800
+ // Smile only on right side
801
+ loom.setAU(12, 0.8, 1);
802
+
803
+ // 70% left, 30% right
804
+ loom.setAU(12, 0.8, -0.4);
805
+ ```
806
+
807
+ ### String-based side selection
808
+
809
+ You can also specify the side directly in the AU ID:
810
+
811
+ ```typescript
812
+ // These are equivalent:
813
+ loom.setAU('12L', 0.8); // Left side only
814
+ loom.setAU(12, 0.8, -1); // Left side only
815
+
816
+ loom.setAU('12R', 0.8); // Right side only
817
+ loom.setAU(12, 0.8, 1); // Right side only
818
+ ```
819
+
820
+ ---
821
+
822
+ ## 7. Mix Weight System
823
+
824
+ > **Screenshot placeholder:** Add a comparison showing morph-only vs bone-only vs mixed weights
825
+
826
+ Some AUs can be driven by both morph targets (blend shapes) AND bone rotations. The mix weight controls the blend between them.
827
+
828
+ ### Why mix weights?
829
+
830
+ Take jaw opening (AU26) as an example:
831
+ - **Morph-only (weight 0)**: Vertices deform to show open mouth, but jaw bone doesn't move
832
+ - **Bone-only (weight 1)**: Jaw bone rotates down, but no soft tissue deformation
833
+ - **Mixed (weight 0.5)**: Both contribute equally for realistic results
834
+
835
+ ### Setting mix weights
836
+
837
+ ```typescript
838
+ // Get the default mix weight for AU26
839
+ const weight = loom.getAUMixWeight(26); // e.g., 0.5
840
+
841
+ // Set to pure morph
842
+ loom.setAUMixWeight(26, 0);
843
+
844
+ // Set to pure bone
845
+ loom.setAUMixWeight(26, 1);
846
+
847
+ // Set to 70% bone, 30% morph
848
+ loom.setAUMixWeight(26, 0.7);
849
+ ```
850
+
851
+ ### Which AUs support mixing?
852
+
853
+ Only AUs that have both `auToMorphs` AND `auToBones` entries support mixing. Common examples:
854
+ - AU26 (Jaw Drop)
855
+ - AU27 (Mouth Stretch)
856
+ - AU51-56 (Head movements)
857
+ - AU61-64 (Eye movements)
858
+
859
+ ```typescript
860
+ import { isMixedAU } from 'loom3';
861
+
862
+ if (isMixedAU(26)) {
863
+ console.log('AU26 supports morph/bone mixing');
864
+ }
865
+ ```
866
+
867
+ ---
868
+
869
+ ## 8. Composite Rotation System
870
+
871
+ > **Screenshot placeholder:** Add a diagram showing the pitch/yaw/roll axes on a head
872
+
873
+ Bones like the head and eyes need multi-axis rotation (pitch, yaw, roll). The composite rotation system handles this automatically.
874
+
875
+ ### How it works
876
+
877
+ When you set an AU that affects a bone rotation, Loom3:
878
+ 1. Queues the rotation update in `pendingCompositeNodes`
879
+ 2. At the end of `update()`, calls `flushPendingComposites()`
880
+ 3. Applies all three axes (pitch, yaw, roll) together to prevent gimbal issues
881
+
882
+ ### Supported bones and their axes
883
+
884
+ | Bone | Pitch (X) | Yaw (Y) | Roll (Z) |
885
+ |------|-----------|---------|----------|
886
+ | HEAD | AU53 (up) / AU54 (down) | AU51 (left) / AU52 (right) | AU55 (tilt left) / AU56 (tilt right) |
887
+ | EYE_L | AU63 (up) / AU64 (down) | AU61 (left) / AU62 (right) | - |
888
+ | EYE_R | AU63 (up) / AU64 (down) | AU61 (left) / AU62 (right) | - |
889
+ | JAW | AU25-27 (open) | AU30 (left) / AU35 (right) | - |
890
+ | TONGUE | AU37 (up) / AU38 (down) | AU39 (left) / AU40 (right) | AU41 / AU42 (tilt) |
891
+
892
+ ### Example: Moving the head
893
+
894
+ ```typescript
895
+ // Turn head left 50%
896
+ loom.setAU(51, 0.5);
897
+
898
+ // Turn head right 50%
899
+ loom.setAU(52, 0.5);
900
+
901
+ // Tilt head up 30%
902
+ loom.setAU(53, 0.3);
903
+
904
+ // Combine: turn left AND tilt up
905
+ loom.setAU(51, 0.5);
906
+ loom.setAU(53, 0.3);
907
+ // Both are applied together in a single composite rotation
908
+ ```
909
+
910
+ ### Example: Eye gaze
911
+
912
+ ```typescript
913
+ // Look left
914
+ loom.setAU(61, 0.7);
915
+
916
+ // Look right
917
+ loom.setAU(62, 0.7);
918
+
919
+ // Look up
920
+ loom.setAU(63, 0.5);
921
+
922
+ // Look down-right (combined)
923
+ loom.setAU(62, 0.6);
924
+ loom.setAU(64, 0.4);
925
+ ```
926
+
927
+ ---
928
+
929
+ ## 9. Continuum Pairs
930
+
931
+ > **Screenshot placeholder:** Add a screenshot showing a continuum slider UI
932
+
933
+ Continuum pairs are bidirectional AU pairs that represent opposite directions on the same axis. They're linked so that activating one should deactivate the other.
934
+
935
+ ### Pair mappings
936
+
937
+ | Pair | Description |
938
+ |------|-------------|
939
+ | AU51 ↔ AU52 | Head turn left / right |
940
+ | AU53 ↔ AU54 | Head up / down |
941
+ | AU55 ↔ AU56 | Head tilt left / right |
942
+ | AU61 ↔ AU62 | Eyes look left / right |
943
+ | AU63 ↔ AU64 | Eyes look up / down |
944
+ | AU30 ↔ AU35 | Jaw shift left / right |
945
+ | AU37 ↔ AU38 | Tongue up / down |
946
+ | AU39 ↔ AU40 | Tongue left / right |
947
+ | AU73 ↔ AU74 | Tongue narrow / wide |
948
+ | AU76 ↔ AU77 | Tongue tip up / down |
949
+
950
+ ### Negative value shorthand (recommended)
951
+
952
+ The simplest way to work with continuum pairs is using **negative values**. When you pass a negative value to `setAU()` or `transitionAU()`, the engine automatically activates the paired AU instead:
953
+
954
+ ```typescript
955
+ // Head looking left at 50% (AU51 is "head left")
956
+ loom.setAU(51, 0.5);
957
+
958
+ // Head looking right at 50% - just use a negative value!
959
+ loom.setAU(51, -0.5); // Automatically activates AU52 at 0.5
960
+
961
+ // This is equivalent to manually setting the pair:
962
+ loom.setAU(51, 0);
963
+ loom.setAU(52, 0.5);
964
+ ```
965
+
966
+ This works for transitions too:
967
+
968
+ ```typescript
969
+ // Animate head from left to right over 500ms
970
+ await loom.transitionAU(51, 0.5, 250).promise; // Turn left
971
+ await loom.transitionAU(51, -0.5, 500).promise; // Turn right (activates AU52)
972
+ ```
973
+
974
+ ### The setContinuum method
975
+
976
+ For explicit continuum control, use `setContinuum()` with a single value from -1 to +1:
977
+
978
+ ```typescript
979
+ // setContinuum(negativeAU, positiveAU, value)
980
+ // value: -1 = full negative, 0 = neutral, +1 = full positive
981
+
982
+ // Head centered
983
+ loom.setContinuum(51, 52, 0);
984
+
985
+ // Head 50% left
986
+ loom.setContinuum(51, 52, -0.5);
987
+
988
+ // Head 70% right
989
+ loom.setContinuum(51, 52, 0.7);
990
+ ```
991
+
992
+ With smooth animation:
993
+
994
+ ```typescript
995
+ // Animate head from current position to 80% right over 300ms
996
+ await loom.transitionContinuum(51, 52, 0.8, 300).promise;
997
+
998
+ // Animate eyes to look left over 200ms
999
+ await loom.transitionContinuum(61, 62, -0.6, 200).promise;
1000
+ ```
1001
+
1002
+ ### Manual pair management
1003
+
1004
+ You can also manually manage pairs by setting each AU individually:
1005
+
1006
+ ```typescript
1007
+ // Head looking left at 50%
1008
+ loom.setAU(51, 0.5);
1009
+ loom.setAU(52, 0); // Right should be 0
1010
+
1011
+ // Head looking right at 70%
1012
+ loom.setAU(51, 0); // Left should be 0
1013
+ loom.setAU(52, 0.7);
1014
+ ```
1015
+
1016
+ ### The CONTINUUM_PAIRS_MAP
1017
+
1018
+ You can access pair information programmatically:
1019
+
1020
+ ```typescript
1021
+ import { CONTINUUM_PAIRS_MAP } from 'loom3';
1022
+
1023
+ const pair = CONTINUUM_PAIRS_MAP[51];
1024
+ // { pairId: 52, isNegative: true, axis: 'yaw', node: 'HEAD' }
1025
+ ```
1026
+
1027
+ ---
1028
+
1029
+ ## 10. Direct Morph Control
1030
+
1031
+ > **Screenshot placeholder:** Add a screenshot of a morph target being controlled directly
1032
+
1033
+ Sometimes you need to control morph targets directly by name, bypassing the AU system.
1034
+
1035
+ ### Setting a morph immediately
1036
+
1037
+ ```typescript
1038
+ // Set a specific morph to 50%
1039
+ loom.setMorph('Mouth_Smile_L', 0.5);
1040
+
1041
+ // Set on specific meshes only
1042
+ loom.setMorph('Mouth_Smile_L', 0.5, ['CC_Base_Body']);
1043
+ ```
1044
+
1045
+ ### Transitioning a morph
1046
+
1047
+ ```typescript
1048
+ // Animate morph over 200ms
1049
+ const handle = loom.transitionMorph('Mouth_Smile_L', 0.8, 200);
1050
+
1051
+ // With mesh targeting
1052
+ loom.transitionMorph('Eye_Blink_L', 1.0, 100, ['CC_Base_Body']);
1053
+
1054
+ // Wait for completion
1055
+ await handle.promise;
1056
+ ```
1057
+
1058
+ ### Reading current morph value
1059
+
1060
+ ```typescript
1061
+ const value = loom.getMorphValue('Mouth_Smile_L');
1062
+ ```
1063
+
1064
+ ### Morph caching
1065
+
1066
+ Loom3 caches morph target lookups for performance. The first time you access a morph, it searches all meshes and caches the index. Subsequent accesses are O(1).
1067
+
1068
+ ---
1069
+
1070
+ ## 11. Viseme System
1071
+
1072
+ > **Screenshot placeholder:** Add a grid showing all 15 viseme mouth shapes
1073
+
1074
+ Visemes are mouth shapes used for lip-sync. Loom3 includes 15 visemes with automatic jaw coupling.
1075
+
1076
+ ### The 15 visemes
1077
+
1078
+ | Index | Key | Phoneme Example |
1079
+ |-------|-----|-----------------|
1080
+ | 0 | EE | "b**ee**" |
1081
+ | 1 | Er | "h**er**" |
1082
+ | 2 | IH | "s**i**t" |
1083
+ | 3 | Ah | "f**a**ther" |
1084
+ | 4 | Oh | "g**o**" |
1085
+ | 5 | W_OO | "t**oo**" |
1086
+ | 6 | S_Z | "**s**un, **z**oo" |
1087
+ | 7 | Ch_J | "**ch**ip, **j**ump" |
1088
+ | 8 | F_V | "**f**un, **v**an" |
1089
+ | 9 | TH | "**th**ink" |
1090
+ | 10 | T_L_D_N | "**t**op, **l**ip, **d**og, **n**o" |
1091
+ | 11 | B_M_P | "**b**at, **m**an, **p**op" |
1092
+ | 12 | K_G_H_NG | "**k**ite, **g**o, **h**at, si**ng**" |
1093
+ | 13 | AE | "c**a**t" |
1094
+ | 14 | R | "**r**ed" |
1095
+
1096
+ ### Setting a viseme
1097
+
1098
+ ```typescript
1099
+ // Set viseme 3 (Ah) to full intensity
1100
+ loom.setViseme(3, 1.0);
1101
+
1102
+ // With jaw scale (0-1, default 1)
1103
+ loom.setViseme(3, 1.0, 0.5); // Half jaw opening
1104
+ ```
1105
+
1106
+ ### Transitioning visemes
1107
+
1108
+ Viseme transitions default to 80ms and use the standard `easeInOutQuad` easing when no duration is provided.
1109
+
1110
+ ```typescript
1111
+ // Animate to a viseme using the default 80ms duration
1112
+ const handle = loom.transitionViseme(3, 1.0);
1113
+
1114
+ // Disable jaw coupling (duration can be omitted to use the 80ms default)
1115
+ loom.transitionViseme(3, 1.0, 80, 0);
1116
+ ```
1117
+
1118
+ ### Automatic jaw coupling
1119
+
1120
+ Each viseme has a predefined jaw opening amount. When you set a viseme, the jaw automatically opens proportionally:
1121
+
1122
+ | Viseme | Jaw Amount |
1123
+ |--------|------------|
1124
+ | EE | 0.15 |
1125
+ | Ah | 0.70 |
1126
+ | Oh | 0.50 |
1127
+ | B_M_P | 0.20 |
1128
+
1129
+ The `jawScale` parameter multiplies this amount:
1130
+ - `jawScale = 1.0`: Normal jaw opening
1131
+ - `jawScale = 0.5`: Half jaw opening
1132
+ - `jawScale = 0`: No jaw movement (viseme only)
1133
+
1134
+ ### Lip-sync example
1135
+
1136
+ ```typescript
1137
+ async function speak(phonemes: number[]) {
1138
+ for (const viseme of phonemes) {
1139
+ // Clear previous viseme
1140
+ for (let i = 0; i < 15; i++) loom.setViseme(i, 0);
1141
+
1142
+ // Transition to new viseme
1143
+ await loom.transitionViseme(viseme, 1.0, 80).promise;
1144
+
1145
+ // Hold briefly
1146
+ await new Promise(r => setTimeout(r, 100));
1147
+ }
1148
+
1149
+ // Return to neutral
1150
+ for (let i = 0; i < 15; i++) loom.setViseme(i, 0);
1151
+ }
1152
+
1153
+ // "Hello" approximation
1154
+ speak([5, 0, 10, 4]);
1155
+ ```
1156
+
1157
+ ---
1158
+
1159
+ ## 12. Transition System
1160
+
1161
+ > **Screenshot placeholder:** Add a diagram showing transition timeline with easing
1162
+
1163
+ All animated changes in Loom3 go through the transition system, which provides smooth interpolation with easing.
1164
+
1165
+ ### TransitionHandle
1166
+
1167
+ Every transition method returns a `TransitionHandle`:
1168
+
1169
+ ```typescript
1170
+ interface TransitionHandle {
1171
+ promise: Promise<void>; // Resolves when transition completes
1172
+ pause(): void; // Pause this transition
1173
+ resume(): void; // Resume this transition
1174
+ cancel(): void; // Cancel immediately
1175
+ }
1176
+ ```
1177
+
1178
+ ### Using handles
1179
+
1180
+ ```typescript
1181
+ // Start a transition
1182
+ const handle = loom.transitionAU(12, 1.0, 500);
1183
+
1184
+ // Pause it
1185
+ handle.pause();
1186
+
1187
+ // Resume later
1188
+ handle.resume();
1189
+
1190
+ // Or cancel entirely
1191
+ handle.cancel();
1192
+
1193
+ // Wait for completion
1194
+ await handle.promise;
1195
+ ```
1196
+
1197
+ ### Combining multiple transitions
1198
+
1199
+ When you call `transitionAU`, it may create multiple internal transitions (one per morph target). The returned handle controls all of them:
1200
+
1201
+ ```typescript
1202
+ // AU12 might affect Mouth_Smile_L and Mouth_Smile_R
1203
+ const handle = loom.transitionAU(12, 1.0, 200);
1204
+
1205
+ // Pausing the handle pauses both morph transitions
1206
+ handle.pause();
1207
+ ```
1208
+
1209
+ ### Easing
1210
+
1211
+ The default easing is `easeInOutQuad`. Custom easing can be provided when using the Animation system directly:
1212
+
1213
+ ```typescript
1214
+ // The AnimationThree class supports custom easing
1215
+ animation.addTransition(
1216
+ 'custom',
1217
+ 0,
1218
+ 1,
1219
+ 200,
1220
+ (v) => console.log(v),
1221
+ (t) => t * t // Custom ease-in quadratic
1222
+ );
1223
+ ```
1224
+
1225
+ ### Active transition count
1226
+
1227
+ ```typescript
1228
+ const count = loom.getActiveTransitionCount();
1229
+ console.log(`${count} transitions in progress`);
1230
+ ```
1231
+
1232
+ ### Clearing all transitions
1233
+
1234
+ ```typescript
1235
+ // Cancel everything immediately
1236
+ loom.clearTransitions();
1237
+ ```
1238
+
1239
+ ---
1240
+
1241
+ ## 13. Playback & State Control
1242
+
1243
+ > **Screenshot placeholder:** Add a screenshot showing pause/resume controls in a UI
1244
+
1245
+ ### Pausing and resuming
1246
+
1247
+ ```typescript
1248
+ // Pause all animation updates
1249
+ loom.pause();
1250
+
1251
+ // Check pause state
1252
+ if (loom.getPaused()) {
1253
+ console.log('Animation is paused');
1254
+ }
1255
+
1256
+ // Resume
1257
+ loom.resume();
1258
+ ```
1259
+
1260
+ When paused, `loom.update()` stops processing transitions, but you can still call `setAU()` for immediate changes.
1261
+
1262
+ ### Resetting to neutral
1263
+
1264
+ ```typescript
1265
+ // Reset everything to rest state
1266
+ loom.resetToNeutral();
1267
+ ```
1268
+
1269
+ This:
1270
+ - Clears all AU values to 0
1271
+ - Cancels all active transitions
1272
+ - Resets all morph targets to 0
1273
+ - Returns all bones to their original position/rotation
1274
+
1275
+ ### Mesh visibility
1276
+
1277
+ ```typescript
1278
+ // Get list of all meshes
1279
+ const meshes = loom.getMeshList();
1280
+ // Returns: [{ name: 'CC_Base_Body', visible: true, morphCount: 80 }, ...]
1281
+
1282
+ // Hide a mesh
1283
+ loom.setMeshVisible('CC_Base_Hair', false);
1284
+
1285
+ // Show it again
1286
+ loom.setMeshVisible('CC_Base_Hair', true);
1287
+ ```
1288
+
1289
+ ### Cleanup
1290
+
1291
+ ```typescript
1292
+ // When done, dispose of resources
1293
+ loom.dispose();
1294
+ ```
1295
+
1296
+ ---
1297
+
1298
+ ## 14. Hair Physics (Mixer-Driven)
1299
+
1300
+ > **Screenshot placeholder:** Add a GIF showing hair physics responding to head movement
1301
+
1302
+ Loom3 includes a built-in hair physics system that drives morph targets through the AnimationMixer.
1303
+ It is **mixer-only** (no per-frame morph LERP), and it reacts to **head rotation** coming from AUs.
1304
+
1305
+ ### How it works
1306
+
1307
+ Hair motion is decomposed into three clip families:
1308
+
1309
+ 1. **Idle/Wind loop** - continuous sway and optional wind.
1310
+ 2. **Impulse clips** - short oscillations triggered by *changes* in head yaw/pitch.
1311
+ 3. **Gravity clip** - a single clip that is **scrubbed** by head pitch (up/down).
1312
+
1313
+ All clips are created with `buildClip` and applied to the mixer.
1314
+ When you update head AUs (e.g. `setAU`, `setContinuum`, `transitionAU`), hair updates automatically.
1315
+
1316
+ ### Basic setup
1317
+
1318
+ ```typescript
1319
+ const loom = new Loom3({ presetType: 'cc4' });
1320
+
1321
+ loader.load('/character.glb', (gltf) => {
1322
+ const meshes = collectMorphMeshes(gltf.scene);
1323
+ loom.onReady({ meshes, model: gltf.scene });
1324
+
1325
+ // Register hair + eyebrow meshes (filters using CC4_MESHES category tags)
1326
+ const allObjects: Object3D[] = [];
1327
+ gltf.scene.traverse((obj) => allObjects.push(obj));
1328
+ loom.registerHairObjects(allObjects);
1329
+
1330
+ // Enable physics (starts idle + gravity + impulse clips)
1331
+ loom.setHairPhysicsEnabled(true);
1332
+ });
1333
+ ```
1334
+
1335
+ ### Configuration (profile defaults)
1336
+
1337
+ Hair physics defaults live in the preset/profile and are applied automatically at init:
1338
+
1339
+ ```typescript
1340
+ import type { Profile } from 'loom3';
1341
+
1342
+ const profile: Profile = {
1343
+ // ...all your usual AU mappings...
1344
+ hairPhysics: {
1345
+ stiffness: 7.5,
1346
+ damping: 0.18,
1347
+ inertia: 3.5,
1348
+ gravity: 12,
1349
+ responseScale: 2.5,
1350
+ idleSwayAmount: 0.12,
1351
+ idleSwaySpeed: 1.0,
1352
+ windStrength: 0,
1353
+ windDirectionX: 1.0,
1354
+ windDirectionZ: 0,
1355
+ windTurbulence: 0.3,
1356
+ windFrequency: 1.4,
1357
+ idleClipDuration: 10,
1358
+ impulseClipDuration: 1.4,
1359
+ direction: {
1360
+ yawSign: -1,
1361
+ pitchSign: -1,
1362
+ },
1363
+ morphTargets: {
1364
+ swayLeft: { key: 'L_Hair_Left', axis: 'yaw' },
1365
+ swayRight: { key: 'L_Hair_Right', axis: 'yaw' },
1366
+ swayFront: { key: 'L_Hair_Front', axis: 'pitch' },
1367
+ fluffRight: { key: 'Fluffy_Right', axis: 'yaw' },
1368
+ fluffBottom: { key: 'Fluffy_Bottom_ALL', axis: 'pitch' },
1369
+ headUp: {
1370
+ Hairline_High_ALL: { value: 0.45, axis: 'pitch' },
1371
+ Length_Short: { value: 0.65, axis: 'pitch' },
1372
+ },
1373
+ headDown: {
1374
+ L_Hair_Front: { value: 2.0, axis: 'pitch' },
1375
+ Fluffy_Bottom_ALL: { value: 1.0, axis: 'pitch' },
1376
+ },
1377
+ },
1378
+ },
1379
+ };
1380
+ ```
1381
+
1382
+ ### Configuration (runtime overrides)
1383
+
1384
+ ```typescript
1385
+ loom.setHairPhysicsConfig({
1386
+ stiffness: 7.5,
1387
+ damping: 0.18,
1388
+ inertia: 3.5,
1389
+ gravity: 12,
1390
+ responseScale: 2.5,
1391
+ idleSwayAmount: 0.12,
1392
+ idleSwaySpeed: 1.0,
1393
+ windStrength: 0,
1394
+ windDirectionX: 1.0,
1395
+ windDirectionZ: 0,
1396
+ windTurbulence: 0.3,
1397
+ windFrequency: 1.4,
1398
+ idleClipDuration: 10,
1399
+ impulseClipDuration: 1.4,
1400
+
1401
+ // Direction mapping (signs) – adjust if hair goes the wrong way.
1402
+ direction: {
1403
+ yawSign: -1, // hair lags opposite head yaw
1404
+ pitchSign: -1, // head down drives forward hair motion
1405
+ },
1406
+
1407
+ // Morph target mapping (override per character/rig)
1408
+ morphTargets: {
1409
+ swayLeft: 'L_Hair_Left',
1410
+ swayRight: 'L_Hair_Right',
1411
+ swayFront: 'L_Hair_Front',
1412
+ fluffRight: 'Fluffy_Right',
1413
+ fluffBottom: 'Fluffy_Bottom_ALL',
1414
+ headUp: {
1415
+ Hairline_High_ALL: 0.45,
1416
+ Length_Short: 0.65,
1417
+ },
1418
+ headDown: {
1419
+ L_Hair_Front: 2.0,
1420
+ Fluffy_Bottom_ALL: 1.0,
1421
+ },
1422
+ },
1423
+ });
1424
+ ```
1425
+
1426
+ ### Validation
1427
+
1428
+ ```typescript
1429
+ const missing = loom.validateHairMorphTargets();
1430
+ if (missing.length > 0) {
1431
+ console.warn('Missing hair morph targets:', missing);
1432
+ }
1433
+ ```
1434
+
1435
+ Loom3 also logs a warning the first time it encounters a missing hair morph key.
1436
+
1437
+ ### Notes
1438
+
1439
+ - **Head rotation input** comes from AUs (e.g. 51/52 yaw, 53/54 pitch).
1440
+ Hair updates when those AUs change.
1441
+ - **Mesh selection** comes from the preset (`CC4_MESHES` categories).
1442
+ Hair morph target *names* live in the preset/profile (`Profile.hairPhysics`) and can be overridden at runtime.
1443
+ - **Direction/morphs are explicit** so you can expose a clean, user-friendly API.
1444
+
1445
+ ### Troubleshooting
1446
+
1447
+ - Hair moves the wrong direction → flip `direction.yawSign` or `direction.pitchSign`.
1448
+ - Wrong morphs are moving → override `morphTargets` with your rig’s names.
1449
+ - Need stronger response → increase `responseScale` or the `headDown/headUp` values.
1450
+
1451
+ ---
1452
+
1453
+ ## 15. Baked Animations
1454
+
1455
+ Loom3 can play baked skeletal animations from your GLB/GLTF files using Three.js AnimationMixer. This allows you to combine pre-made animations (idle, walk, gestures) with real-time facial control.
1456
+
1457
+ ### Loading animations
1458
+
1459
+ After loading your model, pass the animations array to Loom3:
1460
+
1461
+ ```typescript
1462
+ const loader = new GLTFLoader();
1463
+ loader.load('/character.glb', (gltf) => {
1464
+ scene.add(gltf.scene);
1465
+
1466
+ const meshes = collectMorphMeshes(gltf.scene);
1467
+ loom.onReady({ meshes, model: gltf.scene });
1468
+
1469
+ // Load baked animations from the GLB file
1470
+ loom.loadAnimationClips(gltf.animations);
1471
+
1472
+ // Start the internal update loop
1473
+ loom.start();
1474
+ });
1475
+ ```
1476
+
1477
+ ### Listing available animations
1478
+
1479
+ ```typescript
1480
+ const clips = loom.getAnimationClips();
1481
+ console.log(clips);
1482
+ // [
1483
+ // { name: 'Idle', duration: 4.0, trackCount: 52 },
1484
+ // { name: 'Walk', duration: 1.2, trackCount: 52 },
1485
+ // { name: 'Wave', duration: 2.5, trackCount: 24 },
1486
+ // ]
1487
+ ```
1488
+
1489
+ ### Playing animations
1490
+
1491
+ ```typescript
1492
+ // Play an animation with default settings (looping)
1493
+ loom.playAnimation('Idle');
1494
+
1495
+ // Play with options
1496
+ const handle = loom.playAnimation('Wave', {
1497
+ speed: 1.0, // Playback speed (1.0 = normal)
1498
+ intensity: 1.0, // Weight/intensity (0-1)
1499
+ loop: false, // Don't loop
1500
+ loopMode: 'once', // 'repeat', 'pingpong', or 'once'
1501
+ clampWhenFinished: true, // Hold last frame when done
1502
+ startTime: 0, // Start from beginning
1503
+ });
1504
+
1505
+ // Wait for non-looping animation to finish
1506
+ await handle.finished;
1507
+ ```
1508
+
1509
+ ### Mixer clip playback for curves
1510
+
1511
+ Loom3 can convert AU/morph curves into AnimationMixer clips for smooth, mixer-only playback. This is the preferred path for high-frequency animation agencies (eye/head tracking, visemes, prosody) because it avoids per-keyframe transitions.
1512
+
1513
+ Key APIs:
1514
+ - `snippetToClip(name, curves, options)` builds an AnimationClip from curves.
1515
+ - `playClip(clip, options)` returns a ClipHandle you can pause/resume/stop.
1516
+ - `clipHandle.stop()` now resolves cleanly (no rejected promise).
1517
+
1518
+ ```typescript
1519
+ const clip = loom.snippetToClip('gaze', {
1520
+ '61': [{ time: 0, intensity: 0 }, { time: 0.4, intensity: 0.6 }],
1521
+ '62': [{ time: 0, intensity: 0 }, { time: 0.4, intensity: 0 }],
1522
+ }, { loop: false });
1523
+
1524
+ if (clip) {
1525
+ const handle = loom.playClip(clip, { loop: false, speed: 1 });
1526
+ await handle.finished;
1527
+ }
1528
+ ```
1529
+
1530
+ ### Controlling playback
1531
+
1532
+ The handle returned from `playAnimation()` provides full control:
1533
+
1534
+ ```typescript
1535
+ const handle = loom.playAnimation('Idle');
1536
+
1537
+ // Pause and resume
1538
+ handle.pause();
1539
+ handle.resume();
1540
+
1541
+ // Adjust speed in real-time
1542
+ handle.setSpeed(0.5); // Half speed
1543
+ handle.setSpeed(2.0); // Double speed
1544
+
1545
+ // Adjust intensity/weight
1546
+ handle.setWeight(0.5); // 50% influence
1547
+
1548
+ // Seek to specific time
1549
+ handle.seekTo(1.5); // Jump to 1.5 seconds
1550
+
1551
+ // Get current state
1552
+ const state = handle.getState();
1553
+ console.log(state);
1554
+ // {
1555
+ // name: 'Idle',
1556
+ // isPlaying: true,
1557
+ // isPaused: false,
1558
+ // time: 1.5,
1559
+ // duration: 4.0,
1560
+ // speed: 1.0,
1561
+ // weight: 1.0,
1562
+ // isLooping: true
1563
+ // }
1564
+
1565
+ // Stop the animation
1566
+ handle.stop();
1567
+ ```
1568
+
1569
+ ### Crossfading between animations
1570
+
1571
+ Smoothly transition from one animation to another:
1572
+
1573
+ ```typescript
1574
+ // Start with idle
1575
+ loom.playAnimation('Idle');
1576
+
1577
+ // Later, crossfade to walk over 0.3 seconds
1578
+ loom.crossfadeTo('Walk', 0.3);
1579
+
1580
+ // Or use the handle
1581
+ const idleHandle = loom.playAnimation('Idle');
1582
+ idleHandle.crossfadeTo('Walk', 0.5);
1583
+ ```
1584
+
1585
+ ### Global animation control
1586
+
1587
+ Control all animations at once:
1588
+
1589
+ ```typescript
1590
+ // Stop all animations
1591
+ loom.stopAllAnimations();
1592
+
1593
+ // Pause all animations
1594
+ loom.pauseAllAnimations();
1595
+
1596
+ // Resume all paused animations
1597
+ loom.resumeAllAnimations();
1598
+
1599
+ // Set global time scale (affects all animations)
1600
+ loom.setAnimationTimeScale(0.5); // Everything at half speed
1601
+
1602
+ // Get all currently playing animations
1603
+ const playing = loom.getPlayingAnimations();
1604
+ ```
1605
+
1606
+ ### Direct control by name
1607
+
1608
+ You can also control animations directly without using handles:
1609
+
1610
+ ```typescript
1611
+ loom.playAnimation('Idle');
1612
+
1613
+ // Later...
1614
+ loom.setAnimationSpeed('Idle', 1.5);
1615
+ loom.setAnimationIntensity('Idle', 0.8);
1616
+ loom.pauseAnimation('Idle');
1617
+ loom.resumeAnimation('Idle');
1618
+ loom.stopAnimation('Idle');
1619
+
1620
+ // Get state of specific animation
1621
+ const state = loom.getAnimationState('Idle');
1622
+ ```
1623
+
1624
+ ### Combining with facial animation
1625
+
1626
+ Baked animations and facial AU control work together seamlessly. The AnimationMixer updates automatically when you call `loom.update()` or use `loom.start()`:
1627
+
1628
+ ```typescript
1629
+ loom.loadAnimationClips(gltf.animations);
1630
+ loom.start(); // Starts internal RAF loop
1631
+
1632
+ // Play a body animation
1633
+ loom.playAnimation('Idle');
1634
+
1635
+ // Control facial expressions on top
1636
+ loom.setAU(12, 0.8); // Smile
1637
+ loom.transitionAU(45, 1.0, 100); // Blink
1638
+
1639
+ // Both update together - no separate render loop needed
1640
+ ```
1641
+
1642
+ ### Animation types
1643
+
1644
+ | Option | Type | Default | Description |
1645
+ |--------|------|---------|-------------|
1646
+ | `speed` | number | 1.0 | Playback speed multiplier |
1647
+ | `intensity` | number | 1.0 | Animation weight (0-1) |
1648
+ | `loop` | boolean | true | Whether to loop |
1649
+ | `loopMode` | string | 'repeat' | 'repeat', 'pingpong', or 'once' |
1650
+ | `crossfadeDuration` | number | 0 | Fade in duration (seconds) |
1651
+ | `clampWhenFinished` | boolean | true | Hold last frame when done |
1652
+ | `startTime` | number | 0 | Initial playback position |
1653
+
1654
+ ---
1655
+
1656
+ ## Resources
1657
+
1658
+ > **Screenshot placeholder:** Add logos or screenshots from the resources below
1659
+
1660
+ - [FACS on Wikipedia](https://en.wikipedia.org/wiki/Facial_Action_Coding_System)
1661
+ - [Paul Ekman Group - FACS](https://www.paulekman.com/facial-action-coding-system/)
1662
+ - [Character Creator 4](https://www.reallusion.com/character-creator/)
1663
+ - [Three.js Documentation](https://threejs.org/docs/)
1664
+
1665
+ ## License
1666
+
1667
+ MIT License - see LICENSE file for details.