loomlarge 0.2.0 → 0.2.5

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
@@ -245,7 +245,7 @@ var VISEME_KEYS = [
245
245
  ];
246
246
  var BONE_AU_TO_BINDINGS = {
247
247
  // Head turn and tilt (M51-M56) - use HEAD bone only (NECK should not rotate with head)
248
- // Three.js Y rotation: positive = counter-clockwise from above = head turns LEFT (character POV)
248
+ // Axis is derived from COMPOSITE_ROTATIONS, not stored here
249
249
  51: [
250
250
  { node: "HEAD", channel: "ry", scale: 1, maxDegrees: 30 }
251
251
  // Head turn left
@@ -270,7 +270,7 @@ var BONE_AU_TO_BINDINGS = {
270
270
  { node: "HEAD", channel: "rz", scale: 1, maxDegrees: 15 }
271
271
  // Head tilt right
272
272
  ],
273
- // Eyes horizontal (yaw) - CC4 rigs use rz for horizontal eye rotation
273
+ // Eyes - CC4 rigs use rz for horizontal, rx for vertical
274
274
  61: [
275
275
  { node: "EYE_L", channel: "rz", scale: 1, maxDegrees: 32 },
276
276
  // Eyes look left
@@ -283,45 +283,47 @@ var BONE_AU_TO_BINDINGS = {
283
283
  ],
284
284
  63: [
285
285
  { node: "EYE_L", channel: "rx", scale: -1, maxDegrees: 32 },
286
+ // Eyes Up
286
287
  { node: "EYE_R", channel: "rx", scale: -1, maxDegrees: 32 }
287
288
  ],
288
289
  64: [
289
290
  { node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 32 },
291
+ // Eyes Down
290
292
  { node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 32 }
291
293
  ],
292
294
  // Single-eye (Left) — horizontal (rz for CC4) and vertical (rx)
293
295
  65: [{ node: "EYE_L", channel: "rz", scale: -1, maxDegrees: 15 }],
294
296
  66: [{ node: "EYE_L", channel: "rz", scale: 1, maxDegrees: 15 }],
295
297
  67: [{ node: "EYE_L", channel: "rx", scale: -1, maxDegrees: 12 }],
298
+ // Left Eye Up
296
299
  68: [{ node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 12 }],
300
+ // Left Eye Down
297
301
  // Single-eye (Right) — horizontal (rz for CC4) and vertical (rx)
298
302
  69: [{ node: "EYE_R", channel: "rz", scale: -1, maxDegrees: 15 }],
299
303
  70: [{ node: "EYE_R", channel: "rz", scale: 1, maxDegrees: 15 }],
300
304
  71: [{ node: "EYE_R", channel: "rx", scale: -1, maxDegrees: 12 }],
305
+ // Right Eye Up
301
306
  72: [{ node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 12 }],
307
+ // Right Eye Down
302
308
  // Jaw / Mouth
303
309
  8: [
304
310
  // Lips Toward Each Other - slight jaw open helps sell the lip press
305
311
  { node: "JAW", channel: "rz", scale: 1, maxDegrees: 8 }
306
- // Small downward rotation (jaw opening slightly)
307
312
  ],
308
313
  25: [
309
314
  // Lips Part — small jaw open
310
315
  { node: "JAW", channel: "rz", scale: 1, maxDegrees: 5.84 }
311
- // 73% of 8
312
316
  ],
313
317
  26: [
314
318
  { node: "JAW", channel: "rz", scale: 1, maxDegrees: 28 }
315
- // 73% of 20
316
319
  ],
317
320
  27: [
318
321
  // Mouth Stretch — larger jaw open
319
322
  { node: "JAW", channel: "rz", scale: 1, maxDegrees: 32 }
320
- // 73% of 25
321
323
  ],
322
324
  29: [
323
325
  { node: "JAW", channel: "tz", scale: -1, maxUnits: 0.02 }
324
- // Negative for forward thrust
326
+ // Translation
325
327
  ],
326
328
  30: [
327
329
  // Jaw Left
@@ -334,6 +336,7 @@ var BONE_AU_TO_BINDINGS = {
334
336
  // Tongue
335
337
  19: [
336
338
  { node: "TONGUE", channel: "tz", scale: -1, maxUnits: 8e-3 }
339
+ // Translation
337
340
  ],
338
341
  37: [
339
342
  // Tongue Up
@@ -413,7 +416,7 @@ var COMPOSITE_ROTATIONS = [
413
416
  }
414
417
  ];
415
418
  var CONTINUUM_PAIRS_MAP = {
416
- // Eyes horizontal (yaw) - both eyes share same AUs
419
+ // Eyes horizontal - both eyes share same AUs (yaw maps to rz via COMPOSITE_ROTATIONS)
417
420
  61: { pairId: 62, isNegative: true, axis: "yaw", node: "EYE_L" },
418
421
  62: { pairId: 61, isNegative: false, axis: "yaw", node: "EYE_L" },
419
422
  // Eyes vertical (pitch)
@@ -650,6 +653,25 @@ var CC4_PRESET = {
650
653
 
651
654
  // src/engines/three/LoomLargeThree.ts
652
655
  var deg2rad = (d) => d * Math.PI / 180;
656
+ var X_AXIS = new three.Vector3(1, 0, 0);
657
+ var Y_AXIS = new three.Vector3(0, 1, 0);
658
+ var Z_AXIS = new three.Vector3(0, 0, 1);
659
+ var AU_TO_COMPOSITE_MAP = /* @__PURE__ */ new Map();
660
+ COMPOSITE_ROTATIONS.forEach((comp) => {
661
+ ["pitch", "yaw", "roll"].forEach((axisName) => {
662
+ const axisConfig = comp[axisName];
663
+ if (axisConfig) {
664
+ axisConfig.aus.forEach((auId) => {
665
+ const existing = AU_TO_COMPOSITE_MAP.get(auId);
666
+ if (existing) {
667
+ existing.nodes.push(comp.node);
668
+ } else {
669
+ AU_TO_COMPOSITE_MAP.set(auId, { nodes: [comp.node], axis: axisName });
670
+ }
671
+ });
672
+ }
673
+ });
674
+ });
653
675
  function clamp01(x) {
654
676
  return x < 0 ? 0 : x > 1 ? 1 : x;
655
677
  }
@@ -795,13 +817,31 @@ var _LoomLargeThree = class _LoomLargeThree {
795
817
  this.setMorph(k, base, meshNames);
796
818
  }
797
819
  }
820
+ const compositeInfo = AU_TO_COMPOSITE_MAP.get(id);
821
+ if (compositeInfo) {
822
+ for (const nodeKey of compositeInfo.nodes) {
823
+ const config = COMPOSITE_ROTATIONS.find((c) => c.node === nodeKey);
824
+ if (!config) continue;
825
+ const axisConfig = config[compositeInfo.axis];
826
+ if (!axisConfig) continue;
827
+ let axisValue;
828
+ if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
829
+ const negValue = this.auValues[axisConfig.negative] ?? 0;
830
+ const posValue = this.auValues[axisConfig.positive] ?? 0;
831
+ axisValue = posValue - negValue;
832
+ } else if (axisConfig.aus.length > 1) {
833
+ axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
834
+ } else {
835
+ axisValue = v;
836
+ }
837
+ this.updateBoneRotation(nodeKey, compositeInfo.axis, axisValue);
838
+ this.pendingCompositeNodes.add(nodeKey);
839
+ }
840
+ }
798
841
  const bindings = this.config.auToBones[id];
799
842
  if (bindings) {
800
843
  for (const binding of bindings) {
801
- if (binding.channel === "rx" || binding.channel === "ry" || binding.channel === "rz") {
802
- const axis = binding.channel === "rx" ? "pitch" : binding.channel === "ry" ? "yaw" : "roll";
803
- this.updateBoneRotation(binding.node, axis, v * binding.scale, binding.maxDegrees ?? 0);
804
- } else if (binding.channel === "tx" || binding.channel === "ty" || binding.channel === "tz") {
844
+ if (binding.channel === "tx" || binding.channel === "ty" || binding.channel === "tz") {
805
845
  if (binding.maxUnits !== void 0) {
806
846
  this.updateBoneTranslation(binding.node, binding.channel, v * binding.scale, binding.maxUnits);
807
847
  }
@@ -836,11 +876,28 @@ var _LoomLargeThree = class _LoomLargeThree {
836
876
  for (const k of centerKeys) {
837
877
  handles.push(this.transitionMorph(k, base, durationMs, meshNames));
838
878
  }
879
+ const compositeInfo = AU_TO_COMPOSITE_MAP.get(numId);
880
+ if (compositeInfo) {
881
+ for (const nodeKey of compositeInfo.nodes) {
882
+ const config = COMPOSITE_ROTATIONS.find((c) => c.node === nodeKey);
883
+ if (!config) continue;
884
+ const axisConfig = config[compositeInfo.axis];
885
+ if (!axisConfig) continue;
886
+ let axisValue;
887
+ if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
888
+ const negValue = this.auValues[axisConfig.negative] ?? 0;
889
+ const posValue = this.auValues[axisConfig.positive] ?? 0;
890
+ axisValue = posValue - negValue;
891
+ } else if (axisConfig.aus.length > 1) {
892
+ axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
893
+ } else {
894
+ axisValue = target;
895
+ }
896
+ handles.push(this.transitionBoneRotation(nodeKey, compositeInfo.axis, axisValue, durationMs));
897
+ }
898
+ }
839
899
  for (const binding of bindings) {
840
- if (binding.channel === "rx" || binding.channel === "ry" || binding.channel === "rz") {
841
- const axis = binding.channel === "rx" ? "pitch" : binding.channel === "ry" ? "yaw" : "roll";
842
- handles.push(this.transitionBoneRotation(binding.node, axis, target * binding.scale, binding.maxDegrees ?? 0, durationMs));
843
- } else if (binding.channel === "tx" || binding.channel === "ty" || binding.channel === "tz") {
900
+ if (binding.channel === "tx" || binding.channel === "ty" || binding.channel === "tz") {
844
901
  if (binding.maxUnits !== void 0) {
845
902
  handles.push(this.transitionBoneTranslation(binding.node, binding.channel, target * binding.scale, binding.maxUnits, durationMs));
846
903
  }
@@ -852,10 +909,53 @@ var _LoomLargeThree = class _LoomLargeThree {
852
909
  return this.auValues[id] ?? 0;
853
910
  }
854
911
  // ============================================================================
855
- // MORPH CONTROL
912
+ // CONTINUUM CONTROL (for paired AUs like eyes left/right, head up/down)
856
913
  // ============================================================================
857
- setMorph(key, v, meshNames) {
914
+ /**
915
+ * Set a continuum AU pair immediately (no animation).
916
+ *
917
+ * Sign convention:
918
+ * - Negative value (-1 to 0): activates negAU (e.g., head left, eyes left)
919
+ * - Positive value (0 to +1): activates posAU (e.g., head right, eyes right)
920
+ *
921
+ * @param negAU - AU ID for negative direction (e.g., 61 for eyes left)
922
+ * @param posAU - AU ID for positive direction (e.g., 62 for eyes right)
923
+ * @param continuumValue - Value from -1 (full negative) to +1 (full positive)
924
+ */
925
+ setContinuum(negAU, posAU, continuumValue) {
926
+ const value = Math.max(-1, Math.min(1, continuumValue));
927
+ const negVal = value < 0 ? Math.abs(value) : 0;
928
+ const posVal = value > 0 ? value : 0;
929
+ this.setAU(negAU, negVal);
930
+ this.setAU(posAU, posVal);
931
+ }
932
+ /**
933
+ * Smoothly transition a continuum AU pair (e.g., eyes left/right, head up/down).
934
+ * Takes a continuum value from -1 to +1 and internally manages both AU values.
935
+ *
936
+ * @param negAU - AU ID for negative direction (e.g., 61 for eyes left)
937
+ * @param posAU - AU ID for positive direction (e.g., 62 for eyes right)
938
+ * @param continuumValue - Target value from -1 (full negative) to +1 (full positive)
939
+ * @param durationMs - Transition duration in milliseconds
940
+ */
941
+ transitionContinuum(negAU, posAU, continuumValue, durationMs = 200) {
942
+ const target = Math.max(-1, Math.min(1, continuumValue));
943
+ const driverKey = `continuum_${negAU}_${posAU}`;
944
+ const currentNeg = this.auValues[negAU] ?? 0;
945
+ const currentPos = this.auValues[posAU] ?? 0;
946
+ const currentContinuum = currentPos - currentNeg;
947
+ return this.animation.addTransition(driverKey, currentContinuum, target, durationMs, (value) => this.setContinuum(negAU, posAU, value));
948
+ }
949
+ setMorph(key, v, meshNamesOrTargets) {
858
950
  const val = clamp01(v);
951
+ if (Array.isArray(meshNamesOrTargets) && meshNamesOrTargets.length > 0 && typeof meshNamesOrTargets[0] === "object" && "infl" in meshNamesOrTargets[0]) {
952
+ const targets2 = meshNamesOrTargets;
953
+ for (const target of targets2) {
954
+ target.infl[target.idx] = val;
955
+ }
956
+ return;
957
+ }
958
+ const meshNames = meshNamesOrTargets;
859
959
  const targetMeshes = meshNames || this.config.morphToMesh?.face || [];
860
960
  const cached = this.morphCache.get(key);
861
961
  if (cached) {
@@ -894,11 +994,54 @@ var _LoomLargeThree = class _LoomLargeThree {
894
994
  this.morphCache.set(key, targets);
895
995
  }
896
996
  }
997
+ /**
998
+ * Resolve morph key to direct targets for ultra-fast repeated access.
999
+ * Use this when you need to set the same morph many times (e.g., in animation loops).
1000
+ */
1001
+ resolveMorphTargets(key, meshNames) {
1002
+ const cached = this.morphCache.get(key);
1003
+ if (cached) return cached;
1004
+ const targetMeshes = meshNames || this.config.morphToMesh?.face || [];
1005
+ const targets = [];
1006
+ if (targetMeshes.length) {
1007
+ for (const name of targetMeshes) {
1008
+ const mesh = this.meshByName.get(name);
1009
+ if (!mesh) continue;
1010
+ const dict = mesh.morphTargetDictionary;
1011
+ const infl = mesh.morphTargetInfluences;
1012
+ if (!dict || !infl) continue;
1013
+ const idx = dict[key];
1014
+ if (idx !== void 0) {
1015
+ targets.push({ infl, idx });
1016
+ }
1017
+ }
1018
+ } else {
1019
+ for (const mesh of this.meshes) {
1020
+ const dict = mesh.morphTargetDictionary;
1021
+ const infl = mesh.morphTargetInfluences;
1022
+ if (!dict || !infl) continue;
1023
+ const idx = dict[key];
1024
+ if (idx !== void 0) {
1025
+ targets.push({ infl, idx });
1026
+ }
1027
+ }
1028
+ }
1029
+ if (targets.length > 0) {
1030
+ this.morphCache.set(key, targets);
1031
+ }
1032
+ return targets;
1033
+ }
897
1034
  transitionMorph(key, to, durationMs = 120, meshNames) {
898
1035
  const transitionKey = `morph_${key}`;
899
1036
  const from = this.getMorphValue(key);
900
1037
  const target = clamp01(to);
901
- return this.animation.addTransition(transitionKey, from, target, durationMs, (value) => this.setMorph(key, value, meshNames));
1038
+ const targets = this.resolveMorphTargets(key, meshNames);
1039
+ return this.animation.addTransition(transitionKey, from, target, durationMs, (value) => {
1040
+ const val = clamp01(value);
1041
+ for (const t of targets) {
1042
+ t.infl[t.idx] = val;
1043
+ }
1044
+ });
902
1045
  }
903
1046
  // ============================================================================
904
1047
  // VISEME CONTROL
@@ -911,7 +1054,7 @@ var _LoomLargeThree = class _LoomLargeThree {
911
1054
  this.setMorph(morphKey, val);
912
1055
  const jawAmount = _LoomLargeThree.VISEME_JAW_AMOUNTS[visemeIndex] * val * jawScale;
913
1056
  if (Math.abs(jawScale) > 1e-6 && Math.abs(jawAmount) > 1e-6) {
914
- this.updateBoneRotation("JAW", "roll", jawAmount, _LoomLargeThree.JAW_MAX_DEGREES);
1057
+ this.updateBoneRotation("JAW", "pitch", jawAmount);
915
1058
  }
916
1059
  }
917
1060
  transitionViseme(visemeIndex, to, durationMs = 80, jawScale = 1) {
@@ -929,7 +1072,7 @@ var _LoomLargeThree = class _LoomLargeThree {
929
1072
  if (Math.abs(jawScale) <= 1e-6 || Math.abs(jawAmount) <= 1e-6) {
930
1073
  return morphHandle;
931
1074
  }
932
- const jawHandle = this.transitionBoneRotation("JAW", "roll", jawAmount, _LoomLargeThree.JAW_MAX_DEGREES, durationMs);
1075
+ const jawHandle = this.transitionBoneRotation("JAW", "pitch", jawAmount, durationMs);
933
1076
  return this.combineHandles([morphHandle, jawHandle]);
934
1077
  }
935
1078
  // ============================================================================
@@ -992,15 +1135,44 @@ var _LoomLargeThree = class _LoomLargeThree {
992
1135
  const result = [];
993
1136
  this.model.traverse((obj) => {
994
1137
  if (obj.isMesh) {
1138
+ const meshInfo = CC4_MESHES[obj.name];
995
1139
  result.push({
996
1140
  name: obj.name,
997
1141
  visible: obj.visible,
998
- morphCount: obj.morphTargetInfluences?.length || 0
1142
+ morphCount: obj.morphTargetInfluences?.length || 0,
1143
+ category: meshInfo?.category || "other"
999
1144
  });
1000
1145
  }
1001
1146
  });
1002
1147
  return result;
1003
1148
  }
1149
+ /** Get all morph targets grouped by mesh name */
1150
+ getMorphTargets() {
1151
+ const result = {};
1152
+ for (const mesh of this.meshes) {
1153
+ const dict = mesh.morphTargetDictionary;
1154
+ if (dict) {
1155
+ result[mesh.name] = Object.keys(dict).sort();
1156
+ }
1157
+ }
1158
+ return result;
1159
+ }
1160
+ /** Get all resolved bone names and their current transforms */
1161
+ getBones() {
1162
+ const result = {};
1163
+ for (const name of Object.keys(this.bones)) {
1164
+ const entry = this.bones[name];
1165
+ if (entry) {
1166
+ const pos = entry.obj.position;
1167
+ const rot = entry.obj.rotation;
1168
+ result[name] = {
1169
+ position: [pos.x, pos.y, pos.z],
1170
+ rotation: [rot.x * 180 / Math.PI, rot.y * 180 / Math.PI, rot.z * 180 / Math.PI]
1171
+ };
1172
+ }
1173
+ }
1174
+ return result;
1175
+ }
1004
1176
  setMeshVisible(meshName, visible) {
1005
1177
  if (!this.model) return;
1006
1178
  this.model.traverse((obj) => {
@@ -1009,6 +1181,70 @@ var _LoomLargeThree = class _LoomLargeThree {
1009
1181
  }
1010
1182
  });
1011
1183
  }
1184
+ /** Get material config for a mesh */
1185
+ getMeshMaterialConfig(meshName) {
1186
+ if (!this.model) return null;
1187
+ let result = null;
1188
+ this.model.traverse((obj) => {
1189
+ if (obj.isMesh && obj.name === meshName) {
1190
+ const mat = obj.material;
1191
+ if (mat) {
1192
+ let blendingName = "Normal";
1193
+ for (const [name, value] of Object.entries(_LoomLargeThree.BLENDING_MODES)) {
1194
+ if (mat.blending === value) {
1195
+ blendingName = name;
1196
+ break;
1197
+ }
1198
+ }
1199
+ result = {
1200
+ renderOrder: obj.renderOrder,
1201
+ transparent: mat.transparent,
1202
+ opacity: mat.opacity,
1203
+ depthWrite: mat.depthWrite,
1204
+ depthTest: mat.depthTest,
1205
+ blending: blendingName
1206
+ };
1207
+ }
1208
+ }
1209
+ });
1210
+ return result;
1211
+ }
1212
+ /** Set material config for a mesh */
1213
+ setMeshMaterialConfig(meshName, config) {
1214
+ if (!this.model) return;
1215
+ this.model.traverse((obj) => {
1216
+ if (obj.isMesh && obj.name === meshName) {
1217
+ const mat = obj.material;
1218
+ if (config.renderOrder !== void 0) {
1219
+ obj.renderOrder = config.renderOrder;
1220
+ }
1221
+ if (mat) {
1222
+ if (config.opacity !== void 0) {
1223
+ mat.opacity = config.opacity;
1224
+ if (config.opacity < 1 && config.transparent === void 0) {
1225
+ mat.transparent = true;
1226
+ }
1227
+ }
1228
+ if (config.transparent !== void 0) {
1229
+ mat.transparent = config.transparent;
1230
+ }
1231
+ if (config.depthWrite !== void 0) {
1232
+ mat.depthWrite = config.depthWrite;
1233
+ }
1234
+ if (config.depthTest !== void 0) {
1235
+ mat.depthTest = config.depthTest;
1236
+ }
1237
+ if (config.blending !== void 0) {
1238
+ const blendValue = _LoomLargeThree.BLENDING_MODES[config.blending];
1239
+ if (blendValue !== void 0) {
1240
+ mat.blending = blendValue;
1241
+ }
1242
+ }
1243
+ mat.needsUpdate = true;
1244
+ }
1245
+ }
1246
+ });
1247
+ }
1012
1248
  // ============================================================================
1013
1249
  // CONFIGURATION
1014
1250
  // ============================================================================
@@ -1063,20 +1299,20 @@ var _LoomLargeThree = class _LoomLargeThree {
1063
1299
  return !!(this.config.auToMorphs[id]?.length && this.config.auToBones[id]?.length);
1064
1300
  }
1065
1301
  initBoneRotations() {
1066
- const zeroAxis = { value: 0, maxRadians: 0 };
1067
1302
  this.rotations = {};
1068
1303
  this.pendingCompositeNodes.clear();
1069
1304
  const allBoneKeys = Array.from(
1070
1305
  new Set(Object.values(this.config.auToBones).flat().map((binding) => binding.node))
1071
1306
  );
1072
1307
  for (const node of allBoneKeys) {
1073
- this.rotations[node] = { pitch: { ...zeroAxis }, yaw: { ...zeroAxis }, roll: { ...zeroAxis } };
1308
+ this.rotations[node] = { pitch: 0, yaw: 0, roll: 0 };
1074
1309
  this.pendingCompositeNodes.add(node);
1075
1310
  }
1076
1311
  }
1077
- updateBoneRotation(nodeKey, axis, value, maxDegrees) {
1312
+ /** Update rotation state - just stores -1 to 1 value like stable version */
1313
+ updateBoneRotation(nodeKey, axis, value) {
1078
1314
  if (!this.rotations[nodeKey]) return;
1079
- this.rotations[nodeKey][axis] = { value: Math.max(-1, Math.min(1, value)), maxRadians: deg2rad(maxDegrees) };
1315
+ this.rotations[nodeKey][axis] = Math.max(-1, Math.min(1, value));
1080
1316
  this.pendingCompositeNodes.add(nodeKey);
1081
1317
  }
1082
1318
  updateBoneTranslation(nodeKey, channel, value, maxUnits) {
@@ -1088,11 +1324,11 @@ var _LoomLargeThree = class _LoomLargeThree {
1088
1324
  else this.translations[nodeKey].z = offset;
1089
1325
  this.pendingCompositeNodes.add(nodeKey);
1090
1326
  }
1091
- transitionBoneRotation(nodeKey, axis, to, maxDegrees, durationMs = 200) {
1327
+ transitionBoneRotation(nodeKey, axis, to, durationMs = 200) {
1092
1328
  const transitionKey = `bone_${nodeKey}_${axis}`;
1093
- const from = this.rotations[nodeKey]?.[axis]?.value ?? 0;
1329
+ const from = this.rotations[nodeKey]?.[axis] ?? 0;
1094
1330
  const target = Math.max(-1, Math.min(1, to));
1095
- return this.animation.addTransition(transitionKey, from, target, durationMs, (value) => this.updateBoneRotation(nodeKey, axis, value, maxDegrees));
1331
+ return this.animation.addTransition(transitionKey, from, target, durationMs, (value) => this.updateBoneRotation(nodeKey, axis, value));
1096
1332
  }
1097
1333
  transitionBoneTranslation(nodeKey, channel, to, maxUnits, durationMs = 200) {
1098
1334
  const transitionKey = `boneT_${nodeKey}_${channel}`;
@@ -1109,6 +1345,10 @@ var _LoomLargeThree = class _LoomLargeThree {
1109
1345
  }
1110
1346
  this.pendingCompositeNodes.clear();
1111
1347
  }
1348
+ /**
1349
+ * Apply composite rotation using quaternion composition like stable version.
1350
+ * Looks up maxDegrees and channel from BONE_AU_TO_BINDINGS.
1351
+ */
1112
1352
  applyCompositeRotation(nodeKey) {
1113
1353
  const entry = this.bones[nodeKey];
1114
1354
  if (!entry || !this.model) {
@@ -1117,12 +1357,60 @@ var _LoomLargeThree = class _LoomLargeThree {
1117
1357
  }
1118
1358
  return;
1119
1359
  }
1120
- const { obj, basePos, baseEuler } = entry;
1360
+ const { obj, basePos, baseQuat } = entry;
1121
1361
  const rotState = this.rotations[nodeKey];
1122
1362
  if (!rotState) return;
1123
- const yawRad = rotState.yaw.maxRadians * rotState.yaw.value;
1124
- const pitchRad = rotState.pitch.maxRadians * rotState.pitch.value;
1125
- const rollRad = rotState.roll.maxRadians * rotState.roll.value;
1363
+ const config = COMPOSITE_ROTATIONS.find((c) => c.node === nodeKey);
1364
+ if (!config) return;
1365
+ const getBindingForAxis = (axisConfig, direction) => {
1366
+ if (!axisConfig) return null;
1367
+ if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
1368
+ const auId = direction < 0 ? axisConfig.negative : axisConfig.positive;
1369
+ return BONE_AU_TO_BINDINGS[auId]?.find((b) => b.node === nodeKey);
1370
+ }
1371
+ if (axisConfig.aus.length > 1) {
1372
+ let maxAU = axisConfig.aus[0];
1373
+ let maxValue = this.auValues[maxAU] ?? 0;
1374
+ for (const auId of axisConfig.aus) {
1375
+ const val = this.auValues[auId] ?? 0;
1376
+ if (val > maxValue) {
1377
+ maxValue = val;
1378
+ maxAU = auId;
1379
+ }
1380
+ }
1381
+ return BONE_AU_TO_BINDINGS[maxAU]?.find((b) => b.node === nodeKey);
1382
+ }
1383
+ return BONE_AU_TO_BINDINGS[axisConfig.aus[0]]?.find((b) => b.node === nodeKey);
1384
+ };
1385
+ const getAxis = (channel) => channel === "rx" ? X_AXIS : channel === "ry" ? Y_AXIS : Z_AXIS;
1386
+ const compositeQ = new three.Quaternion().copy(baseQuat);
1387
+ if (config.yaw && rotState.yaw !== 0) {
1388
+ const binding = getBindingForAxis(config.yaw, rotState.yaw);
1389
+ if (binding?.maxDegrees && binding.channel) {
1390
+ const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.yaw) * binding.scale;
1391
+ const axis = getAxis(binding.channel);
1392
+ const deltaQ = new three.Quaternion().setFromAxisAngle(axis, radians);
1393
+ compositeQ.multiply(deltaQ);
1394
+ }
1395
+ }
1396
+ if (config.pitch && rotState.pitch !== 0) {
1397
+ const binding = getBindingForAxis(config.pitch, rotState.pitch);
1398
+ if (binding?.maxDegrees && binding.channel) {
1399
+ const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.pitch) * binding.scale;
1400
+ const axis = getAxis(binding.channel);
1401
+ const deltaQ = new three.Quaternion().setFromAxisAngle(axis, radians);
1402
+ compositeQ.multiply(deltaQ);
1403
+ }
1404
+ }
1405
+ if (config.roll && rotState.roll !== 0) {
1406
+ const binding = getBindingForAxis(config.roll, rotState.roll);
1407
+ if (binding?.maxDegrees && binding.channel) {
1408
+ const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.roll) * binding.scale;
1409
+ const axis = getAxis(binding.channel);
1410
+ const deltaQ = new three.Quaternion().setFromAxisAngle(axis, radians);
1411
+ compositeQ.multiply(deltaQ);
1412
+ }
1413
+ }
1126
1414
  obj.position.copy(basePos);
1127
1415
  const t = this.translations[nodeKey];
1128
1416
  if (t) {
@@ -1130,7 +1418,7 @@ var _LoomLargeThree = class _LoomLargeThree {
1130
1418
  obj.position.y += t.y;
1131
1419
  obj.position.z += t.z;
1132
1420
  }
1133
- obj.rotation.set(baseEuler.x + pitchRad, baseEuler.y + yawRad, baseEuler.z + rollRad, baseEuler.order);
1421
+ obj.quaternion.copy(compositeQ);
1134
1422
  obj.updateMatrixWorld(false);
1135
1423
  this.model.updateMatrixWorld(true);
1136
1424
  }
@@ -1200,6 +1488,13 @@ var _LoomLargeThree = class _LoomLargeThree {
1200
1488
  if (typeof settings.depthTest === "boolean") {
1201
1489
  obj.material.depthTest = settings.depthTest;
1202
1490
  }
1491
+ if (typeof settings.blending === "string") {
1492
+ const blendValue = _LoomLargeThree.BLENDING_MODES[settings.blending];
1493
+ if (blendValue !== void 0) {
1494
+ obj.material.blending = blendValue;
1495
+ }
1496
+ }
1497
+ obj.material.needsUpdate = true;
1203
1498
  }
1204
1499
  });
1205
1500
  }
@@ -1223,6 +1518,19 @@ __publicField(_LoomLargeThree, "VISEME_JAW_AMOUNTS", [
1223
1518
  0.4
1224
1519
  ]);
1225
1520
  __publicField(_LoomLargeThree, "JAW_MAX_DEGREES", 28);
1521
+ /** Blending mode options for Three.js materials */
1522
+ __publicField(_LoomLargeThree, "BLENDING_MODES", {
1523
+ "Normal": 1,
1524
+ // THREE.NormalBlending
1525
+ "Additive": 2,
1526
+ // THREE.AdditiveBlending
1527
+ "Subtractive": 3,
1528
+ // THREE.SubtractiveBlending
1529
+ "Multiply": 4,
1530
+ // THREE.MultiplyBlending
1531
+ "None": 0
1532
+ // THREE.NoBlending
1533
+ });
1226
1534
  var LoomLargeThree = _LoomLargeThree;
1227
1535
  function collectMorphMeshes(root) {
1228
1536
  const meshes = [];
@@ -1236,6 +1544,20 @@ function collectMorphMeshes(root) {
1236
1544
  return meshes;
1237
1545
  }
1238
1546
 
1547
+ // src/mappings/types.ts
1548
+ var BLENDING_MODES = {
1549
+ "Normal": 1,
1550
+ // THREE.NormalBlending
1551
+ "Additive": 2,
1552
+ // THREE.AdditiveBlending
1553
+ "Subtractive": 3,
1554
+ // THREE.SubtractiveBlending
1555
+ "Multiply": 4,
1556
+ // THREE.MultiplyBlending
1557
+ "None": 0
1558
+ // THREE.NoBlending
1559
+ };
1560
+
1239
1561
  // src/physics/HairPhysics.ts
1240
1562
  var DEFAULT_HAIR_PHYSICS_CONFIG = {
1241
1563
  mass: 1,
@@ -1364,6 +1686,7 @@ exports.AU_INFO = AU_INFO;
1364
1686
  exports.AU_MIX_DEFAULTS = AU_MIX_DEFAULTS;
1365
1687
  exports.AU_TO_MORPHS = AU_TO_MORPHS;
1366
1688
  exports.AnimationThree = AnimationThree;
1689
+ exports.BLENDING_MODES = BLENDING_MODES;
1367
1690
  exports.BONE_AU_TO_BINDINGS = BONE_AU_TO_BINDINGS;
1368
1691
  exports.CC4_BONE_NODES = CC4_BONE_NODES;
1369
1692
  exports.CC4_EYE_MESH_NODES = CC4_EYE_MESH_NODES;