@lovelace_lol/loom3 1.0.39 → 1.0.40

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
@@ -1356,6 +1356,25 @@ const targets = loom.resolveMorphTargets('Mouth_Smile_L', ['CC_Base_Body']);
1356
1356
  const value = targets.length > 0 ? (targets[0].infl[targets[0].idx] ?? 0) : 0;
1357
1357
  ```
1358
1358
 
1359
+ ### Adding runtime morph targets
1360
+
1361
+ Generated or sidecar morph targets can be registered after a model loads. Deltas use the same relative `POSITION` format as glTF morph targets: one XYZ delta per base mesh vertex. Optional `normal` and `tangent` deltas can be supplied when available.
1362
+
1363
+ ```typescript
1364
+ const index = loom.addMorphTarget({
1365
+ meshName: 'CC_Base_Body',
1366
+ name: 'BodyType_Muscular',
1367
+ position: bodyTypeMuscularDeltas,
1368
+ });
1369
+
1370
+ loom.setMorphInfluence(index, 0.6, ['CC_Base_Body']);
1371
+ loom.setMorph('BodyType_Muscular', 0.6, ['CC_Base_Body']);
1372
+ ```
1373
+
1374
+ By default, Loom3 replaces and disposes the mesh `BufferGeometry` before appending morph attributes. This is intentional: Three.js does not support mutating `geometry.morphAttributes` in place after a geometry has rendered. For pre-render authoring paths, pass `{ forceGeometryReplacement: false }`.
1375
+
1376
+ If you need a named slot before real deltas are available, use `ensureMorphInfluence(meshName, morphName)`. It creates a zero-delta target and returns the assigned `morphTargetInfluences` index. After external code changes morph dictionaries or geometry, call `refreshMorphTargets()` so AU, viseme, hair, and clip-building caches see the updated targets.
1377
+
1359
1378
  ### Morph caching
1360
1379
 
1361
1380
  Loom3 caches morph target lookups for performance. The first time you access a morph, it searches all meshes and caches the index. Subsequent accesses are O(1).
@@ -2128,6 +2147,7 @@ This is a compact reference for the public surface exported by `@lovelace_lol/lo
2128
2147
  - Lifecycle: `onReady()`, `update()`, `start()`, `stop()`, `dispose()`.
2129
2148
  - Preset state: `setProfile()`, `getProfile()`.
2130
2149
  - Control APIs: `setAU()`, `transitionAU()`, `setContinuum()`, `transitionContinuum()`, `setMorph()`, `transitionMorph()`, `setViseme()`, `transitionViseme()`.
2150
+ - Runtime morph authoring: `addMorphTarget()`, `addMorphTargets()`, `ensureMorphInfluence()`, `refreshMorphTargets()`.
2131
2151
  - Transition state: `pause()`, `resume()`, `getPaused()`, `clearTransitions()`, `getActiveTransitionCount()`, `resetToNeutral()`.
2132
2152
 
2133
2153
  ### Presets and profiles
