@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.cjs CHANGED
@@ -26,11 +26,53 @@ var __defProp = Object.defineProperty;
26
26
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
27
27
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
28
28
 
29
+ // src/core/compositeAxis.ts
30
+ function toAUList(value) {
31
+ if (value === void 0) return [];
32
+ return Array.isArray(value) ? value : [value];
33
+ }
34
+ function findBoneBindingForNode(auToBones, auId, nodeKey) {
35
+ return auToBones[auId]?.find((candidate) => candidate.node === nodeKey) ?? null;
36
+ }
37
+ function getCompositeAxisValue(axisConfig, getValue) {
38
+ if (!axisConfig) return 0;
39
+ const negativeAUs = toAUList(axisConfig.negative);
40
+ const positiveAUs = toAUList(axisConfig.positive);
41
+ if (negativeAUs.length > 0 && positiveAUs.length > 0) {
42
+ const negativeValue = Math.max(...negativeAUs.map(getValue), 0);
43
+ const positiveValue = Math.max(...positiveAUs.map(getValue), 0);
44
+ return positiveValue - negativeValue;
45
+ }
46
+ if (axisConfig.aus.length > 1) {
47
+ return Math.max(...axisConfig.aus.map(getValue), 0);
48
+ }
49
+ return getValue(axisConfig.aus[0]);
50
+ }
51
+ function getCompositeAxisBinding(nodeKey, axisConfig, direction, getValue, auToBones) {
52
+ if (!axisConfig) return null;
53
+ const directionalAUs = direction < 0 ? toAUList(axisConfig.negative) : toAUList(axisConfig.positive);
54
+ const candidates = directionalAUs.length > 0 ? directionalAUs : axisConfig.aus;
55
+ const ranked = [...candidates].sort((a, b) => getValue(b) - getValue(a));
56
+ for (const auId of ranked) {
57
+ const binding = findBoneBindingForNode(auToBones, auId, nodeKey);
58
+ if (binding) return binding;
59
+ }
60
+ return null;
61
+ }
62
+
29
63
  // src/engines/three/balanceUtils.ts
30
64
  function clampBalance(value) {
31
65
  if (!Number.isFinite(value)) return 0;
32
66
  return Math.max(-1, Math.min(1, value));
33
67
  }
