@lovelace_lol/loom3 1.0.4 → 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/README.md CHANGED
@@ -854,7 +854,7 @@ Only AUs that have both `auToMorphs` AND `auToBones` entries support mixing. Com
854
854
  - AU26 (Jaw Drop)
855
855
  - AU27 (Mouth Stretch)
856
856
  - AU51-56 (Head movements)
857
- - AU61-64 (Eye movements)
857
+ - AU61-72 (Shared + independent eye movements)
858
858
 
859
859
  ```typescript
860
860
  import { isMixedAU } from '@lovelace_lol/loom3';
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]));
@@ -554,43 +596,23 @@ var BakedAnimationController = class {
554
596
  Object.keys(curves).filter(isNumericAU).map((id) => Number(id))
555
597
  );
556
598
  const getAxisBinding = (nodeKey, axisConfig, axisValue, t) => {
557
- if (!axisConfig) return null;
558
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
559
- const auId2 = axisValue < 0 ? axisConfig.negative : axisConfig.positive;
560
- return config.auToBones[auId2]?.find((b) => b.node === nodeKey) ?? null;
561
- }
562
- if (axisConfig.aus.length > 1) {
563
- let maxAU = axisConfig.aus[0];
564
- let maxVal = sampleCurve(String(maxAU), t);
565
- for (const auId2 of axisConfig.aus) {
566
- const val = sampleCurve(String(auId2), t);
567
- if (val > maxVal) {
568
- maxVal = val;
569
- maxAU = auId2;
570
- }
571
- }
572
- return config.auToBones[maxAU]?.find((b) => b.node === nodeKey) ?? null;
573
- }
574
- const auId = axisConfig.aus[0];
575
- 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
+ );
576
606
  };
577
- const getAxisValue = (axisConfig, t) => {
578
- if (!axisConfig) return 0;
579
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
580
- const posValue = sampleCurve(String(axisConfig.positive), t);
581
- const negValue = sampleCurve(String(axisConfig.negative), t);
582
- return posValue - negValue;
583
- }
584
- if (axisConfig.aus.length > 1) {
585
- let maxVal = 0;
586
- for (const auId of axisConfig.aus) {
587
- const val = sampleCurve(String(auId), t);
588
- if (val > maxVal) maxVal = val;
589
- }
590
- return maxVal;
591
- }
592
- 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);
593
614
  };
615
+ const getAxisValue = (nodeKey, axisConfig, t) => getCompositeAxisValue(axisConfig, (auId) => getAxisSampleForNode(auId, nodeKey, t));
594
616
  const autoVisemeJawHandledJaw = autoVisemeJaw && jawScale > 0 && visemeJawAmounts && options?.snippetCategory === "visemeSnippet";
595
617
  for (const composite of compositeRotations) {
596
618
  const nodeKey = composite.node;
@@ -611,7 +633,7 @@ var BakedAnimationController = class {
611
633
  const compositeQ = new THREE.Quaternion().copy(entry.baseQuat);
612
634
  const applyAxis = (axisConfig) => {
613
635
  if (!axisConfig) return;
614
- let axisValue = getAxisValue(axisConfig, t);
636
+ let axisValue = getAxisValue(nodeKey, axisConfig, t);
615
637
  if (Math.abs(axisValue) <= 1e-6) return;
616
638
  const binding = getAxisBinding(nodeKey, axisConfig, axisValue, t);
617
639
  if (!binding?.maxDegrees || !binding.channel) return;
@@ -1214,24 +1236,24 @@ var AU_TO_MORPHS = {
1214
1236
  center: []
1215
1237
  },
1216
1238
  61: {
1217
- left: ["Eye_L_Look_L", "Eye_R_Look_L"],
1218
- right: [],
1239
+ left: ["Eye_L_Look_L"],
1240
+ right: ["Eye_R_Look_L"],
1219
1241
  center: []
1220
1242
  },
1221
1243
  62: {
1222
- left: [],
1223
- right: ["Eye_L_Look_R", "Eye_R_Look_R"],
1244
+ left: ["Eye_L_Look_R"],
1245
+ right: ["Eye_R_Look_R"],
1224
1246
  center: []
1225
1247
  },
1226
1248
  63: {
1227
- left: [],
1228
- right: [],
1229
- center: ["Eye_L_Look_Up", "Eye_R_Look_Up"]
1249
+ left: ["Eye_L_Look_Up"],
1250
+ right: ["Eye_R_Look_Up"],
1251
+ center: []
1230
1252
  },
1231
1253
  64: {
1232
- left: [],
1233
- right: [],
1234
- center: ["Eye_L_Look_Down", "Eye_R_Look_Down"]
1254
+ left: ["Eye_L_Look_Down"],
1255
+ right: ["Eye_R_Look_Down"],
1256
+ center: []
1235
1257
  },
1236
1258
  65: {
1237
1259
  left: ["Eye_L_Look_L"],
@@ -1459,6 +1481,14 @@ var BONE_AU_TO_BINDINGS = {
1459
1481
  { node: "EYE_L", channel: "rx", scale: 1, maxDegrees: 20, side: "left" },
1460
1482
  { node: "EYE_R", channel: "rx", scale: 1, maxDegrees: 20, side: "right" }
1461
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" }],
1462
1492
  // Tongue controls (optional, for rigs that expose them)
1463
1493
  37: [{ node: "TONGUE", channel: "rz", scale: 1, maxDegrees: 20 }],
1464
1494
  38: [{ node: "TONGUE", channel: "rz", scale: -1, maxDegrees: 20 }],
@@ -1546,18 +1576,18 @@ var COMPOSITE_ROTATIONS = [
1546
1576
  },
1547
1577
  {
1548
1578
  node: "EYE_L",
1549
- 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] },
1550
1580
  // Eyes down/up
1551
- 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] },
1552
1582
  // Eyes left/right (rz for CC4)
1553
1583
  roll: null
1554
1584
  // Eyes don't have roll
1555
1585
  },
1556
1586
  {
1557
1587
  node: "EYE_R",
1558
- 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] },
1559
1589
  // Eyes down/up
1560
- 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] },
1561
1591
  // Eyes left/right (rz for CC4)
1562
1592
  roll: null
1563
1593
  // Eyes don't have roll
@@ -1576,9 +1606,17 @@ var CONTINUUM_PAIRS_MAP = {
1576
1606
  // Eyes horizontal - both eyes share same AUs (yaw maps to rz via COMPOSITE_ROTATIONS)
1577
1607
  61: { pairId: 62, isNegative: true, axis: "yaw", node: "EYE_L" },
1578
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" },
1579
1613
  // Eyes vertical (pitch)
1580
1614
  64: { pairId: 63, isNegative: true, axis: "pitch", node: "EYE_L" },
1581
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" },
1582
1620
  // Head yaw (turn left/right)
1583
1621
  51: { pairId: 52, isNegative: true, axis: "yaw", node: "HEAD" },
1584
1622
  52: { pairId: 51, isNegative: false, axis: "yaw", node: "HEAD" },
@@ -1611,6 +1649,10 @@ var CONTINUUM_PAIRS_MAP = {
1611
1649
  var CONTINUUM_LABELS = {
1612
1650
  "61-62": "Eyes \u2014 Horizontal",
1613
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",
1614
1656
  "51-52": "Head \u2014 Horizontal",
1615
1657
  "54-53": "Head \u2014 Vertical",
1616
1658
  "55-56": "Head \u2014 Tilt",
@@ -3855,37 +3897,12 @@ var _Loom3 = class _Loom3 {
3855
3897
  }
3856
3898
  const compositeInfo = this.auToCompositeMap.get(id);
3857
3899
  if (compositeInfo) {
3858
- const storedBalance = this.auBalances[id] ?? 0;
3859
- const { left: leftVal, right: rightVal } = this.computeSideValues(clamp012(v), storedBalance);
3860
3900
  for (const nodeKey of compositeInfo.nodes) {
3861
3901
  const config = this.compositeRotations.find((c) => c.node === nodeKey);
3862
3902
  if (!config) continue;
3863
3903
  const axisConfig = config[compositeInfo.axis];
3864
3904
  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 = v;
3874
- }
3875
- const auBoneBindings = this.config.auToBones[id] || [];
3876
- const sideByNode = /* @__PURE__ */ new Map();
3877
- for (const binding of auBoneBindings) {
3878
- if (binding.side === "left" || binding.side === "right") {
3879
- sideByNode.set(binding.node, binding.side);
3880
- }
3881
- }
3882
- const side = sideByNode.get(nodeKey);
3883
- if (side) {
3884
- const baseValue = clamp012(v);
3885
- const balanceValue = side === "left" ? leftVal : rightVal;
3886
- const denom = baseValue > 0 ? baseValue : 1;
3887
- axisValue = axisValue * (balanceValue / denom);
3888
- }
3905
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3889
3906
  this.updateBoneRotation(nodeKey, compositeInfo.axis, axisValue);
3890
3907
  this.pendingCompositeNodes.add(nodeKey);
3891
3908
  }
@@ -3913,11 +3930,15 @@ var _Loom3 = class _Loom3 {
3913
3930
  }
3914
3931
  }
3915
3932
  const target = clamp012(to);
3933
+ if (balance !== void 0) {
3934
+ this.auBalances[numId] = balance;
3935
+ }
3936
+ const storedBalance = this.auBalances[numId] ?? 0;
3916
3937
  const { left: leftKeys, right: rightKeys, center: centerKeys } = this.getAUMorphsBySide(numId);
3917
3938
  const bindings = this.config.auToBones[numId] || [];
3918
3939
  const mixWeight = this.isMixedAU(numId) ? this.getAUMixWeight(numId) : 1;
3919
3940
  const base = target * mixWeight;
3920
- const { left: leftVal, right: rightVal } = this.computeSideValues(base, balance);
3941
+ const { left: leftVal, right: rightVal } = this.computeSideValues(base, storedBalance);
3921
3942
  this.auValues[numId] = target;
3922
3943
  const handles = [];
3923
3944
  const meshNames = this.getMeshNamesForAU(numId);
@@ -3945,16 +3966,7 @@ var _Loom3 = class _Loom3 {
3945
3966
  if (!config) continue;
3946
3967
  const axisConfig = config[compositeInfo.axis];
3947
3968
  if (!axisConfig) continue;
3948
- let axisValue;
3949
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
3950
- const negValue = this.auValues[axisConfig.negative] ?? 0;
3951
- const posValue = this.auValues[axisConfig.positive] ?? 0;
3952
- axisValue = posValue - negValue;
3953
- } else if (axisConfig.aus.length > 1) {
3954
- axisValue = Math.max(...axisConfig.aus.map((auId) => this.auValues[auId] ?? 0));
3955
- } else {
3956
- axisValue = target;
3957
- }
3969
+ const axisValue = this.getCompositeAxisValueForNode(nodeKey, axisConfig);
3958
3970
  handles.push(this.transitionBoneRotation(nodeKey, compositeInfo.axis, axisValue, durationMs));
3959
3971
  }
3960
3972
  }
@@ -4588,6 +4600,25 @@ var _Loom3 = class _Loom3 {
4588
4600
  const hasMorphs = !!(morphs?.left?.length || morphs?.right?.length || morphs?.center?.length);
4589
4601
  return !!(hasMorphs && this.config.auToBones[id]?.length);
4590
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
+ }
4591
4622
  initBoneRotations() {
4592
4623
  this.rotations = {};
4593
4624
  this.pendingCompositeNodes.clear();
@@ -4663,30 +4694,10 @@ var _Loom3 = class _Loom3 {
4663
4694
  if (!config) {
4664
4695
  return;
4665
4696
  }
4666
- const getBindingForAxis = (axisConfig, direction) => {
4667
- if (!axisConfig) return null;
4668
- if (axisConfig.negative !== void 0 && axisConfig.positive !== void 0) {
4669
- const auId = direction < 0 ? axisConfig.negative : axisConfig.positive;
4670
- return this.config.auToBones[auId]?.find((b) => b.node === nodeKey);
4671
- }
4672
- if (axisConfig.aus.length > 1) {
4673
- let maxAU = axisConfig.aus[0];
4674
- let maxValue = this.auValues[maxAU] ?? 0;
4675
- for (const auId of axisConfig.aus) {
4676
- const val = this.auValues[auId] ?? 0;
4677
- if (val > maxValue) {
4678
- maxValue = val;
4679
- maxAU = auId;
4680
- }
4681
- }
4682
- return this.config.auToBones[maxAU]?.find((b) => b.node === nodeKey);
4683
- }
4684
- return this.config.auToBones[axisConfig.aus[0]]?.find((b) => b.node === nodeKey);
4685
- };
4686
4697
  const getAxis = (channel) => channel === "rx" ? X_AXIS2 : channel === "ry" ? Y_AXIS2 : Z_AXIS2;
4687
4698
  const compositeQ = new THREE.Quaternion().copy(baseQuat);
4688
4699
  if (config.yaw && rotState.yaw !== 0) {
4689
- const binding = getBindingForAxis(config.yaw, rotState.yaw);
4700
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.yaw, rotState.yaw);
4690
4701
  if (binding?.maxDegrees && binding.channel) {
4691
4702
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.yaw) * binding.scale;
4692
4703
  const axis = getAxis(binding.channel);
@@ -4695,7 +4706,7 @@ var _Loom3 = class _Loom3 {
4695
4706
  }
4696
4707
  }
4697
4708
  if (config.pitch && rotState.pitch !== 0) {
4698
- const binding = getBindingForAxis(config.pitch, rotState.pitch);
4709
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.pitch, rotState.pitch);
4699
4710
  if (binding?.maxDegrees && binding.channel) {
4700
4711
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.pitch) * binding.scale;
4701
4712
  const axis = getAxis(binding.channel);
@@ -4704,7 +4715,7 @@ var _Loom3 = class _Loom3 {
4704
4715
  }
4705
4716
  }
4706
4717
  if (config.roll && rotState.roll !== 0) {
4707
- const binding = getBindingForAxis(config.roll, rotState.roll);
4718
+ const binding = this.getCompositeAxisBindingForNode(nodeKey, config.roll, rotState.roll);
4708
4719
  if (binding?.maxDegrees && binding.channel) {
4709
4720
  const radians = deg2rad(binding.maxDegrees) * Math.abs(rotState.roll) * binding.scale;
4710
4721
  const axis = getAxis(binding.channel);
@@ -5573,7 +5584,7 @@ function findMatches(targetNames, candidateNames, prefix, suffix, suffixPattern)
5573
5584
  return { found, missing };
5574
5585
  }
5575
5586
  function collectAxisConfigs(axisConfigs) {
5576
- return axisConfigs.filter((entry) => entry.config);
5587
+ return axisConfigs.filter((entry) => entry.config !== null);
5577
5588
  }
5578
5589
  function isEyeNodeKey(nodeKey) {
5579
5590
  return nodeKey === "EYE_L" || nodeKey === "EYE_R";
@@ -5643,28 +5654,35 @@ function validateMappingConfig(config) {
5643
5654
  );
5644
5655
  continue;
5645
5656
  }
5646
- if (axisConfig.negative !== void 0 && !axisConfig.aus.includes(axisConfig.negative)) {
5647
- push(
5648
- "error",
5649
- "COMPOSITE_AU_MISSING",
5650
- `Composite axis for "${composite.node}" is missing negative AU ${axisConfig.negative} in aus list`,
5651
- { node: composite.node, auId: axisConfig.negative }
5652
- );
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
+ }
5653
5666
  }
5654
- if (axisConfig.positive !== void 0 && !axisConfig.aus.includes(axisConfig.positive)) {
5655
- push(
5656
- "error",
5657
- "COMPOSITE_AU_MISSING",
5658
- `Composite axis for "${composite.node}" is missing positive AU ${axisConfig.positive} in aus list`,
5659
- { node: composite.node, auId: axisConfig.positive }
5660
- );
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
+ }
5661
5676
  }
5662
- 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) {
5663
5681
  push(
5664
5682
  "error",
5665
5683
  "COMPOSITE_AU_DUPLICATE",
5666
- `Composite axis for "${composite.node}" has identical negative/positive AU ${axisConfig.negative}`,
5667
- { 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] }
5668
5686
  );
5669
5687
  }
5670
5688
  }
@@ -5754,11 +5772,11 @@ function validateMappingConfig(config) {
5754
5772
  );
5755
5773
  continue;
5756
5774
  }
5757
- const expectedNeg = axisConfig.negative;
5758
- const expectedPos = axisConfig.positive;
5775
+ const expectedNeg = toAUList(axisConfig.negative);
5776
+ const expectedPos = toAUList(axisConfig.positive);
5759
5777
  const negId = info.isNegative ? Number(auIdStr) : info.pairId;
5760
5778
  const posId = info.isNegative ? info.pairId : Number(auIdStr);
5761
- if (negId !== expectedNeg || posId !== expectedPos) {
5779
+ if (!expectedNeg.includes(negId) || !expectedPos.includes(posId)) {
5762
5780
  push(
5763
5781
  "warning",
5764
5782
  "CONTINUUM_COMPOSITE_MISMATCH",