package/dist/index.cjs CHANGED
@@ -4638,11 +4638,7 @@ var _Loom3 = class _Loom3 {
4638
4638
  this.morphIndexCache.clear();
4639
4639
  model.traverse((obj) => {
4640
4640
  if (obj.isMesh && obj.name) {
4641
- const infl = obj.morphTargetInfluences;
4642
- const dict = obj.morphTargetDictionary;
4643
- if (Array.isArray(infl) && infl.length > 0 || dict && Object.keys(dict).length > 0) {
4644
- this.meshByName.set(obj.name, obj);
4645
- }
4641
+ this.meshByName.set(obj.name, obj);
4646
4642
  }
4647
4643
  });
4648
4644
  this.bones = this.resolveBones(model);
@@ -5010,6 +5006,58 @@ var _Loom3 = class _Loom3 {
5010
5006
  const currentContinuum = currentPos - currentNeg;
5011
5007
  return this.animation.addTransition(driverKey, currentContinuum, target, durationMs, (value) => this.setContinuum(negAU, posAU, value, balance));
5012
5008
  }
5009
+ // ============================================================================
5010
+ // MORPH CONTROL
5011
+ // ============================================================================
5012
+ addMorphTarget(target, options = {}) {
5013
+ const staleMorphTargets = this.collectResolvedExpressionMorphTargets();
5014
+ const index = this.applyMorphTargetDelta(target, options);
5015
+ this.refreshMorphTargets([target.meshName]);
5016
+ this.reinitializeRuntimeStateFromCurrentControls(staleMorphTargets);
5017
+ return index;
5018
+ }
5019
+ addMorphTargets(targets, options = {}) {
5020
+ const staleMorphTargets = this.collectResolvedExpressionMorphTargets();
5021
+ const result = {};
5022
+ for (const target of targets) {
5023
+ const index = this.applyMorphTargetDelta(target, options);
5024
+ result[`${target.meshName}:${target.name}`] = index;
5025
+ }
5026
+ this.refreshMorphTargets(Array.from(new Set(targets.map((target) => target.meshName))));
5027
+ this.reinitializeRuntimeStateFromCurrentControls(staleMorphTargets);
5028
+ return result;
5029
+ }
5030
+ ensureMorphInfluence(meshName, morphName) {
5031
+ const mesh = this.requireNamedMesh(meshName);
5032
+ const dict = this.getMeshMorphDictionary(mesh);
5033
+ const existing = dict[morphName];
5034
+ if (existing !== void 0) return existing;
5035
+ const position = mesh.geometry.getAttribute("position");
5036
+ if (!position) {
5037
+ throw new Error(`Cannot create morph target "${morphName}" on mesh "${meshName}": geometry has no position attribute.`);
5038
+ }
5039
+ return this.addMorphTarget({
5040
+ meshName,
5041
+ name: morphName,
5042
+ position: new Float32Array(position.count * position.itemSize),
5043
+ relative: true
5044
+ });
5045
+ }
5046
+ refreshMorphTargets(_meshNames) {
5047
+ this.morphKeyCache.clear();
5048
+ this.morphIndexCache.clear();
5049
+ if (this.model) {
5050
+ this.meshByName.clear();
5051
+ this.model.traverse((obj) => {
5052
+ if (obj.isMesh && obj.name) {
5053
+ this.meshByName.set(obj.name, obj);
5054
+ }
5055
+ });
5056
+ this.meshes = collectMorphMeshes(this.model);
5057
+ }
5058
+ this.rebuildMorphTargetsCache();
5059
+ this.hairPhysics.refreshMeshSelection();
5060
+ }
5013
5061
  setMorph(key, v, meshNamesOrTargets) {
5014
5062
  const val = clamp012(v);
5015
5063
  if (Array.isArray(meshNamesOrTargets) && meshNamesOrTargets.length > 0 && typeof meshNamesOrTargets[0] === "object" && "infl" in meshNamesOrTargets[0]) {
@@ -5628,6 +5676,148 @@ var _Loom3 = class _Loom3 {
5628
5676
  }
5629
5677
  return 0;
5630
5678
  }
5679
+ applyMorphTargetDelta(target, options) {
5680
+ const mesh = this.requireNamedMesh(target.meshName);
5681
+ const sourceGeometry = mesh.geometry;
5682
+ const position = sourceGeometry.getAttribute("position");
5683
+ if (!position) {
5684
+ throw new Error(`Cannot add morph target "${target.name}" to mesh "${target.meshName}": geometry has no position attribute.`);
5685
+ }
5686
+ if (!target.name || !target.name.trim()) {
5687
+ throw new Error(`Cannot add morph target to mesh "${target.meshName}": target name is required.`);
5688
+ }
5689
+ const replace = options.replace === true;
5690
+ const resetInfluence = options.resetInfluence !== false;
5691
+ const forceGeometryReplacement = options.forceGeometryReplacement !== false;
5692
+ const previousInfluences = mesh.morphTargetInfluences ? [...mesh.morphTargetInfluences] : [];
5693
+ const previousDictionary = this.getMeshMorphDictionary(mesh);
5694
+ const existingIndex = previousDictionary[target.name];
5695
+ if (existingIndex !== void 0 && !replace) {
5696
+ throw new Error(`Morph target "${target.name}" already exists on mesh "${target.meshName}". Pass replace: true to overwrite it.`);
5697
+ }
5698
+ const geometry = forceGeometryReplacement ? sourceGeometry.clone() : sourceGeometry;
5699
+ const dictionary = { ...previousDictionary };
5700
+ const usedIndices = Object.values(dictionary).filter(Number.isInteger);
5701
+ const existingAttributeTargetCount = Math.max(
5702
+ 0,
5703
+ ...Object.values(geometry.morphAttributes).map((attributes) => attributes?.length ?? 0)
5704
+ );
5705
+ const nextIndex = Math.max(existingAttributeTargetCount, usedIndices.length ? Math.max(...usedIndices) + 1 : 0);
5706
+ const index = existingIndex ?? nextIndex;
5707
+ dictionary[target.name] = index;
5708
+ this.setMorphAttributeAtIndex(geometry, "position", target.position, position.itemSize, position.count, index, target.name);
5709
+ const normal = geometry.getAttribute("normal");
5710
+ if (target.normal) {
5711
+ this.setMorphAttributeAtIndex(geometry, "normal", target.normal, normal?.itemSize ?? 3, position.count, index, target.name);
5712
+ } else {
5713
+ this.setZeroMorphAttributeAtIndex(geometry, "normal", normal?.itemSize ?? 3, position.count, index, target.name);
5714
+ }
5715
+ const tangent = geometry.getAttribute("tangent");
5716
+ if (target.tangent) {
5717
+ this.setMorphAttributeAtIndex(geometry, "tangent", target.tangent, tangent?.itemSize ?? 4, position.count, index, target.name);
5718
+ } else {
5719
+ this.setZeroMorphAttributeAtIndex(geometry, "tangent", tangent?.itemSize ?? 4, position.count, index, target.name);
5720
+ }
5721
+ const color = geometry.getAttribute("color");
5722
+ const existingColorMorph = geometry.morphAttributes.color?.find(Boolean);
5723
+ this.setZeroMorphAttributeAtIndex(
5724
+ geometry,
5725
+ "color",
5726
+ color?.itemSize ?? existingColorMorph?.itemSize ?? 3,
5727
+ position.count,
5728
+ index,
5729
+ target.name
5730
+ );
5731
+ geometry.morphTargetsRelative = target.relative !== false;
5732
+ geometry.morphTargetDictionary = dictionary;
5733
+ if (forceGeometryReplacement) {
5734
+ mesh.geometry = geometry;
5735
+ sourceGeometry.dispose();
5736
+ }
5737
+ const influenceLength = Math.max(previousInfluences.length, index + 1);
5738
+ const influences = previousInfluences.slice(0, influenceLength);
5739
+ while (influences.length < influenceLength) {
5740
+ influences.push(0);
5741
+ }
5742
+ if (resetInfluence) {
5743
+ influences[index] = 0;
5744
+ }
5745
+ mesh.morphTargetDictionary = dictionary;
5746
+ mesh.morphTargetInfluences = influences;
5747
+ this.addRuntimeMorphMesh(mesh);
5748
+ if (!this.config.morphToMesh?.face?.length) {
5749
+ this.config.morphToMesh = {
5750
+ ...this.config.morphToMesh,
5751
+ face: [mesh.name]
5752
+ };
5753
+ }
5754
+ return index;
5755
+ }
5756
+ requireNamedMesh(meshName) {
5757
+ const mesh = this.meshByName.get(meshName);
5758
+ if (mesh) return mesh;
5759
+ if (this.model) {
5760
+ let found = null;
5761
+ this.model.traverse((obj) => {
5762
+ if (!found && obj.isMesh && obj.name === meshName) {
5763
+ found = obj;
5764
+ }
5765
+ });
5766
+ if (found) {
5767
+ this.meshByName.set(meshName, found);
5768
+ return found;
5769
+ }
5770
+ }
5771
+ throw new Error(`Mesh "${meshName}" was not found in the current model.`);
5772
+ }
5773
+ getMeshMorphDictionary(mesh) {
5774
+ const meshDictionary = mesh.morphTargetDictionary;
5775
+ const geometryDictionary = mesh.geometry.morphTargetDictionary;
5776
+ const dictionary = meshDictionary || geometryDictionary || {};
5777
+ mesh.morphTargetDictionary = dictionary;
5778
+ mesh.geometry.morphTargetDictionary = dictionary;
5779
+ return dictionary;
5780
+ }
5781
+ setMorphAttributeAtIndex(geometry, semantic, data, itemSize, vertexCount, index, name) {
5782
+ const expectedLength = vertexCount * itemSize;
5783
+ if (data.length !== expectedLength) {
5784
+ throw new Error(
5785
+ `Morph target "${name}" ${semantic} data has ${data.length} values; expected ${expectedLength} (${vertexCount} vertices * itemSize ${itemSize}).`
5786
+ );
5787
+ }
5788
+ const attributes = geometry.morphAttributes[semantic] ? [...geometry.morphAttributes[semantic]] : [];
5789
+ while (attributes.length < index) {
5790
+ const empty = new THREE2.BufferAttribute(new Float32Array(expectedLength), itemSize);
5791
+ empty.name = `morph_${attributes.length}`;
5792
+ attributes.push(empty);
5793
+ }
5794
+ const values = data instanceof Float32Array ? new Float32Array(data) : Float32Array.from(data);
5795
+ const attribute = new THREE2.BufferAttribute(values, itemSize);
5796
+ attribute.name = name;
5797
+ attributes[index] = attribute;
5798
+ geometry.morphAttributes[semantic] = attributes;
5799
+ }
5800
+ setZeroMorphAttributeAtIndex(geometry, semantic, itemSize, vertexCount, index, name) {
5801
+ if (!geometry.morphAttributes[semantic]?.length) return;
5802
+ const expectedLength = vertexCount * itemSize;
5803
+ const attributes = [...geometry.morphAttributes[semantic]];
5804
+ while (attributes.length < index) {
5805
+ const empty2 = new THREE2.BufferAttribute(new Float32Array(expectedLength), itemSize);
5806
+ empty2.name = `morph_${attributes.length}`;
5807
+ attributes.push(empty2);
5808
+ }
5809
+ const empty = new THREE2.BufferAttribute(new Float32Array(expectedLength), itemSize);
5810
+ empty.name = name;
5811
+ attributes[index] = empty;
5812
+ geometry.morphAttributes[semantic] = attributes;
5813
+ }
5814
+ addRuntimeMorphMesh(mesh) {
5815
+ const key = mesh.name || mesh.uuid;
5816
+ const exists = this.meshes.some((candidate) => (candidate.name || candidate.uuid) === key);
5817
+ if (!exists) {
5818
+ this.meshes.push(mesh);
5819
+ }
5820
+ }
5631
5821
  getMorphKeyCacheKey(key, meshNames) {
5632
5822
  return meshNames?.length ? `key:${key}@${meshNames.join(",")}` : `key:${key}`;
5633
5823
  }
@@ -7465,7 +7655,7 @@ function extractMorphs(meshes) {
7465
7655
  for (const mesh of meshes) {
7466
7656
  const geo = mesh.geometry;
7467
7657
  if (!geo.morphAttributes) continue;
7468
- const dict = geo.morphTargetDictionary;
7658
+ const dict = mesh.morphTargetDictionary || geo.morphTargetDictionary;
7469
7659
  if (dict) {
7470
7660
  for (const [name, index] of Object.entries(dict)) {
7471
7661
  morphs.push({