68
+ function getSideScale(balance, side) {
69
+ if (side !== "left" && side !== "right") return 1;
70
+ const clamped = clampBalance(balance);
71
+ if (side === "left") {
72
+ return clamped > 0 ? 1 - clamped : 1;
73
+ }
74
+ return clamped < 0 ? 1 + clamped : 1;
75
+ }
34
76
  function resolveCurveBalance(curveId, globalBalance, balanceMap) {
35
77
  if (balanceMap && Object.prototype.hasOwnProperty.call(balanceMap, curveId)) {
36
78
  return clampBalance(Number(balanceMap[curveId]));
@@ -158,8 +200,22 @@ var BakedAnimationController = class {
158
200
  return this.host.getMeshNamesForAU(auId) || [];
159
201
  }
160
202
  const facePart = config.auInfo?.[String(auId)]?.facePart;
161
- if (facePart === "Tongue") return config.morphToMesh?.tongue || [];
162
- if (facePart === "Eye") return config.morphToMesh?.eye || [];
203
+ if (facePart) {
204
+ const category = config.auFacePartToMeshCategory?.[facePart];
205
+ if (category) return config.morphToMesh?.[category] || [];
206
+ }
207
+ return config.morphToMesh?.face || [];
208
+ }
209
+ getMeshNamesForViseme(config, explicitMeshNames) {
210
+ if (explicitMeshNames && explicitMeshNames.length > 0) {
211
+ return explicitMeshNames;
212
+ }
213
+ if (typeof this.host.getMeshNamesForViseme === "function") {
214
+ return this.host.getMeshNamesForViseme() || [];
215
+ }
216
+ const category = config.visemeMeshCategory || (config.morphToMesh?.viseme ? "viseme" : "face");
217
+ const visemeMeshes = config.morphToMesh?.[category];
218
+ if (visemeMeshes && visemeMeshes.length > 0) return visemeMeshes;
163
219
  return config.morphToMesh?.face || [];
164
220
  }
165
221
  update(dtSeconds) {
@@ -455,11 +511,12 @@ var BakedAnimationController = class {
455
511
  if (isNumericAU(curveId)) {
456
512
  const auId = Number(curveId);
457
513
  if (isVisemeIndex(curveId)) {
514
+ const visemeMeshNames = this.getMeshNamesForViseme(config, meshNames);
458
515
  const visemeKey = config.visemeKeys[auId];
459
516
  if (typeof visemeKey === "number") {
460
- this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
517
+ this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
461
518
  } else if (visemeKey) {
462
- this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
519
+ this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
463
520
  }
464
521
  } else {
465
522
  const auMeshNames = this.getMeshNamesForAU(auId, config, meshNames);
@@ -539,43 +596,23 @@ var BakedAnimationController = class {
539
596
  Object.keys(curves).filter(isNumericAU).map((id) => Number(id))
540
597
  );
541
598
  const getAxisBinding = (nodeKey, axisConfig, axisValue, t) => {
542
- if (!axisConfig) return null;
543
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
544
- const auId2 = axisValue < 0 ? axisConfig.negative : axisConfig.positive;
545
- return config.auToBones[auId2]?.find((b) => b.node === nodeKey) ?? null;
546
- }
547
- if (axisConfig.aus.length > 1) {
548
- let maxAU = axisConfig.aus[0];
549
- let maxVal = sampleCurve(String(maxAU), t);
550
- for (const auId2 of axisConfig.aus) {
551
- const val = sampleCurve(String(auId2), t);
552
- if (val > maxVal) {
553
- maxVal = val;
554
- maxAU = auId2;
555
- }
556
- }
557
- return config.auToBones[maxAU]?.find((b) => b.node === nodeKey) ?? null;
558
- }
559
- const auId = axisConfig.aus[0];
560
- return config.auToBones[auId]?.find((b) => b.node === nodeKey) ?? null;
599
+ return getCompositeAxisBinding(
600
+ nodeKey,
601
+ axisConfig,
602
+ axisValue,
603
+ (auId) => getAxisSampleForNode(auId, nodeKey, t),
604
+ config.auToBones
605
+ );
561
606
  };
562
- const getAxisValue = (axisConfig, t) => {
563
- if (!axisConfig) return 0;
564
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
565
- const posValue = sampleCurve(String(axisConfig.positive), t);
566
- const negValue = sampleCurve(String(axisConfig.negative), t);
567
- return posValue - negValue;
568
- }
569
- if (axisConfig.aus.length > 1) {
570
- let maxVal = 0;
571
- for (const auId of axisConfig.aus) {
572
- const val = sampleCurve(String(auId), t);
573
- if (val > maxVal) maxVal = val;
574
- }
575
- return maxVal;
576
- }
577
- return sampleCurve(String(axisConfig.aus[0]), t);
607
+ const getAxisSampleForNode = (auId, nodeKey, t) => {
608
+ const rawValue = sampleCurve(String(auId), t);
609
+ if (rawValue <= 1e-6) return 0;
610
+ const binding = config.auToBones[auId]?.find((b) => b.node === nodeKey) ?? null;
611
+ if (!binding?.side) return rawValue;
612
+ const curveBalance = resolveCurveBalance(String(auId), globalBalance, balanceMap);
613
+ return rawValue * getSideScale(curveBalance, binding.side);
578
614
  };
615
+ const getAxisValue = (nodeKey, axisConfig, t) => getCompositeAxisValue(axisConfig, (auId) => getAxisSampleForNode(auId, nodeKey, t));
579
616
  const autoVisemeJawHandledJaw = autoVisemeJaw && jawScale > 0 && visemeJawAmounts && options?.snippetCategory === "visemeSnippet";
580
617
  for (const composite of compositeRotations) {
581
618
  const nodeKey = composite.node;
@@ -596,7 +633,7 @@ var BakedAnimationController = class {
596
633
  const compositeQ = new THREE.Quaternion().copy(entry.baseQuat);
597
634
  const applyAxis = (axisConfig) => {
598
635
  if (!axisConfig) return;
599
- let axisValue = getAxisValue(axisConfig, t);
636
+ let axisValue = getAxisValue(nodeKey, axisConfig, t);
600
637
  if (Math.abs(axisValue) <= 1e-6) return;
601
638
  const binding = getAxisBinding(nodeKey, axisConfig, axisValue, t);
602
639
  if (!binding?.maxDegrees || !binding.channel) return;
@@ -1199,24 +1236,24 @@ var AU_TO_MORPHS = {
1199
1236
  center: []
1200
1237
  },
1201
1238
  61: {
1202
- left: ["Eye_L_Look_L", "Eye_R_Look_L"],
1203
- right: [],
1239
+ left: ["Eye_L_Look_L"],
1240
+ right: ["Eye_R_Look_L"],
1204
1241
  center: []
1205
1242
  },
1206
1243
  62: {
1207
- left: [],
1208
- right: ["Eye_L_Look_R", "Eye_R_Look_R"],
1244
+ left: ["Eye_L_Look_R"],
1245
+ right: ["Eye_R_Look_R"],
1209
1246
  center: []
1210
1247
  },
1211
1248
  63: {
1212
- left: [],
1213
- right: [],
1214
- center: ["Eye_L_Look_Up", "Eye_R_Look_Up"]
1249
+ left: ["Eye_L_Look_Up"],
1250
+ right: ["Eye_R_Look_Up"],
1251
+ center: []
1215
1252
  },
1216
1253
  64: {
1217
- left: [],
1218
- right: [],
1219
- center: ["Eye_L_Look_Down", "Eye_R_Look_Down"]
1254
+ left: ["Eye_L_Look_Down"],
1255
+ right: ["Eye_R_Look_Down"],
1256
+ center: []
1220
1257
  },
1221
1258
  65: {
1222
1259
  left: ["Eye_L_Look_L"],
@@ -1444,6 +1481,14 @@ var BONE_AU_TO_BINDINGS = {
1444
1481
  { node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 20, side: "left" },
1445
1482
  { node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 20, side: "right" }
1446
1483
  ],
1484
+ 65: [{ node: "EYE_L", channel: "rz", scale: 1, maxDegrees: 25, side: "left" }],
1485
+ 66: [{ node: "EYE_L", channel: "rz", scale: -1, maxDegrees: 25, side: "left" }],
1486
+ 67: [{ node: "EYE_L", channel: "rx", scale: -1, maxDegrees: 20, side: "left" }],
1487
+ 68: [{ node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 20, side: "left" }],
1488
+ 69: [{ node: "EYE_R", channel: "rz", scale: 1, maxDegrees: 25, side: "right" }],
1489
+ 70: [{ node: "EYE_R", channel: "rz", scale: -1, maxDegrees: 25, side: "right" }],
1490
+ 71: [{ node: "EYE_R", channel: "rx", scale: -1, maxDegrees: 20, side: "right" }],
1491
+ 72: [{ node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 20, side: "right" }],
1447
1492
  // Tongue controls (optional, for rigs that expose them)
1448
1493
  37: [{ node: "TONGUE", channel: "rz", scale: 1, maxDegrees: 20 }],
1449
1494
  38: [{ node: "TONGUE", channel: "rz", scale: -1, maxDegrees: 20 }],
@@ -1531,18 +1576,18 @@ var COMPOSITE_ROTATIONS = [
1531
1576
  },
1532
1577
  {
1533
1578
  node: "EYE_L",
1534
- pitch: { aus: [64, 63], axis: "rx", negative: 64, positive: 63 },
1579
+ pitch: { aus: [64, 63, 68, 67], axis: "rx", negative: [64, 68], positive: [63, 67] },
1535
1580
  // Eyes down/up
1536
- yaw: { aus: [61, 62], axis: "rz", negative: 61, positive: 62 },
1581
+ yaw: { aus: [61, 62, 65, 66], axis: "rz", negative: [61, 65], positive: [62, 66] },
1537
1582
  // Eyes left/right (rz for CC4)
1538
1583
  roll: null
1539
1584
  // Eyes don't have roll
1540
1585
  },
1541
1586
  {
1542
1587
  node: "EYE_R",
1543
- pitch: { aus: [64, 63], axis: "rx", negative: 64, positive: 63 },
1588
+ pitch: { aus: [64, 63, 72, 71], axis: "rx", negative: [64, 72], positive: [63, 71] },
1544
1589
  // Eyes down/up
1545
- yaw: { aus: [61, 62], axis: "rz", negative: 61, positive: 62 },
1590
+ yaw: { aus: [61, 62, 69, 70], axis: "rz", negative: [61, 69], positive: [62, 70] },
1546
1591
  // Eyes left/right (rz for CC4)
1547
1592
  roll: null
1548
1593
  // Eyes don't have roll
@@ -1561,9 +1606,17 @@ var CONTINUUM_PAIRS_MAP = {
1561
1606
  // Eyes horizontal - both eyes share same AUs (yaw maps to rz via COMPOSITE_ROTATIONS)
1562
1607
  61: { pairId: 62, isNegative: true, axis: "yaw", node: "EYE_L" },
1563
1608
  62: { pairId: 61, isNegative: false, axis: "yaw", node: "EYE_L" },
1609
+ 65: { pairId: 66, isNegative: true, axis: "yaw", node: "EYE_L" },
1610
+ 66: { pairId: 65, isNegative: false, axis: "yaw", node: "EYE_L" },
1611
+ 69: { pairId: 70, isNegative: true, axis: "yaw", node: "EYE_R" },
1612
+ 70: { pairId: 69, isNegative: false, axis: "yaw", node: "EYE_R" },
1564
1613
  // Eyes vertical (pitch)
1565
1614
  64: { pairId: 63, isNegative: true, axis: "pitch", node: "EYE_L" },
1566
1615
  63: { pairId: 64, isNegative: false, axis: "pitch", node: "EYE_L" },
1616
+ 68: { pairId: 67, isNegative: true, axis: "pitch", node: "EYE_L" },
1617
+ 67: { pairId: 68, isNegative: false, axis: "pitch", node: "EYE_L" },
1618
+ 72: { pairId: 71, isNegative: true, axis: "pitch", node: "EYE_R" },
1619
+ 71: { pairId: 72, isNegative: false, axis: "pitch", node: "EYE_R" },
1567
1620
  // Head yaw (turn left/right)
1568
1621
  51: { pairId: 52, isNegative: true, axis: "yaw", node: "HEAD" },
1569
1622
  52: { pairId: 51, isNegative: false, axis: "yaw", node: "HEAD" },
@@ -1596,6 +1649,10 @@ var CONTINUUM_PAIRS_MAP = {
1596
1649
  var CONTINUUM_LABELS = {
1597
1650
  "61-62": "Eyes \u2014 Horizontal",
1598
1651
  "64-63": "Eyes \u2014 Vertical",
1652
+ "65-66": "Left Eye \u2014 Horizontal",
1653
+ "68-67": "Left Eye \u2014 Vertical",
1654
+ "69-70": "Right Eye \u2014 Horizontal",
1655
+ "72-71": "Right Eye \u2014 Vertical",
1599
1656
  "51-52": "Head \u2014 Horizontal",
1600
1657
  "54-53": "Head \u2014 Vertical",
1601
1658
  "55-56": "Head \u2014 Tilt",
@@ -1784,6 +1841,12 @@ var MORPH_TO_MESH = {
1784
1841
  tongue: ["CC_Base_Tongue", "CC_Base_Tongue_1"],
1785
1842
  hair: ["Side_part_wavy", "Side_part_wavy_1", "Side_part_wavy_2"]
1786
1843
  };
1844
+ var AU_FACEPART_TO_MESH_CATEGORY = {
1845
+ Eye: "eye",
1846
+ Eyes: "eye",
1847
+ Eyelids: "eye",
1848
+ Tongue: "tongue"
1849
+ };
1787
1850
  var CC4_HAIR_PHYSICS = {
1788
1851
  stiffness: 7.5,
1789
1852
  damping: 0.18,
@@ -1829,7 +1892,9 @@ var CC4_PRESET = {
1829
1892
  bonePrefix: CC4_BONE_PREFIX,
1830
1893
  suffixPattern: CC4_SUFFIX_PATTERN,
1831
1894
  morphToMesh: MORPH_TO_MESH,
1895
+ auFacePartToMeshCategory: AU_FACEPART_TO_MESH_CATEGORY,
1832
1896
  visemeKeys: VISEME_KEYS,
1897
+ visemeMeshCategory: "viseme",
1833
1898
  visemeJawAmounts: VISEME_JAW_AMOUNTS,
1834
1899
  auMixDefaults: AU_MIX_DEFAULTS,
1835
1900
  auInfo: AU_INFO,
@@ -2149,9 +2214,10 @@ var HairPhysicsController = class {
2149
2214
  if (meshName) {
2150
2215
  targetMesh = this.registeredHairObjects.get(meshName);
2151
2216
  } else {
2152
- for (const [name, mesh] of this.registeredHairObjects) {
2153
- const info = CC4_MESHES[name];
2154
- if (info?.category === "hair") {
2217
+ const hairMeshNames = this.getHairMeshNames();
2218
+ for (const name of hairMeshNames) {
2219
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2220
+ if (mesh) {
2155
2221
  targetMesh = mesh;
2156
2222
  break;
2157
2223
  }
@@ -2245,6 +2311,15 @@ var HairPhysicsController = class {
2245
2311
  }
2246
2312
  getHairMeshNames() {
2247
2313
  if (this.cachedHairMeshNames) return this.cachedHairMeshNames;
2314
+ if (typeof this.host.getSelectedHairMeshNames === "function") {
2315
+ const selectedHairMeshNames = this.host.getSelectedHairMeshNames() || [];
2316
+ const resolved = selectedHairMeshNames.filter((name) => {
2317
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2318
+ return !!mesh;
2319
+ });
2320
+ this.cachedHairMeshNames = Array.from(new Set(resolved));
2321
+ return this.cachedHairMeshNames;
2322
+ }
2248
2323
  const names = [];
2249
2324
  this.registeredHairObjects.forEach((mesh, name) => {
2250
2325
  const info = CC4_MESHES[name];
@@ -2260,6 +2335,18 @@ var HairPhysicsController = class {
2260
2335
  this.cachedHairMeshNames = names;
2261
2336
  return names;
2262
2337
  }
2338
+ refreshMeshSelection() {
2339
+ this.cachedHairMeshNames = null;
2340
+ this.idleClipDirty = true;
2341
+ this.gravityClipDirty = true;
2342
+ this.impulseClipDirty = true;
2343
+ this.warnMissingHairMorphTargets();
2344
+ if (this.hairPhysicsEnabled) {
2345
+ this.startIdleClip();
2346
+ this.startGravityClip();
2347
+ this.buildImpulseClips();
2348
+ }
2349
+ }
2263
2350
  supportsMixerClips() {
2264
2351
  return typeof this.host.buildClip === "function";
2265
2352
  }
@@ -2274,7 +2361,11 @@ var HairPhysicsController = class {
2274
2361
  return;
2275
2362
  }
2276
2363
  const hairMeshNames = this.getHairMeshNames();
2277
- if (hairMeshNames.length === 0) return;
2364
+ if (hairMeshNames.length === 0) {
2365
+ this.stopIdleClip();
2366
+ this.idleClipDirty = false;
2367
+ return;
2368
+ }
2278
2369
  if (!this.idleClipDirty && this.idleClipHandle) return;
2279
2370
  this.stopIdleClip();
2280
2371
  const duration = Math.max(0.5, cfg.idleClipDuration);
@@ -2292,7 +2383,11 @@ var HairPhysicsController = class {
2292
2383
  startGravityClip() {
2293
2384
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2294
2385
  const hairMeshNames = this.getHairMeshNames();
2295
- if (hairMeshNames.length === 0) return;
2386
+ if (hairMeshNames.length === 0) {
2387
+ this.stopGravityClip();
2388
+ this.gravityClipDirty = false;
2389
+ return;
2390
+ }
2296
2391
  if (!this.gravityClipDirty && this.gravityClipHandle) return;
2297
2392
  this.stopGravityClip();
2298
2393
  const morphTargets = this.hairPhysicsConfig.morphTargets;
@@ -2345,7 +2440,11 @@ var HairPhysicsController = class {
2345
2440
  buildImpulseClips() {
2346
2441
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2347
2442
  const hairMeshNames = this.getHairMeshNames();
2348
- if (hairMeshNames.length === 0) return;
2443
+ if (hairMeshNames.length === 0) {
2444
+ this.stopImpulseClips();
2445
+ this.impulseClipDirty = false;
2446
+ return;
2447
+ }
2349
2448
  if (!this.impulseClipDirty && this.impulseClips.left && this.impulseClips.right && this.impulseClips.front) {
2350
2449
  return;
2351
2450
  }
@@ -2681,7 +2780,9 @@ function resolveProfile(base, override) {
2681
2780
  auToBones: mergeRecord(base.auToBones, override.auToBones),
2682
2781
  boneNodes: mergeRecord(base.boneNodes, override.boneNodes),
2683
2782
  morphToMesh: mergeRecord(base.morphToMesh, override.morphToMesh),
2783
+ auFacePartToMeshCategory: base.auFacePartToMeshCategory || override.auFacePartToMeshCategory ? mergeRecord(base.auFacePartToMeshCategory || {}, override.auFacePartToMeshCategory || {}) : void 0,
2684
2784
  visemeKeys: override.visemeKeys ? [...override.visemeKeys] : [...base.visemeKeys],
2785
+ visemeMeshCategory: override.visemeMeshCategory ?? base.visemeMeshCategory,
2685
2786
  auMixDefaults: base.auMixDefaults || override.auMixDefaults ? mergeRecord(base.auMixDefaults || {}, override.auMixDefaults || {}) : void 0,
2686
2787
  auInfo: base.auInfo || override.auInfo ? mergeRecord(base.auInfo || {}, override.auInfo || {}) : void 0,
2687
2788
  eyeMeshNodes: override.eyeMeshNodes ?? base.eyeMeshNodes,
@@ -3519,6 +3620,7 @@ var _Loom3 = class _Loom3 {
3519
3620
  this.auToCompositeMap = buildAUToCompositeMap(this.compositeRotations);
3520
3621
  this.hairPhysics = new HairPhysicsController({
3521
3622
  getMeshByName: (name) => this.meshByName.get(name),
3623
+ getSelectedHairMeshNames: () => this.config.morphToMesh?.hair || [],
3522
3624
  buildClip: (clipName, curves, options) => this.buildClip(clipName, curves, options),
3523
3625
  cleanupSnippet: (name) => this.cleanupSnippet(name)
3524
3626
  });
@@ -3527,6 +3629,7 @@ var _Loom3 = class _Loom3 {
3527
3629
  getMeshes: () => this.meshes,
3528
3630
  getMeshByName: (name) => this.meshByName.get(name),
3529
3631
  getMeshNamesForAU: (auId) => this.getMeshNamesForAU(auId),
3632
+ getMeshNamesForViseme: () => this.getMeshNamesForViseme(),
3530
3633
  getBones: () => this.bones,
3531
3634
  getConfig: () => this.config,
3532
3635
  getCompositeRotations: () => this.compositeRotations,
@@ -3623,7 +3726,8 @@ var _Loom3 = class _Loom3 {
3623
3726
  }
3624
3727
  for (let i = 0; i < (this.config.visemeKeys || []).length; i += 1) {
3625
3728
  const key = this.config.visemeKeys[i];
3626
- const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key) : this.resolveMorphTargets(key);
3729
+ const visemeMeshNames = this.getMeshNamesForViseme();
3730
+ const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key, visemeMeshNames) : this.resolveMorphTargets(key, visemeMeshNames);
3627
3731
  this.resolvedVisemeTargets[i] = targets;
3628
3732
  }
3629
3733
  }
@@ -3793,37 +3897,12 @@ var _Loom3 = class _Loom3 {
3793
3897
  }
3794
3898
  const compositeInfo = this.auToCompositeMap.get(id);
3795
3899
  if (compositeInfo) {
3796
- const storedBalance = this.auBalances[id] ?? 0;
3797
- const { left: leftVal, right: rightVal } = this.computeSideValues(clamp012(v), storedBalance);
3798
3900
  for (const nodeKey of compositeInfo.nodes) {
3799
3901
  const config = this.compositeRotations.find((c) => c.node === nodeKey);
3800
3902
  if (!config) continue;
3801
3903
  const axisConfig = config[compositeInfo.axis];
3802
3904
  if (!axisConfig) continue;
3803
- let axisValue;
3804
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
3805
- const negValue = this.auValues[axisConfig.negative] ?? 0;
3806
- const posValue = this.auValues[axisConfig.positive] ?? 0;
3807
- axisValue = posValue - negValue;
3808
- } else if (axisConfig.aus.length > 1) {
3809
- axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
3810
- } else {
3811
- axisValue = v;
3812
- }
3813
- const auBoneBindings = this.config.auToBones[id] || [];
3814
- const sideByNode = /* @__PURE__ */ new Map();
3815
- for (const binding of auBoneBindings) {
3816
- if (binding.side === "left" || binding.side === "right") {
3817
- sideByNode.set(binding.node, binding.side);
3818
- }
3819
- }
3820
- const side = sideByNode.get(nodeKey);
3821
- if (side) {
3822
- const baseValue = clamp012(v);
3823
- const balanceValue = side === "left" ? leftVal : rightVal;
3824
- const denom = baseValue > 0 ? baseValue : 1;
3825
- axisValue = axisValue * (balanceValue / denom);
3826
- }
3905
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3827
3906
  this.updateBoneRotation(nodeKey, compositeInfo.axis, axisValue);
3828
3907
  this.pendingCompositeNodes.add(nodeKey);
3829
3908
  }
@@ -3851,11 +3930,15 @@ var _Loom3 = class _Loom3 {
3851
3930
  }
3852
3931
  }
3853
3932
  const target = clamp012(to);
3933
+ if (balance !== void 0) {
3934
+ this.auBalances[numId] = balance;
3935
+ }
3936
+ const storedBalance = this.auBalances[numId] ?? 0;
3854
3937
  const { left: leftKeys, right: rightKeys, center: centerKeys } = this.getAUMorphsBySide(numId);
3855
3938
  const bindings = this.config.auToBones[numId] || [];
3856
3939
  const mixWeight = this.isMixedAU(numId) ? this.getAUMixWeight(numId) : 1;
3857
3940
  const base = target * mixWeight;
3858
- const { left: leftVal, right: rightVal } = this.computeSideValues(base, balance);
3941
+ const { left: leftVal, right: rightVal } = this.computeSideValues(base, storedBalance);
3859
3942
  this.auValues[numId] = target;
3860
3943
  const handles = [];
3861
3944
  const meshNames = this.getMeshNamesForAU(numId);
@@ -3883,16 +3966,7 @@ var _Loom3 = class _Loom3 {
3883
3966
  if (!config) continue;
3884
3967
  const axisConfig = config[compositeInfo.axis];
3885
3968
  if (!axisConfig) continue;
3886
- let axisValue;
3887
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
3888
- const negValue = this.auValues[axisConfig.negative] ?? 0;
3889
- const posValue = this.auValues[axisConfig.positive] ?? 0;
3890
- axisValue = posValue - negValue;
3891
- } else if (axisConfig.aus.length > 1) {
3892
- axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
3893
- } else {
3894
- axisValue = target;
3895
- }
3969
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3896
3970
  handles.push(this.transitionBoneRotation(nodeKey, compositeInfo.axis, axisValue, durationMs));
3897
3971
  }
3898
3972
  }
@@ -4055,10 +4129,11 @@ var _Loom3 = class _Loom3 {
4055
4129
  this.applyMorphTargets(targets, val);
4056
4130
  } else {
4057
4131
  const morphKey = this.config.visemeKeys[visemeIndex];
4132
+ const visemeMeshNames = this.getMeshNamesForViseme();
4058
4133
  if (typeof morphKey === "number") {
4059
- this.setMorphInfluence(morphKey, val);
4134
+ this.setMorphInfluence(morphKey, val, visemeMeshNames);
4060
4135
  } else if (typeof morphKey === "string") {
4061
- this.setMorph(morphKey, val);
4136
+ this.setMorph(morphKey, val, visemeMeshNames);
4062
4137
  }
4063
4138
  }
4064
4139
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * val * jawScale;
@@ -4076,7 +4151,8 @@ var _Loom3 = class _Loom3 {
4076
4151
  const morphKey = this.config.visemeKeys[visemeIndex];
4077
4152
  const target = clamp012(to);
4078
4153
  this.visemeValues[visemeIndex] = target;
4079
- const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs) : this.transitionMorph(morphKey, target, durationMs);
4154
+ const visemeMeshNames = this.getMeshNamesForViseme();
4155
+ const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs, visemeMeshNames) : this.transitionMorph(morphKey, target, durationMs, visemeMeshNames);
4080
4156
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * target * jawScale;
4081
4157
  if (Math.abs(jawScale) <= 1e-6 || Math.abs(jawAmount) <= 1e-6) {
4082
4158
  return morphHandle;
@@ -4380,6 +4456,7 @@ var _Loom3 = class _Loom3 {
4380
4456
  if (this.model) {
4381
4457
  this.rebuildMorphTargetsCache();
4382
4458
  }
4459
+ this.hairPhysics.refreshMeshSelection();
4383
4460
  this.applyHairPhysicsProfileConfig();
4384
4461
  }
4385
4462
  getProfile() {
@@ -4387,20 +4464,24 @@ var _Loom3 = class _Loom3 {
4387
4464
  }
4388
4465
  /**
4389
4466
  * Get the mesh names that should receive morph influences for a given AU.
4390
- * Routes by facePart: Tongue tongue, Eye → eye, everything else → face.
4467
+ * Routing is driven by `auFacePartToMeshCategory` in profile config.
4391
4468
  */
4392
4469
  getMeshNamesForAU(auId) {
4393
4470
  const m = this.config.morphToMesh;
4394
4471
  const info = this.config.auInfo?.[String(auId)];
4395
- if (!info?.facePart) return m?.face || [];
4396
- switch (info.facePart) {
4397
- case "Tongue":
4398
- return m?.tongue || [];
4399
- case "Eye":
4400
- return m?.eye || [];
4401
- default:
4402
- return m?.face || [];
4472
+ const facePart = info?.facePart;
4473
+ if (facePart) {
4474
+ const category = this.config.auFacePartToMeshCategory?.[facePart];
4475
+ if (category) {
4476
+ return m?.[category] || [];
4477
+ }
4403
4478
  }
4479
+ return m?.face || [];
4480
+ }
4481
+ getMeshNamesForViseme() {
4482
+ const m = this.config.morphToMesh;
4483
+ const category = this.config.visemeMeshCategory || (m?.viseme ? "viseme" : "face");
4484
+ return m?.[category] || m?.face || [];
4404
4485
  }
4405
4486
  // ============================================================================
4406
4487
  // HAIR PHYSICS
@@ -4519,6 +4600,25 @@ var _Loom3 = class _Loom3 {
4519
4600
  const hasMorphs = !!(morphs?.left?.length || morphs?.right?.length || morphs?.center?.length);
4520
4601
  return !!(hasMorphs && this.config.auToBones[id]?.length);
4521
4602
  }
4603
+ getEffectiveBoneAUValue(auId, nodeKey) {
4604
+ const rawValue = clamp012(this.auValues[auId] ?? 0);
4605
+ if (rawValue <= 1e-6) return 0;
4606
+ const binding = this.config.auToBones[auId]?.find((candidate) => candidate.node === nodeKey) ?? null;
4607
+ if (!binding?.side) return rawValue;
4608
+ return rawValue * getSideScale(this.auBalances[auId] ?? 0, binding.side);
4609
+ }
4610
+ getCompositeAxisValueForNode(nodeKey, axisConfig) {
4611
+ return getCompositeAxisValue(axisConfig, (auId) => this.getEffectiveBoneAUValue(auId, nodeKey));
4612
+ }
4613
+ getCompositeAxisBindingForNode(nodeKey, axisConfig, direction) {
4614
+ return getCompositeAxisBinding(
4615
+ nodeKey,
4616
+ axisConfig,
4617
+ direction,
4618
+ (auId) => this.getEffectiveBoneAUValue(auId, nodeKey),
4619
+ this.config.auToBones
4620
+ );
4621
+ }
4522
4622
  initBoneRotations() {
4523
4623
  this.rotations = {};
4524
4624
  this.pendingCompositeNodes.clear();
@@ -4594,30 +4694,10 @@ var _Loom3 = class _Loom3 {
4594
4694
  if (!config) {
4595
4695
  return;
4596
4696
  }
4597
- const getBindingForAxis = (axisConfig, direction) => {
4598
- if (!axisConfig) return null;
4599
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
4600
- const auId = direction < 0 ? axisConfig.negative : axisConfig.positive;
4601
- return this.config.auToBones[auId]?.find((b) => b.node === nodeKey);
4602
- }
4603
- if (axisConfig.aus.length > 1) {
4604
- let maxAU = axisConfig.aus[0];
4605
- let maxValue = this.auValues[maxAU] ?? 0;
4606
- for (const auId of axisConfig.aus) {
4607
- const val = this.auValues[auId] ?? 0;
4608
- if (val > maxValue) {
4609
- maxValue = val;
4610
- maxAU = auId;
4611
- }
4612
- }
4613
- return this.config.auToBones[maxAU]?.find((b) => b.node === nodeKey);
4614
- }
4615
- return this.config.auToBones[axisConfig.aus[0]]?.find((b) => b.node === nodeKey);
4616
- };
4617
4697
  const getAxis = (channel) => channel === "rx" ? X_AXIS2 : channel === "ry" ? Y_AXIS2 : Z_AXIS2;
4618
4698
  const compositeQ = new THREE.Quaternion().copy(baseQuat);
4619
4699
  if (config.yaw && rotState.yaw !== 0) {
4620
- const binding = getBindingForAxis(config.yaw, rotState.yaw);
4700
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.yaw, rotState.yaw);
4621
4701
  if (binding?.maxDegrees && binding.channel) {
4622
4702
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.yaw) * binding.scale;
4623
4703
  const axis = getAxis(binding.channel);
@@ -4626,7 +4706,7 @@ var _Loom3 = class _Loom3 {
4626
4706
  }
4627
4707
  }
4628
4708
  if (config.pitch && rotState.pitch !== 0) {
4629
- const binding = getBindingForAxis(config.pitch, rotState.pitch);
4709
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.pitch, rotState.pitch);
4630
4710
  if (binding?.maxDegrees && binding.channel) {
4631
4711
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.pitch) * binding.scale;
4632
4712
  const axis = getAxis(binding.channel);
@@ -4635,7 +4715,7 @@ var _Loom3 = class _Loom3 {
4635
4715
  }
4636
4716
  }
4637
4717
  if (config.roll && rotState.roll !== 0) {
4638
- const binding = getBindingForAxis(config.roll, rotState.roll);
4718
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.roll, rotState.roll);
4639
4719
  if (binding?.maxDegrees && binding.channel) {
4640
4720
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.roll) * binding.scale;
4641
4721
  const axis = getAxis(binding.channel);
@@ -5504,7 +5584,7 @@ function findMatches(targetNames, candidateNames, prefix, suffix, suffixPattern)
5504
5584
  return { found, missing };
5505
5585
  }
5506
5586
  function collectAxisConfigs(axisConfigs) {
5507
- return axisConfigs.filter((entry) => entry.config);
5587
+ return axisConfigs.filter((entry) => entry.config !== null);
5508
5588
  }
5509
5589
  function isEyeNodeKey(nodeKey) {
5510
5590
  return nodeKey === "EYE_L" || nodeKey === "EYE_R";
@@ -5574,28 +5654,35 @@ function validateMappingConfig(config) {
5574
5654
  );
5575
5655
  continue;
5576
5656
  }
5577
- if (axisConfig.negative !== void 0 && !axisConfig.aus.includes(axisConfig.negative)) {
5578
- push(
5579
- "error",
5580
- "COMPOSITE_AU_MISSING",
5581
- `Composite axis for "${composite.node}" is missing negative AU ${axisConfig.negative} in aus list`,
5582
- { node: composite.node, auId: axisConfig.negative }
5583
- );
5657
+ for (const auId of toAUList(axisConfig.negative)) {
5658
+ if (!axisConfig.aus.includes(auId)) {
5659
+ push(
5660
+ "error",
5661
+ "COMPOSITE_AU_MISSING",
5662
+ `Composite axis for "${composite.node}" is missing negative AU ${auId} in aus list`,
5663
+ { node: composite.node, auId }
5664
+ );
5665
+ }
5584
5666
  }
5585
- if (axisConfig.positive !== void 0 && !axisConfig.aus.includes(axisConfig.positive)) {
5586
- push(
5587
- "error",
5588
- "COMPOSITE_AU_MISSING",
5589
- `Composite axis for "${composite.node}" is missing positive AU ${axisConfig.positive} in aus list`,
5590
- { node: composite.node, auId: axisConfig.positive }
5591
- );
5667
+ for (const auId of toAUList(axisConfig.positive)) {
5668
+ if (!axisConfig.aus.includes(auId)) {
5669
+ push(
5670
+ "error",
5671
+ "COMPOSITE_AU_MISSING",
5672
+ `Composite axis for "${composite.node}" is missing positive AU ${auId} in aus list`,
5673
+ { node: composite.node, auId }
5674
+ );
5675
+ }
5592
5676
  }
5593
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0 && axisConfig.negative === axisConfig.positive) {
5677
+ const negativeAUs = toAUList(axisConfig.negative);
5678
+ const positiveAUs = toAUList(axisConfig.positive);
5679
+ const overlappingAUs = negativeAUs.filter((auId) => positiveAUs.includes(auId));
5680
+ if (overlappingAUs.length > 0) {
5594
5681
  push(
5595
5682
  "error",
5596
5683
  "COMPOSITE_AU_DUPLICATE",
5597
- `Composite axis for "${composite.node}" has identical negative/positive AU ${axisConfig.negative}`,
5598
- { node: composite.node, auId: axisConfig.negative }
5684
+ `Composite axis for "${composite.node}" reuses AU ${overlappingAUs[0]} in both negative and positive groups`,
5685
+ { node: composite.node, auId: overlappingAUs[0] }
5599
5686
  );
5600
5687
  }
5601
5688
  }
@@ -5685,11 +5772,11 @@ function validateMappingConfig(config) {
5685
5772
  );
5686
5773
  continue;
5687
5774
  }
5688
- const expectedNeg = axisConfig.negative;
5689
- const expectedPos = axisConfig.positive;
5775
+ const expectedNeg = toAUList(axisConfig.negative);
5776
+ const expectedPos = toAUList(axisConfig.positive);
5690
5777
  const negId = info.isNegative ? Number(auIdStr) : info.pairId;
5691
5778
  const posId = info.isNegative ? info.pairId : Number(auIdStr);
5692
- if (negId !== expectedNeg || posId !== expectedPos) {
5779
+ if (!expectedNeg.includes(negId) || !expectedPos.includes(posId)) {
5693
5780
  push(
5694
5781
  "warning",
5695
5782
  "CONTINUUM_COMPOSITE_MISMATCH",