@lovelace_lol/loom3 1.0.3 → 1.0.6

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/dist/index.js CHANGED
@@ -5,11 +5,53 @@ var __defProp = Object.defineProperty;
5
5
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
6
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
7
 
8
+ // src/core/compositeAxis.ts
9
+ function toAUList(value) {
10
+ if (value === void 0) return [];
11
+ return Array.isArray(value) ? value : [value];
12
+ }
13
+ function findBoneBindingForNode(auToBones, auId, nodeKey) {
14
+ return auToBones[auId]?.find((candidate) => candidate.node === nodeKey) ?? null;
15
+ }
16
+ function getCompositeAxisValue(axisConfig, getValue) {
17
+ if (!axisConfig) return 0;
18
+ const negativeAUs = toAUList(axisConfig.negative);
19
+ const positiveAUs = toAUList(axisConfig.positive);
20
+ if (negativeAUs.length > 0 && positiveAUs.length > 0) {
21
+ const negativeValue = Math.max(...negativeAUs.map(getValue), 0);
22
+ const positiveValue = Math.max(...positiveAUs.map(getValue), 0);
23
+ return positiveValue - negativeValue;
24
+ }
25
+ if (axisConfig.aus.length > 1) {
26
+ return Math.max(...axisConfig.aus.map(getValue), 0);
27
+ }
28
+ return getValue(axisConfig.aus[0]);
29
+ }
30
+ function getCompositeAxisBinding(nodeKey, axisConfig, direction, getValue, auToBones) {
31
+ if (!axisConfig) return null;
32
+ const directionalAUs = direction < 0 ? toAUList(axisConfig.negative) : toAUList(axisConfig.positive);
33
+ const candidates = directionalAUs.length > 0 ? directionalAUs : axisConfig.aus;
34
+ const ranked = [...candidates].sort((a, b) => getValue(b) - getValue(a));
35
+ for (const auId of ranked) {
36
+ const binding = findBoneBindingForNode(auToBones, auId, nodeKey);
37
+ if (binding) return binding;
38
+ }
39
+ return null;
40
+ }
41
+
8
42
  // src/engines/three/balanceUtils.ts
9
43
  function clampBalance(value) {
10
44
  if (!Number.isFinite(value)) return 0;
11
45
  return Math.max(-1, Math.min(1, value));
12
46
  }
