@lovelace_lol/loom3 1.0.2 → 1.0.4

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
@@ -1,6 +1,6 @@
1
1
  # Loom3
2
2
 
3
- The missing character controller for Three.js, allowing you to bring humanoid and animal characters to life. Loom3 is based on the Facial Action Coding System (FACS) as the basis of its mappings, providing a morph and bone mapping library for controlling high-definition 3D characters in Three.js.
3
+ The missing character controller for Three.js! Loom3 allows you to bring humanoid and animal characters to life. Loom3 is based on the Facial Action Coding System (FACS) as the basis of its mappings, providing a morph and bone mapping library for controlling high-definition 3D characters in Three.js.
4
4
 
5
5
  Loom3 provides mappings that connect [Facial Action Coding System (FACS)](https://en.wikipedia.org/wiki/Facial_Action_Coding_System) Action Units to the morph targets and bone transforms found in Character Creator 4 (CC4) characters. Instead of manually figuring out which blend shapes correspond to which facial movements, you can simply say `setAU(12, 0.8)` and the library handles the rest.
6
6
 
package/dist/index.cjs CHANGED
@@ -150,6 +150,32 @@ var BakedAnimationController = class {
150
150
  __publicField(this, "actionIdToClip", /* @__PURE__ */ new Map());
151
151
  this.host = host;
152
152
  }
153
+ getMeshNamesForAU(auId, config, explicitMeshNames) {
154
+ if (explicitMeshNames && explicitMeshNames.length > 0) {
155
+ return explicitMeshNames;
156
+ }
157
+ if (typeof this.host.getMeshNamesForAU === "function") {
158
+ return this.host.getMeshNamesForAU(auId) || [];
159
+ }
160
+ const facePart = config.auInfo?.[String(auId)]?.facePart;
161
+ if (facePart) {
162
+ const category = config.auFacePartToMeshCategory?.[facePart];
163
+ if (category) return config.morphToMesh?.[category] || [];
164
+ }
165
+ return config.morphToMesh?.face || [];
166
+ }
167
+ getMeshNamesForViseme(config, explicitMeshNames) {
168
+ if (explicitMeshNames && explicitMeshNames.length > 0) {
169
+ return explicitMeshNames;
170
+ }
171
+ if (typeof this.host.getMeshNamesForViseme === "function") {
172
+ return this.host.getMeshNamesForViseme() || [];
173
+ }
174
+ const category = config.visemeMeshCategory || (config.morphToMesh?.viseme ? "viseme" : "face");
175
+ const visemeMeshes = config.morphToMesh?.[category];
176
+ if (visemeMeshes && visemeMeshes.length > 0) return visemeMeshes;
177
+ return config.morphToMesh?.face || [];
178
+ }
153
179
  update(dtSeconds) {
154
180
  if (this.animationMixer) {
155
181
  this.animationMixer.update(dtSeconds);
@@ -443,13 +469,15 @@ var BakedAnimationController = class {
443
469
  if (isNumericAU(curveId)) {
444
470
  const auId = Number(curveId);
445
471
  if (isVisemeIndex(curveId)) {
472
+ const visemeMeshNames = this.getMeshNamesForViseme(config, meshNames);
446
473
  const visemeKey = config.visemeKeys[auId];
447
474
  if (typeof visemeKey === "number") {
448
- this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
475
+ this.addMorphIndexTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
449
476
  } else if (visemeKey) {
450
- this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, meshNames);
477
+ this.addMorphTracks(tracks, visemeKey, keyframes, intensityScale, visemeMeshNames);
451
478
  }
452
479
  } else {
480
+ const auMeshNames = this.getMeshNamesForAU(auId, config, meshNames);
453
481
  const morphsBySide = config.auToMorphs[auId];
454
482
  const mixWeight = this.host.isMixedAU(auId) ? this.host.getAUMixWeight(auId) : 1;
455
483
  const leftKeys = morphsBySide?.left ?? [];
@@ -460,26 +488,26 @@ var BakedAnimationController = class {
460
488
  let effectiveScale = intensityScale * mixWeight;
461
489
  if (curveBalance > 0) effectiveScale *= 1 - curveBalance;
462
490
  if (typeof morphKey === "number") {
463
- this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
491
+ this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
464
492
  } else {
465
- this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
493
+ this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
466
494
  }
467
495
  }
468
496
  for (const morphKey of rightKeys) {
469
497
  let effectiveScale = intensityScale * mixWeight;
470
498
  if (curveBalance < 0) effectiveScale *= 1 + curveBalance;
471
499
  if (typeof morphKey === "number") {
472
- this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
500
+ this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
473
501
  } else {
474
- this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
502
+ this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
475
503
  }
476
504
  }
477
505
  for (const morphKey of centerKeys) {
478
506
  const effectiveScale = intensityScale * mixWeight;
479
507
  if (typeof morphKey === "number") {
480
- this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
508
+ this.addMorphIndexTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
481
509
  } else {
482
- this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, meshNames);
510
+ this.addMorphTracks(tracks, morphKey, keyframes, effectiveScale, auMeshNames);
483
511
  }
484
512
  }
485
513
  }
@@ -867,11 +895,9 @@ var BakedAnimationController = class {
867
895
  }
868
896
  addMorphTracks(tracks, morphKey, keyframes, intensityScale, meshNames) {
869
897
  const config = this.host.getConfig();
870
- const meshes = this.host.getMeshes();
871
898
  const hasExplicitMeshes = !!(meshNames && meshNames.length > 0);
872
899
  const targetMeshNames = hasExplicitMeshes ? meshNames : config.morphToMesh?.face || [];
873
- const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : meshes;
874
- let added = false;
900
+ const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : [];
875
901
  const addTrackForMesh = (mesh) => {
876
902
  const dict = mesh.morphTargetDictionary;
877
903
  if (!dict || dict[morphKey] === void 0) return;
@@ -885,25 +911,17 @@ var BakedAnimationController = class {
885
911
  const trackName = `${mesh.uuid}.morphTargetInfluences[${morphIndex}]`;
886
912
  const track = new THREE.NumberKeyframeTrack(trackName, times, values);
887
913
  tracks.push(track);
888
- added = true;
889
914
  };
890
915
  for (const mesh of targetMeshes) {
891
916
  addTrackForMesh(mesh);
892
917
  }
893
- if (!added && !hasExplicitMeshes && targetMeshes !== meshes) {
894
- for (const mesh of meshes) {
895
- addTrackForMesh(mesh);
896
- }
897
- }
898
918
  }
899
919
  addMorphIndexTracks(tracks, morphIndex, keyframes, intensityScale, meshNames) {
900
920
  if (!Number.isInteger(morphIndex) || morphIndex < 0) return;
901
921
  const config = this.host.getConfig();
902
- const meshes = this.host.getMeshes();
903
922
  const hasExplicitMeshes = !!(meshNames && meshNames.length > 0);
904
923
  const targetMeshNames = hasExplicitMeshes ? meshNames : config.morphToMesh?.face || [];
905
- const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : meshes;
906
- let added = false;
924
+ const targetMeshes = targetMeshNames.length ? targetMeshNames.map((name) => this.host.getMeshByName(name)).filter(Boolean) : [];
907
925
  const addTrackForMesh = (mesh) => {
908
926
  const infl = mesh.morphTargetInfluences;
909
927
  if (!infl || morphIndex < 0 || morphIndex >= infl.length) return;
@@ -916,16 +934,10 @@ var BakedAnimationController = class {
916
934
  const trackName = `${mesh.uuid}.morphTargetInfluences[${morphIndex}]`;
917
935
  const track = new THREE.NumberKeyframeTrack(trackName, times, values);
918
936
  tracks.push(track);
919
- added = true;
920
937
  };
921
938
  for (const mesh of targetMeshes) {
922
939
  addTrackForMesh(mesh);
923
940
  }
924
- if (!added && !hasExplicitMeshes && targetMeshes !== meshes) {
925
- for (const mesh of meshes) {
926
- addTrackForMesh(mesh);
927
- }
928
- }
929
941
  }
930
942
  ensureMixer() {
931
943
  const model = this.host.getModel();
@@ -1787,6 +1799,12 @@ var MORPH_TO_MESH = {
1787
1799
  tongue: ["CC_Base_Tongue", "CC_Base_Tongue_1"],
1788
1800
  hair: ["Side_part_wavy", "Side_part_wavy_1", "Side_part_wavy_2"]
1789
1801
  };
1802
+ var AU_FACEPART_TO_MESH_CATEGORY = {
1803
+ Eye: "eye",
1804
+ Eyes: "eye",
1805
+ Eyelids: "eye",
1806
+ Tongue: "tongue"
1807
+ };
1790
1808
  var CC4_HAIR_PHYSICS = {
1791
1809
  stiffness: 7.5,
1792
1810
  damping: 0.18,
@@ -1832,7 +1850,9 @@ var CC4_PRESET = {
1832
1850
  bonePrefix: CC4_BONE_PREFIX,
1833
1851
  suffixPattern: CC4_SUFFIX_PATTERN,
1834
1852
  morphToMesh: MORPH_TO_MESH,
1853
+ auFacePartToMeshCategory: AU_FACEPART_TO_MESH_CATEGORY,
1835
1854
  visemeKeys: VISEME_KEYS,
1855
+ visemeMeshCategory: "viseme",
1836
1856
  visemeJawAmounts: VISEME_JAW_AMOUNTS,
1837
1857
  auMixDefaults: AU_MIX_DEFAULTS,
1838
1858
  auInfo: AU_INFO,
@@ -2152,9 +2172,10 @@ var HairPhysicsController = class {
2152
2172
  if (meshName) {
2153
2173
  targetMesh = this.registeredHairObjects.get(meshName);
2154
2174
  } else {
2155
- for (const [name, mesh] of this.registeredHairObjects) {
2156
- const info = CC4_MESHES[name];
2157
- if (info?.category === "hair") {
2175
+ const hairMeshNames = this.getHairMeshNames();
2176
+ for (const name of hairMeshNames) {
2177
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2178
+ if (mesh) {
2158
2179
  targetMesh = mesh;
2159
2180
  break;
2160
2181
  }
@@ -2248,6 +2269,15 @@ var HairPhysicsController = class {
2248
2269
  }
2249
2270
  getHairMeshNames() {
2250
2271
  if (this.cachedHairMeshNames) return this.cachedHairMeshNames;
2272
+ if (typeof this.host.getSelectedHairMeshNames === "function") {
2273
+ const selectedHairMeshNames = this.host.getSelectedHairMeshNames() || [];
2274
+ const resolved = selectedHairMeshNames.filter((name) => {
2275
+ const mesh = this.registeredHairObjects.get(name) || this.host.getMeshByName(name);
2276
+ return !!mesh;
2277
+ });
2278
+ this.cachedHairMeshNames = Array.from(new Set(resolved));
2279
+ return this.cachedHairMeshNames;
2280
+ }
2251
2281
  const names = [];
2252
2282
  this.registeredHairObjects.forEach((mesh, name) => {
2253
2283
  const info = CC4_MESHES[name];
@@ -2263,6 +2293,18 @@ var HairPhysicsController = class {
2263
2293
  this.cachedHairMeshNames = names;
2264
2294
  return names;
2265
2295
  }
2296
+ refreshMeshSelection() {
2297
+ this.cachedHairMeshNames = null;
2298
+ this.idleClipDirty = true;
2299
+ this.gravityClipDirty = true;
2300
+ this.impulseClipDirty = true;
2301
+ this.warnMissingHairMorphTargets();
2302
+ if (this.hairPhysicsEnabled) {
2303
+ this.startIdleClip();
2304
+ this.startGravityClip();
2305
+ this.buildImpulseClips();
2306
+ }
2307
+ }
2266
2308
  supportsMixerClips() {
2267
2309
  return typeof this.host.buildClip === "function";
2268
2310
  }
@@ -2277,7 +2319,11 @@ var HairPhysicsController = class {
2277
2319
  return;
2278
2320
  }
2279
2321
  const hairMeshNames = this.getHairMeshNames();
2280
- if (hairMeshNames.length === 0) return;
2322
+ if (hairMeshNames.length === 0) {
2323
+ this.stopIdleClip();
2324
+ this.idleClipDirty = false;
2325
+ return;
2326
+ }
2281
2327
  if (!this.idleClipDirty && this.idleClipHandle) return;
2282
2328
  this.stopIdleClip();
2283
2329
  const duration = Math.max(0.5, cfg.idleClipDuration);
@@ -2295,7 +2341,11 @@ var HairPhysicsController = class {
2295
2341
  startGravityClip() {
2296
2342
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2297
2343
  const hairMeshNames = this.getHairMeshNames();
2298
- if (hairMeshNames.length === 0) return;
2344
+ if (hairMeshNames.length === 0) {
2345
+ this.stopGravityClip();
2346
+ this.gravityClipDirty = false;
2347
+ return;
2348
+ }
2299
2349
  if (!this.gravityClipDirty && this.gravityClipHandle) return;
2300
2350
  this.stopGravityClip();
2301
2351
  const morphTargets = this.hairPhysicsConfig.morphTargets;
@@ -2348,7 +2398,11 @@ var HairPhysicsController = class {
2348
2398
  buildImpulseClips() {
2349
2399
  if (!this.hairPhysicsEnabled || !this.supportsMixerClips()) return;
2350
2400
  const hairMeshNames = this.getHairMeshNames();
2351
- if (hairMeshNames.length === 0) return;
2401
+ if (hairMeshNames.length === 0) {
2402
+ this.stopImpulseClips();
2403
+ this.impulseClipDirty = false;
2404
+ return;
2405
+ }
2352
2406
  if (!this.impulseClipDirty && this.impulseClips.left && this.impulseClips.right && this.impulseClips.front) {
2353
2407
  return;
2354
2408
  }
@@ -2684,7 +2738,9 @@ function resolveProfile(base, override) {
2684
2738
  auToBones: mergeRecord(base.auToBones, override.auToBones),
2685
2739
  boneNodes: mergeRecord(base.boneNodes, override.boneNodes),
2686
2740
  morphToMesh: mergeRecord(base.morphToMesh, override.morphToMesh),
2741
+ auFacePartToMeshCategory: base.auFacePartToMeshCategory || override.auFacePartToMeshCategory ? mergeRecord(base.auFacePartToMeshCategory || {}, override.auFacePartToMeshCategory || {}) : void 0,
2687
2742
  visemeKeys: override.visemeKeys ? [...override.visemeKeys] : [...base.visemeKeys],
2743
+ visemeMeshCategory: override.visemeMeshCategory ?? base.visemeMeshCategory,
2688
2744
  auMixDefaults: base.auMixDefaults || override.auMixDefaults ? mergeRecord(base.auMixDefaults || {}, override.auMixDefaults || {}) : void 0,
2689
2745
  auInfo: base.auInfo || override.auInfo ? mergeRecord(base.auInfo || {}, override.auInfo || {}) : void 0,
2690
2746
  eyeMeshNodes: override.eyeMeshNodes ?? base.eyeMeshNodes,
@@ -3522,6 +3578,7 @@ var _Loom3 = class _Loom3 {
3522
3578
  this.auToCompositeMap = buildAUToCompositeMap(this.compositeRotations);
3523
3579
  this.hairPhysics = new HairPhysicsController({
3524
3580
  getMeshByName: (name) => this.meshByName.get(name),
3581
+ getSelectedHairMeshNames: () => this.config.morphToMesh?.hair || [],
3525
3582
  buildClip: (clipName, curves, options) => this.buildClip(clipName, curves, options),
3526
3583
  cleanupSnippet: (name) => this.cleanupSnippet(name)
3527
3584
  });
@@ -3529,6 +3586,8 @@ var _Loom3 = class _Loom3 {
3529
3586
  getModel: () => this.model,
3530
3587
  getMeshes: () => this.meshes,
3531
3588
  getMeshByName: (name) => this.meshByName.get(name),
3589
+ getMeshNamesForAU: (auId) => this.getMeshNamesForAU(auId),
3590
+ getMeshNamesForViseme: () => this.getMeshNamesForViseme(),
3532
3591
  getBones: () => this.bones,
3533
3592
  getConfig: () => this.config,
3534
3593
  getCompositeRotations: () => this.compositeRotations,
@@ -3574,15 +3633,11 @@ var _Loom3 = class _Loom3 {
3574
3633
  this.resolvedFaceMeshes = this.resolveFaceMeshes(this.meshes);
3575
3634
  this.faceMesh = this.resolvedFaceMeshes.length > 0 ? this.meshByName.get(this.resolvedFaceMeshes[0]) || null : null;
3576
3635
  if (!this.config.morphToMesh?.face || this.config.morphToMesh.face.length === 0) {
3577
- const morphMeshNames = this.meshes.filter((m) => {
3578
- const infl = m.morphTargetInfluences;
3579
- const dict = m.morphTargetDictionary;
3580
- return Array.isArray(infl) && infl.length > 0 || dict && Object.keys(dict).length > 0;
3581
- }).map((m) => m.name).filter(Boolean);
3582
- if (morphMeshNames.length > 0) {
3636
+ const faceMeshNames = this.resolvedFaceMeshes.filter((name) => this.meshByName.has(name));
3637
+ if (faceMeshNames.length > 0) {
3583
3638
  this.config.morphToMesh = {
3584
3639
  ...this.config.morphToMesh,
3585
- face: Array.from(new Set(morphMeshNames))
3640
+ face: Array.from(new Set(faceMeshNames))
3586
3641
  };
3587
3642
  }
3588
3643
  }
@@ -3629,7 +3684,8 @@ var _Loom3 = class _Loom3 {
3629
3684
  }
3630
3685
  for (let i = 0; i < (this.config.visemeKeys || []).length; i += 1) {
3631
3686
  const key = this.config.visemeKeys[i];
3632
- const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key) : this.resolveMorphTargets(key);
3687
+ const visemeMeshNames = this.getMeshNamesForViseme();
3688
+ const targets = typeof key === "number" ? this.resolveMorphTargetsByIndex(key, visemeMeshNames) : this.resolveMorphTargets(key, visemeMeshNames);
3633
3689
  this.resolvedVisemeTargets[i] = targets;
3634
3690
  }
3635
3691
  }
@@ -4061,10 +4117,11 @@ var _Loom3 = class _Loom3 {
4061
4117
  this.applyMorphTargets(targets, val);
4062
4118
  } else {
4063
4119
  const morphKey = this.config.visemeKeys[visemeIndex];
4120
+ const visemeMeshNames = this.getMeshNamesForViseme();
4064
4121
  if (typeof morphKey === "number") {
4065
- this.setMorphInfluence(morphKey, val);
4122
+ this.setMorphInfluence(morphKey, val, visemeMeshNames);
4066
4123
  } else if (typeof morphKey === "string") {
4067
- this.setMorph(morphKey, val);
4124
+ this.setMorph(morphKey, val, visemeMeshNames);
4068
4125
  }
4069
4126
  }
4070
4127
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * val * jawScale;
@@ -4082,7 +4139,8 @@ var _Loom3 = class _Loom3 {
4082
4139
  const morphKey = this.config.visemeKeys[visemeIndex];
4083
4140
  const target = clamp012(to);
4084
4141
  this.visemeValues[visemeIndex] = target;
4085
- const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs) : this.transitionMorph(morphKey, target, durationMs);
4142
+ const visemeMeshNames = this.getMeshNamesForViseme();
4143
+ const morphHandle = typeof morphKey === "number" ? this.transitionMorphInfluence(morphKey, target, durationMs, visemeMeshNames) : this.transitionMorph(morphKey, target, durationMs, visemeMeshNames);
4086
4144
  const jawAmount = _Loom3.VISEME_JAW_AMOUNTS[visemeIndex] * target * jawScale;
4087
4145
  if (Math.abs(jawScale) <= 1e-6 || Math.abs(jawAmount) <= 1e-6) {
4088
4146
  return morphHandle;
@@ -4386,6 +4444,7 @@ var _Loom3 = class _Loom3 {
4386
4444
  if (this.model) {
4387
4445
  this.rebuildMorphTargetsCache();
4388
4446
  }
4447
+ this.hairPhysics.refreshMeshSelection();
4389
4448
  this.applyHairPhysicsProfileConfig();
4390
4449
  }
4391
4450
  getProfile() {
@@ -4393,20 +4452,24 @@ var _Loom3 = class _Loom3 {
4393
4452
  }
4394
4453
  /**
4395
4454
  * Get the mesh names that should receive morph influences for a given AU.
4396
- * Routes by facePart: Tongue tongue, Eye → eye, everything else → face.
4455
+ * Routing is driven by `auFacePartToMeshCategory` in profile config.
4397
4456
  */
4398
4457
  getMeshNamesForAU(auId) {
4399
4458
  const m = this.config.morphToMesh;
4400
4459
  const info = this.config.auInfo?.[String(auId)];
4401
- if (!info?.facePart) return m?.face || [];
4402
- switch (info.facePart) {
4403
- case "Tongue":
4404
- return m?.tongue || [];
4405
- case "Eye":
4406
- return m?.eye || [];
4407
- default:
4408
- return m?.face || [];
4460
+ const facePart = info?.facePart;
4461
+ if (facePart) {
4462
+ const category = this.config.auFacePartToMeshCategory?.[facePart];
4463
+ if (category) {
4464
+ return m?.[category] || [];
4465
+ }
4409
4466
  }
4467
+ return m?.face || [];
4468
+ }
4469
+ getMeshNamesForViseme() {
4470
+ const m = this.config.morphToMesh;
4471
+ const category = this.config.visemeMeshCategory || (m?.viseme ? "viseme" : "face");
4472
+ return m?.[category] || m?.face || [];
4410
4473
  }
4411
4474
  // ============================================================================
4412
4475
  // HAIR PHYSICS