47
+ function getSideScale(balance, side) {
48
+ if (side !== "left" && side !== "right") return 1;
49
+ const clamped = clampBalance(balance);
50
+ if (side === "left") {
51
+ return clamped > 0 ? 1 - clamped : 1;
52
+ }
53
+ return clamped < 0 ? 1 + clamped : 1;
54
+ }
13
55
  function resolveCurveBalance(curveId, globalBalance, balanceMap) {
14
56
  if (balanceMap && Object.prototype.hasOwnProperty.call(balanceMap, curveId)) {
15
57
  return clampBalance(Number(balanceMap[curveId]));
@@ -137,8 +179,22 @@ var BakedAnimationController = class {
137
179
  return this.host.getMeshNamesForAU(auId) || [];
138
180
  }
139
181
  const facePart = config.auInfo?.[String(auId)]?.facePart;
140
- if (facePart === "Tongue") return config.morphToMesh?.tongue || [];
141
- if (facePart === "Eye") return config.morphToMesh?.eye || [];
182
+ if (facePart) {
183
+ const category = config.auFacePartToMeshCategory?.[facePart];
184
+ if (category) return config.morphToMesh?.[category] || [];
185
+ }
186
+ return config.morphToMesh?.face || [];
187
+ }
188
+ getMeshNamesForViseme(config, explicitMeshNames) {
189
+ if (explicitMeshNames && explicitMeshNames.length > 0) {
190
+ return explicitMeshNames;
191
+ }
192
+ if (typeof this.host.getMeshNamesForViseme === "function") {
193
+ return this.host.getMeshNamesForViseme() || [];
194
+ }
195
+ const category = config.visemeMeshCategory || (config.morphToMesh?.viseme ? "viseme" : "face");
196
+ const visemeMeshes = config.morphToMesh?.[category];
197
+ if (visemeMeshes && visemeMeshes.length > 0) return visemeMeshes;
142
198
  return config.morphToMesh?.face || [];
143
199
  }
144
200
  update(dtSeconds) {
@@ -434,11 +490,12 @@ var BakedAnimationController = class {
434
490
  if (isNumericAU(curveId)) {
435
491
  const auId = Number(curveId);
436
492
  if (isVisemeIndex(curveId)) {
493
+ const visemeMeshNames = this.getMeshNamesForViseme(config, meshNames);
437
494
  const visemeKey = config.visemeKeys[auId];
438
495
  if (typeof visemeKey === "number") {
439
- this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
496
+ this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
440
497
  } else if (visemeKey) {
441
- this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
498
+ this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
442
499
  }
443
500
  } else {
444
501
  const auMeshNames = this.getMeshNamesForAU(auId, config, meshNames);
@@ -518,43 +575,23 @@ var BakedAnimationController = class {
518
575
  Object.keys(curves).filter(isNumericAU).map((id) => Number(id))
519
576
  );
520
577
  const getAxisBinding = (nodeKey, axisConfig, axisValue, t) => {
521
- if (!axisConfig) return null;
522
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
523
- const auId2 = axisValue < 0 ? axisConfig.negative : axisConfig.positive;
524
- return config.auToBones[auId2]?.find((b) => b.node === nodeKey) ?? null;
525
- }
526
- if (axisConfig.aus.length > 1) {
527
- let maxAU = axisConfig.aus[0];
528
- let maxVal = sampleCurve(String(maxAU), t);
529
- for (const auId2 of axisConfig.aus) {
530
- const val = sampleCurve(String(auId2), t);
531
- if (val > maxVal) {
532
- maxVal = val;
533
- maxAU = auId2;
534
- }
535
- }
536
- return config.auToBones[maxAU]?.find((b) => b.node === nodeKey) ?? null;
537
- }
538
- const auId = axisConfig.aus[0];
539
- return config.auToBones[auId]?.find((b) => b.node === nodeKey) ?? null;
578
+ return getCompositeAxisBinding(
579
+ nodeKey,
580
+ axisConfig,
581
+ axisValue,
582
+ (auId) => getAxisSampleForNode(auId, nodeKey, t),
583
+ config.auToBones
584
+ );
540
585
  };
541
- const getAxisValue = (axisConfig, t) => {
542
- if (!axisConfig) return 0;
543
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
544
- const posValue = sampleCurve(String(axisConfig.positive), t);
545
- const negValue = sampleCurve(String(axisConfig.negative), t);
546
- return posValue - negValue;
547
- }
548
- if (axisConfig.aus.length > 1) {
549
- let maxVal = 0;
550
- for (const auId of axisConfig.aus) {
551
- const val = sampleCurve(String(auId), t);
552
- if (val > maxVal) maxVal = val;
553
- }
554
- return maxVal;
555
- }
556
- return sampleCurve(String(axisConfig.aus[0]), t);
586
+ const getAxisSampleForNode = (auId, nodeKey, t) => {
587
+ const rawValue = sampleCurve(String(auId), t);
588
+ if (rawValue <= 1e-6) return 0;
589
+ const binding = config.auToBones[auId]?.find((b) => b.node === nodeKey) ?? null;
590
+ if (!binding?.side) return rawValue;
591
+ const curveBalance = resolveCurveBalance(String(auId), globalBalance, balanceMap);
592
+ return rawValue * getSideScale(curveBalance, binding.side);
557
593
  };
594
+ const getAxisValue = (nodeKey, axisConfig, t) => getCompositeAxisValue(axisConfig, (auId) => getAxisSampleForNode(auId, nodeKey, t));
558
595
  const autoVisemeJawHandledJaw = autoVisemeJaw && jawScale > 0 && visemeJawAmounts && options?.snippetCategory === "visemeSnippet";
559
596
  for (const composite of compositeRotations) {
560
597
  const nodeKey = composite.node;
@@ -575,7 +612,7 @@ var BakedAnimationController = class {
575
612
  const compositeQ = new Quaternion().copy(entry.baseQuat);
576
613
  const applyAxis = (axisConfig) => {
577
614
  if (!axisConfig) return;
578
- let axisValue = getAxisValue(axisConfig, t);
615
+ let axisValue = getAxisValue(nodeKey, axisConfig, t);
579
616
  if (Math.abs(axisValue) <= 1e-6) return;
580
617
  const binding = getAxisBinding(nodeKey, axisConfig, axisValue, t);
581
618
  if (!binding?.maxDegrees || !binding.channel) return;
@@ -1178,24 +1215,24 @@ var AU_TO_MORPHS = {
1178
1215
  center: []
1179
1216
  },
1180
1217
  61: {
1181
- left: ["Eye_L_Look_L", "Eye_R_Look_L"],
1182
- right: [],
1218
+ left: ["Eye_L_Look_L"],
1219
+ right: ["Eye_R_Look_L"],
1183
1220
  center: []
1184
1221
  },
1185
1222
  62: {
1186
- left: [],
1187
- right: ["Eye_L_Look_R", "Eye_R_Look_R"],
1223
+ left: ["Eye_L_Look_R"],
1224
+ right: ["Eye_R_Look_R"],
1188
1225
  center: []
1189
1226
  },
1190
1227
  63: {
1191
- left: [],
1192
- right: [],
1193
- center: ["Eye_L_Look_Up", "Eye_R_Look_Up"]
1228
+ left: ["Eye_L_Look_Up"],
1229
+ right: ["Eye_R_Look_Up"],
1230
+ center: []
1194
1231
  },
1195
1232
  64: {
1196
- left: [],
1197
- right: [],
1198
- center: ["Eye_L_Look_Down", "Eye_R_Look_Down"]
1233
+ left: ["Eye_L_Look_Down"],
1234
+ right: ["Eye_R_Look_Down"],
1235
+ center: []
1199
1236
  },
1200
1237
  65: {
1201
1238
  left: ["Eye_L_Look_L"],
@@ -1423,6 +1460,14 @@ var BONE_AU_TO_BINDINGS = {
1423
1460
  { node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 20, side: "left" },
1424
1461
  { node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 20, side: "right" }
1425
1462
  ],
1463
+ 65: [{ node: "EYE_L", channel: "rz", scale: 1, maxDegrees: 25, side: "left" }],
1464
+ 66: [{ node: "EYE_L", channel: "rz", scale: -1, maxDegrees: 25, side: "left" }],
1465
+ 67: [{ node: "EYE_L", channel: "rx", scale: -1, maxDegrees: 20, side: "left" }],
1466
+ 68: [{ node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 20, side: "left" }],
1467
+ 69: [{ node: "EYE_R", channel: "rz", scale: 1, maxDegrees: 25, side: "right" }],
1468
+ 70: [{ node: "EYE_R", channel: "rz", scale: -1, maxDegrees: 25, side: "right" }],
1469
+ 71: [{ node: "EYE_R", channel: "rx", scale: -1, maxDegrees: 20, side: "right" }],
1470
+ 72: [{ node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 20, side: "right" }],
1426
1471
  // Tongue controls (optional, for rigs that expose them)
1427
1472
  37: [{ node: "TONGUE", channel: "rz", scale: 1, maxDegrees: 20 }],
1428
1473
  38: [{ node: "TONGUE", channel: "rz", scale: -1, maxDegrees: 20 }],
@@ -1510,18 +1555,18 @@ var COMPOSITE_ROTATIONS = [
1510
1555
  },
1511
1556
  {
1512
1557
  node: "EYE_L",
1513
- pitch: { aus: [64, 63], axis: "rx", negative: 64, positive: 63 },
1558
+ pitch: { aus: [64, 63, 68, 67], axis: "rx", negative: [64, 68], positive: [63, 67] },
1514
1559
  // Eyes down/up
1515
- yaw: { aus: [61, 62], axis: "rz", negative: 61, positive: 62 },
1560
+ yaw: { aus: [61, 62, 65, 66], axis: "rz", negative: [61, 65], positive: [62, 66] },
1516
1561
  // Eyes left/right (rz for CC4)
1517
1562
  roll: null
1518
1563
  // Eyes don't have roll
1519
1564
  },
1520
1565
  {
1521
1566
  node: "EYE_R",
1522
- pitch: { aus: [64, 63], axis: "rx", negative: 64, positive: 63 },
1567
+ pitch: { aus: [64, 63, 72, 71], axis: "rx", negative: [64, 72], positive: [63, 71] },
1523
1568
  // Eyes down/up
1524
- yaw: { aus: [61, 62], axis: "rz", negative: 61, positive: 62 },
1569
+ yaw: { aus: [61, 62, 69, 70], axis: "rz", negative: [61, 69], positive: [62, 70] },
1525
1570
  // Eyes left/right (rz for CC4)
1526
1571
  roll: null
1527
1572
  // Eyes don't have roll
@@ -1540,9 +1585,17 @@ var CONTINUUM_PAIRS_MAP = {
1540
1585
  // Eyes horizontal - both eyes share same AUs (yaw maps to rz via COMPOSITE_ROTATIONS)
1541
1586
  61: { pairId: 62, isNegative: true, axis: "yaw", node: "EYE_L" },
1542
1587
  62: { pairId: 61, isNegative: false, axis: "yaw", node: "EYE_L" },
1588
+ 65: { pairId: 66, isNegative: true, axis: "yaw", node: "EYE_L" },
1589
+ 66: { pairId: 65, isNegative: false, axis: "yaw", node: "EYE_L" },
1590
+ 69: { pairId: 70, isNegative: true, axis: "yaw", node: "EYE_R" },
1591
+ 70: { pairId: 69, isNegative: false, axis: "yaw", node: "EYE_R" },
1543
1592
  // Eyes vertical (pitch)
1544
1593
  64: { pairId: 63, isNegative: true, axis: "pitch", node: "EYE_L" },
1545
1594
  63: { pairId: 64, isNegative: false, axis: "pitch", node: "EYE_L" },
1595
+ 68: { pairId: 67, isNegative: true, axis: "pitch", node: "EYE_L" },
1596
+ 67: { pairId: 68, isNegative: false, axis: "pitch", node: "EYE_L" },
1597
+ 72: { pairId: 71, isNegative: true, axis: "pitch", node: "EYE_R" },
1598
+ 71: { pairId: 72, isNegative: false, axis: "pitch", node: "EYE_R" },
1546
1599
  // Head yaw (turn left/right)
1547
1600
  51: { pairId: 52, isNegative: true, axis: "yaw", node: "HEAD" },
1548
1601
  52: { pairId: 51, isNegative: false, axis: "yaw", node: "HEAD" },
@@ -1575,6 +1628,10 @@ var CONTINUUM_PAIRS_MAP = {
1575
1628
  var CONTINUUM_LABELS = {
1576
1629
  "61-62": "Eyes \u2014 Horizontal",
1577
1630
  "64-63": "Eyes \u2014 Vertical",
1631
+ "65-66": "Left Eye \u2014 Horizontal",
1632
+ "68-67": "Left Eye \u2014 Vertical",
1633
+ "69-70": "Right Eye \u2014 Horizontal",
1634
+ "72-71": "Right Eye \u2014 Vertical",
1578
1635
  "51-52": "Head \u2014 Horizontal",
1579
1636
  "54-53": "Head \u2014 Vertical",
1580
1637
  "55-56": "Head \u2014 Tilt",
@@ -1763,6 +1820,12 @@ var MORPH_TO_MESH = {
1763
1820
  tongue: ["CC_Base_Tongue", "CC_Base_Tongue_1"],
1764
1821
  hair: ["Side_part_wavy", "Side_part_wavy_1", "Side_part_wavy_2"]
1765
1822
  };
1823
+ var AU_FACEPART_TO_MESH_CATEGORY = {
1824
+ Eye: "eye",
1825
+ Eyes: "eye",
1826
+ Eyelids: "eye",
1827
+ Tongue: "tongue"
1828
+ };
1766
1829
  var CC4_HAIR_PHYSICS = {
1767
1830
  stiffness: 7.5,
1768
1831
  damping: 0.18,
@@ -1808,7 +1871,9 @@ var CC4_PRESET = {
1808
1871
  bonePrefix: CC4_BONE_PREFIX,
1809
1872
  suffixPattern: CC4_SUFFIX_PATTERN,
1810
1873
  morphToMesh: MORPH_TO_MESH,
1874
+ auFacePartToMeshCategory: AU_FACEPART_TO_MESH_CATEGORY,
1811
1875
  visemeKeys: VISEME_KEYS,
1876
+ visemeMeshCategory: "viseme",
1812
1877
  visemeJawAmounts: VISEME_JAW_AMOUNTS,
1813
1878
  auMixDefaults: AU_MIX_DEFAULTS,
1814
1879
  auInfo: AU_INFO,
@@ -2128,9 +2193,10 @@ var HairPhysicsController = class {
2128
2193
  if (meshName) {
2129
2194
  targetMesh = this.registeredHairObjects.get(meshName);
2130
2195
  } else {
2131
- for (const [name, mesh] of this.registeredHairObjects) {
2132
- const info = CC4_MESHES[name];
2133
- if (info?.category === "hair") {
2196
+ const hairMeshNames = this.getHairMeshNames();
2197
+ for (const name of hairMeshNames) {
2198
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2199
+ if (mesh) {
2134
2200
  targetMesh = mesh;
2135
2201
  break;
2136
2202
  }
@@ -2224,6 +2290,15 @@ var HairPhysicsController = class {
2224
2290
  }
2225
2291
  getHairMeshNames() {
2226
2292
  if (this.cachedHairMeshNames) return this.cachedHairMeshNames;
2293
+ if (typeof this.host.getSelectedHairMeshNames === "function") {
2294
+ const selectedHairMeshNames = this.host.getSelectedHairMeshNames() || [];
2295
+ const resolved = selectedHairMeshNames.filter((name) => {
2296
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2297
+ return !!mesh;
2298
+ });
2299
+ this.cachedHairMeshNames = Array.from(new Set(resolved));
2300
+ return this.cachedHairMeshNames;
2301
+ }
2227
2302
  const names = [];
2228
2303
  this.registeredHairObjects.forEach((mesh, name) => {
2229
2304
  const info = CC4_MESHES[name];
@@ -2239,6 +2314,18 @@ var HairPhysicsController = class {
2239
2314
  this.cachedHairMeshNames = names;
2240
2315
  return names;
2241
2316
  }
2317
+ refreshMeshSelection() {
2318
+ this.cachedHairMeshNames = null;
2319
+ this.idleClipDirty = true;
2320
+ this.gravityClipDirty = true;
2321
+ this.impulseClipDirty = true;
2322
+ this.warnMissingHairMorphTargets();
2323
+ if (this.hairPhysicsEnabled) {
2324
+ this.startIdleClip();
2325
+ this.startGravityClip();
2326
+ this.buildImpulseClips();
2327
+ }
2328
+ }
2242
2329
  supportsMixerClips() {
2243
2330
  return typeof this.host.buildClip === "function";
2244
2331
  }
@@ -2253,7 +2340,11 @@ var HairPhysicsController = class {
2253
2340
  return;
2254
2341
  }
2255
2342
  const hairMeshNames = this.getHairMeshNames();
2256
- if (hairMeshNames.length === 0) return;
2343
+ if (hairMeshNames.length === 0) {
2344
+ this.stopIdleClip();
2345
+ this.idleClipDirty = false;
2346
+ return;
2347
+ }
2257
2348
  if (!this.idleClipDirty && this.idleClipHandle) return;
2258
2349
  this.stopIdleClip();
2259
2350
  const duration = Math.max(0.5, cfg.idleClipDuration);
@@ -2271,7 +2362,11 @@ var HairPhysicsController = class {
2271
2362
  startGravityClip() {
2272
2363
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2273
2364
  const hairMeshNames = this.getHairMeshNames();
2274
- if (hairMeshNames.length === 0) return;
2365
+ if (hairMeshNames.length === 0) {
2366
+ this.stopGravityClip();
2367
+ this.gravityClipDirty = false;
2368
+ return;
2369
+ }
2275
2370
  if (!this.gravityClipDirty && this.gravityClipHandle) return;
2276
2371
  this.stopGravityClip();
2277
2372
  const morphTargets = this.hairPhysicsConfig.morphTargets;
@@ -2324,7 +2419,11 @@ var HairPhysicsController = class {
2324
2419
  buildImpulseClips() {
2325
2420
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2326
2421
  const hairMeshNames = this.getHairMeshNames();
2327
- if (hairMeshNames.length === 0) return;
2422
+ if (hairMeshNames.length === 0) {
2423
+ this.stopImpulseClips();
2424
+ this.impulseClipDirty = false;
2425
+ return;
2426
+ }
2328
2427
  if (!this.impulseClipDirty && this.impulseClips.left && this.impulseClips.right && this.impulseClips.front) {
2329
2428
  return;
2330
2429
  }
@@ -2660,7 +2759,9 @@ function resolveProfile(base, override) {
2660
2759
  auToBones: mergeRecord(base.auToBones, override.auToBones),
2661
2760
  boneNodes: mergeRecord(base.boneNodes, override.boneNodes),
2662
2761
  morphToMesh: mergeRecord(base.morphToMesh, override.morphToMesh),
2762
+ auFacePartToMeshCategory: base.auFacePartToMeshCategory || override.auFacePartToMeshCategory ? mergeRecord(base.auFacePartToMeshCategory || {}, override.auFacePartToMeshCategory || {}) : void 0,
2663
2763
  visemeKeys: override.visemeKeys ? [...override.visemeKeys] : [...base.visemeKeys],
2764
+ visemeMeshCategory: override.visemeMeshCategory ?? base.visemeMeshCategory,
2664
2765
  auMixDefaults: base.auMixDefaults || override.auMixDefaults ? mergeRecord(base.auMixDefaults || {}, override.auMixDefaults || {}) : void 0,
2665
2766
  auInfo: base.auInfo || override.auInfo ? mergeRecord(base.auInfo || {}, override.auInfo || {}) : void 0,
2666
2767
  eyeMeshNodes: override.eyeMeshNodes ?? base.eyeMeshNodes,
@@ -3498,6 +3599,7 @@ var _Loom3 = class _Loom3 {
3498
3599
  this.auToCompositeMap = buildAUToCompositeMap(this.compositeRotations);
3499
3600
  this.hairPhysics = new HairPhysicsController({
3500
3601
  getMeshByName: (name) => this.meshByName.get(name),
3602
+ getSelectedHairMeshNames: () => this.config.morphToMesh?.hair || [],
3501
3603
  buildClip: (clipName, curves, options) => this.buildClip(clipName, curves, options),
3502
3604
  cleanupSnippet: (name) => this.cleanupSnippet(name)
3503
3605
  });
@@ -3506,6 +3608,7 @@ var _Loom3 = class _Loom3 {
3506
3608
  getMeshes: () => this.meshes,
3507
3609
  getMeshByName: (name) => this.meshByName.get(name),
3508
3610
  getMeshNamesForAU: (auId) => this.getMeshNamesForAU(auId),
3611
+ getMeshNamesForViseme: () => this.getMeshNamesForViseme(),
3509
3612
  getBones: () => this.bones,
3510
3613
  getConfig: () => this.config,
3511
3614
  getCompositeRotations: () => this.compositeRotations,
@@ -3602,7 +3705,8 @@ var _Loom3 = class _Loom3 {
3602
3705
  }
3603
3706
  for (let i = 0; i < (this.config.visemeKeys || []).length; i += 1) {
3604
3707
  const key = this.config.visemeKeys[i];
3605
- const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key) : this.resolveMorphTargets(key);
3708
+ const visemeMeshNames = this.getMeshNamesForViseme();
3709
+ const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key, visemeMeshNames) : this.resolveMorphTargets(key, visemeMeshNames);
3606
3710
  this.resolvedVisemeTargets[i] = targets;
3607
3711
  }
3608
3712
  }
@@ -3772,37 +3876,12 @@ var _Loom3 = class _Loom3 {
3772
3876
  }
3773
3877
  const compositeInfo = this.auToCompositeMap.get(id);
3774
3878
  if (compositeInfo) {
3775
- const storedBalance = this.auBalances[id] ?? 0;
3776
- const { left: leftVal, right: rightVal } = this.computeSideValues(clamp012(v), storedBalance);
3777
3879
  for (const nodeKey of compositeInfo.nodes) {
3778
3880
  const config = this.compositeRotations.find((c) => c.node === nodeKey);
3779
3881
  if (!config) continue;
3780
3882
  const axisConfig = config[compositeInfo.axis];
3781
3883
  if (!axisConfig) continue;
3782
- let axisValue;
3783
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
3784
- const negValue = this.auValues[axisConfig.negative] ?? 0;
3785
- const posValue = this.auValues[axisConfig.positive] ?? 0;
3786
- axisValue = posValue - negValue;
3787
- } else if (axisConfig.aus.length > 1) {
3788
- axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
3789
- } else {
3790
- axisValue = v;
3791
- }
3792
- const auBoneBindings = this.config.auToBones[id] || [];
3793
- const sideByNode = /* @__PURE__ */ new Map();
3794
- for (const binding of auBoneBindings) {
3795
- if (binding.side === "left" || binding.side === "right") {
3796
- sideByNode.set(binding.node, binding.side);
3797
- }
3798
- }
3799
- const side = sideByNode.get(nodeKey);
3800
- if (side) {
3801
- const baseValue = clamp012(v);
3802
- const balanceValue = side === "left" ? leftVal : rightVal;
3803
- const denom = baseValue > 0 ? baseValue : 1;
3804
- axisValue = axisValue * (balanceValue / denom);
3805
- }
3884
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3806
3885
  this.updateBoneRotation(nodeKey, compositeInfo.axis, axisValue);
3807
3886
  this.pendingCompositeNodes.add(nodeKey);
3808
3887
  }
@@ -3830,11 +3909,15 @@ var _Loom3 = class _Loom3 {
3830
3909
  }
3831
3910
  }
3832
3911
  const target = clamp012(to);
3912
+ if (balance !== void 0) {
3913
+ this.auBalances[numId] = balance;
3914
+ }
3915
+ const storedBalance = this.auBalances[numId] ?? 0;
3833
3916
  const { left: leftKeys, right: rightKeys, center: centerKeys } = this.getAUMorphsBySide(numId);
3834
3917
  const bindings = this.config.auToBones[numId] || [];
3835
3918
  const mixWeight = this.isMixedAU(numId) ? this.getAUMixWeight(numId) : 1;
3836
3919
  const base = target * mixWeight;
3837
- const { left: leftVal, right: rightVal } = this.computeSideValues(base, balance);
3920
+ const { left: leftVal, right: rightVal } = this.computeSideValues(base, storedBalance);
3838
3921
  this.auValues[numId] = target;
3839
3922
  const handles = [];
3840
3923
  const meshNames = this.getMeshNamesForAU(numId);
@@ -3862,16 +3945,7 @@ var _Loom3 = class _Loom3 {
3862
3945
  if (!config) continue;
3863
3946
  const axisConfig = config[compositeInfo.axis];
3864
3947
  if (!axisConfig) continue;
3865
- let axisValue;
3866
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
3867
- const negValue = this.auValues[axisConfig.negative] ?? 0;
3868
- const posValue = this.auValues[axisConfig.positive] ?? 0;
3869
- axisValue = posValue - negValue;
3870
- } else if (axisConfig.aus.length > 1) {
3871
- axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
3872
- } else {
3873
- axisValue = target;
3874
- }
3948
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3875
3949
  handles.push(this.transitionBoneRotation(nodeKey, compositeInfo.axis, axisValue, durationMs));
3876
3950
  }
3877
3951
  }
@@ -4034,10 +4108,11 @@ var _Loom3 = class _Loom3 {
4034
4108
  this.applyMorphTargets(targets, val);
4035
4109
  } else {
4036
4110
  const morphKey = this.config.visemeKeys[visemeIndex];
4111
+ const visemeMeshNames = this.getMeshNamesForViseme();
4037
4112
  if (typeof morphKey === "number") {
4038
- this.setMorphInfluence(morphKey, val);
4113
+ this.setMorphInfluence(morphKey, val, visemeMeshNames);
4039
4114
  } else if (typeof morphKey === "string") {
4040
- this.setMorph(morphKey, val);
4115
+ this.setMorph(morphKey, val, visemeMeshNames);
4041
4116
  }
4042
4117
  }
4043
4118
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * val * jawScale;
@@ -4055,7 +4130,8 @@ var _Loom3 = class _Loom3 {
4055
4130
  const morphKey = this.config.visemeKeys[visemeIndex];
4056
4131
  const target = clamp012(to);
4057
4132
  this.visemeValues[visemeIndex] = target;
4058
- const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs) : this.transitionMorph(morphKey, target, durationMs);
4133
+ const visemeMeshNames = this.getMeshNamesForViseme();
4134
+ const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs, visemeMeshNames) : this.transitionMorph(morphKey, target, durationMs, visemeMeshNames);
4059
4135
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * target * jawScale;
4060
4136
  if (Math.abs(jawScale) <= 1e-6 || Math.abs(jawAmount) <= 1e-6) {
4061
4137
  return morphHandle;
@@ -4359,6 +4435,7 @@ var _Loom3 = class _Loom3 {
4359
4435
  if (this.model) {
4360
4436
  this.rebuildMorphTargetsCache();
4361
4437
  }
4438
+ this.hairPhysics.refreshMeshSelection();
4362
4439
  this.applyHairPhysicsProfileConfig();
4363
4440
  }
4364
4441
  getProfile() {
@@ -4366,20 +4443,24 @@ var _Loom3 = class _Loom3 {
4366
4443
  }
4367
4444
  /**
4368
4445
  * Get the mesh names that should receive morph influences for a given AU.
4369
- * Routes by facePart: Tongue tongue, Eye → eye, everything else → face.
4446
+ * Routing is driven by `auFacePartToMeshCategory` in profile config.
4370
4447
  */
4371
4448
  getMeshNamesForAU(auId) {
4372
4449
  const m = this.config.morphToMesh;
4373
4450
  const info = this.config.auInfo?.[String(auId)];
4374
- if (!info?.facePart) return m?.face || [];
4375
- switch (info.facePart) {
4376
- case "Tongue":
4377
- return m?.tongue || [];
4378
- case "Eye":
4379
- return m?.eye || [];
4380
- default:
4381
- return m?.face || [];
4451
+ const facePart = info?.facePart;
4452
+ if (facePart) {
4453
+ const category = this.config.auFacePartToMeshCategory?.[facePart];
4454
+ if (category) {
4455
+ return m?.[category] || [];
4456
+ }
4382
4457
  }
4458
+ return m?.face || [];
4459
+ }
4460
+ getMeshNamesForViseme() {
4461
+ const m = this.config.morphToMesh;
4462
+ const category = this.config.visemeMeshCategory || (m?.viseme ? "viseme" : "face");
4463
+ return m?.[category] || m?.face || [];
4383
4464
  }
4384
4465
  // ============================================================================
4385
4466
  // HAIR PHYSICS
@@ -4498,6 +4579,25 @@ var _Loom3 = class _Loom3 {
4498
4579
  const hasMorphs = !!(morphs?.left?.length || morphs?.right?.length || morphs?.center?.length);
4499
4580
  return !!(hasMorphs && this.config.auToBones[id]?.length);
4500
4581
  }
4582
+ getEffectiveBoneAUValue(auId, nodeKey) {
4583
+ const rawValue = clamp012(this.auValues[auId] ?? 0);
4584
+ if (rawValue <= 1e-6) return 0;
4585
+ const binding = this.config.auToBones[auId]?.find((candidate) => candidate.node === nodeKey) ?? null;
4586
+ if (!binding?.side) return rawValue;
4587
+ return rawValue * getSideScale(this.auBalances[auId] ?? 0, binding.side);
4588
+ }
4589
+ getCompositeAxisValueForNode(nodeKey, axisConfig) {
4590
+ return getCompositeAxisValue(axisConfig, (auId) => this.getEffectiveBoneAUValue(auId, nodeKey));
4591
+ }
4592
+ getCompositeAxisBindingForNode(nodeKey, axisConfig, direction) {
4593
+ return getCompositeAxisBinding(
4594
+ nodeKey,
4595
+ axisConfig,
4596
+ direction,
4597
+ (auId) => this.getEffectiveBoneAUValue(auId, nodeKey),
4598
+ this.config.auToBones
4599
+ );
4600
+ }
4501
4601
  initBoneRotations() {
4502
4602
  this.rotations = {};
4503
4603
  this.pendingCompositeNodes.clear();
@@ -4573,30 +4673,10 @@ var _Loom3 = class _Loom3 {
4573
4673
  if (!config) {
4574
4674
  return;
4575
4675
  }
4576
- const getBindingForAxis = (axisConfig, direction) => {
4577
- if (!axisConfig) return null;
4578
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
4579
- const auId = direction < 0 ? axisConfig.negative : axisConfig.positive;
4580
- return this.config.auToBones[auId]?.find((b) => b.node === nodeKey);
4581
- }
4582
- if (axisConfig.aus.length > 1) {
4583
- let maxAU = axisConfig.aus[0];
4584
- let maxValue = this.auValues[maxAU] ?? 0;
4585
- for (const auId of axisConfig.aus) {
4586
- const val = this.auValues[auId] ?? 0;
4587
- if (val > maxValue) {
4588
- maxValue = val;
4589
- maxAU = auId;
4590
- }
4591
- }
4592
- return this.config.auToBones[maxAU]?.find((b) => b.node === nodeKey);
4593
- }
4594
- return this.config.auToBones[axisConfig.aus[0]]?.find((b) => b.node === nodeKey);
4595
- };
4596
4676
  const getAxis = (channel) => channel === "rx" ? X_AXIS2 : channel === "ry" ? Y_AXIS2 : Z_AXIS2;
4597
4677
  const compositeQ = new Quaternion().copy(baseQuat);
4598
4678
  if (config.yaw && rotState.yaw !== 0) {
4599
- const binding = getBindingForAxis(config.yaw, rotState.yaw);
4679
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.yaw, rotState.yaw);
4600
4680
  if (binding?.maxDegrees && binding.channel) {
4601
4681
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.yaw) * binding.scale;
4602
4682
  const axis = getAxis(binding.channel);
@@ -4605,7 +4685,7 @@ var _Loom3 = class _Loom3 {
4605
4685
  }
4606
4686
  }
4607
4687
  if (config.pitch && rotState.pitch !== 0) {
4608
- const binding = getBindingForAxis(config.pitch, rotState.pitch);
4688
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.pitch, rotState.pitch);
4609
4689
  if (binding?.maxDegrees && binding.channel) {
4610
4690
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.pitch) * binding.scale;
4611
4691
  const axis = getAxis(binding.channel);
@@ -4614,7 +4694,7 @@ var _Loom3 = class _Loom3 {
4614
4694
  }
4615
4695
  }
4616
4696
  if (config.roll && rotState.roll !== 0) {
4617
- const binding = getBindingForAxis(config.roll, rotState.roll);
4697
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.roll, rotState.roll);
4618
4698
  if (binding?.maxDegrees && binding.channel) {
4619
4699
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.roll) * binding.scale;
4620
4700
  const axis = getAxis(binding.channel);
@@ -5483,7 +5563,7 @@ function findMatches(targetNames, candidateNames, prefix, suffix, suffixPattern)
5483
5563
  return { found, missing };
5484
5564
  }
5485
5565
  function collectAxisConfigs(axisConfigs) {
5486
- return axisConfigs.filter((entry) => entry.config);
5566
+ return axisConfigs.filter((entry) => entry.config !== null);
5487
5567
  }
5488
5568
  function isEyeNodeKey(nodeKey) {
5489
5569
  return nodeKey === "EYE_L" || nodeKey === "EYE_R";
@@ -5553,28 +5633,35 @@ function validateMappingConfig(config) {
5553
5633
  );
5554
5634
  continue;
5555
5635
  }
5556
- if (axisConfig.negative !== void 0 && !axisConfig.aus.includes(axisConfig.negative)) {
5557
- push(
5558
- "error",
5559
- "COMPOSITE_AU_MISSING",
5560
- `Composite axis for "${composite.node}" is missing negative AU ${axisConfig.negative} in aus list`,
5561
- { node: composite.node, auId: axisConfig.negative }
5562
- );
5636
+ for (const auId of toAUList(axisConfig.negative)) {
5637
+ if (!axisConfig.aus.includes(auId)) {
5638
+ push(
5639
+ "error",
5640
+ "COMPOSITE_AU_MISSING",
5641
+ `Composite axis for "${composite.node}" is missing negative AU ${auId} in aus list`,
5642
+ { node: composite.node, auId }
5643
+ );
5644
+ }
5563
5645
  }
5564
- if (axisConfig.positive !== void 0 && !axisConfig.aus.includes(axisConfig.positive)) {
5565
- push(
5566
- "error",
5567
- "COMPOSITE_AU_MISSING",
5568
- `Composite axis for "${composite.node}" is missing positive AU ${axisConfig.positive} in aus list`,
5569
- { node: composite.node, auId: axisConfig.positive }
5570
- );
5646
+ for (const auId of toAUList(axisConfig.positive)) {
5647
+ if (!axisConfig.aus.includes(auId)) {
5648
+ push(
5649
+ "error",
5650
+ "COMPOSITE_AU_MISSING",
5651
+ `Composite axis for "${composite.node}" is missing positive AU ${auId} in aus list`,
5652
+ { node: composite.node, auId }
5653
+ );
5654
+ }
5571
5655
  }
5572
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0 && axisConfig.negative === axisConfig.positive) {
5656
+ const negativeAUs = toAUList(axisConfig.negative);
5657
+ const positiveAUs = toAUList(axisConfig.positive);
5658
+ const overlappingAUs = negativeAUs.filter((auId) => positiveAUs.includes(auId));
5659
+ if (overlappingAUs.length > 0) {
5573
5660
  push(
5574
5661
  "error",
5575
5662
  "COMPOSITE_AU_DUPLICATE",
5576
- `Composite axis for "${composite.node}" has identical negative/positive AU ${axisConfig.negative}`,
5577
- { node: composite.node, auId: axisConfig.negative }
5663
+ `Composite axis for "${composite.node}" reuses AU ${overlappingAUs[0]} in both negative and positive groups`,
5664
+ { node: composite.node, auId: overlappingAUs[0] }
5578
5665
  );
5579
5666
  }
5580
5667
  }
@@ -5664,11 +5751,11 @@ function validateMappingConfig(config) {
5664
5751
  );
5665
5752
  continue;
5666
5753
  }
5667
- const expectedNeg = axisConfig.negative;
5668
- const expectedPos = axisConfig.positive;
5754
+ const expectedNeg = toAUList(axisConfig.negative);
5755
+ const expectedPos = toAUList(axisConfig.positive);
5669
5756
  const negId = info.isNegative ? Number(auIdStr) : info.pairId;
5670
5757
  const posId = info.isNegative ? info.pairId : Number(auIdStr);
5671
- if (negId !== expectedNeg || posId !== expectedPos) {
5758
+ if (!expectedNeg.includes(negId) || !expectedPos.includes(posId)) {
5672
5759
  push(
5673
5760
  "warning",
5674
5761
  "CONTINUUM_COMPOSITE_MISMATCH",