@lovelace_lol/loom3 1.0.15 → 1.0.16

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
@@ -188,6 +188,8 @@ var BakedAnimationController = class {
188
188
  __publicField(this, "animationFinishedCallbacks", /* @__PURE__ */ new Map());
189
189
  __publicField(this, "clipActions", /* @__PURE__ */ new Map());
190
190
  __publicField(this, "clipHandles", /* @__PURE__ */ new Map());
191
+ __publicField(this, "clipSources", /* @__PURE__ */ new Map());
192
+ __publicField(this, "playbackState", /* @__PURE__ */ new Map());
191
193
  __publicField(this, "actionIds", /* @__PURE__ */ new WeakMap());
192
194
  __publicField(this, "actionIdToClip", /* @__PURE__ */ new Map());
193
195
  this.host = host;
@@ -203,6 +205,117 @@ var BakedAnimationController = class {
203
205
  action.__actionId = actionId;
204
206
  return actionId;
205
207
  }
208
+ normalizePlaybackOptions(options, defaults) {
209
+ const clipOptions = options;
210
+ const rawRate = options?.playbackRate ?? options?.speed ?? 1;
211
+ const playbackRate = Number.isFinite(rawRate) ? Math.max(0, Math.abs(rawRate)) : 1;
212
+ const rawWeight = options?.weight ?? options?.intensity ?? clipOptions?.mixerWeight ?? 1;
213
+ const weight = Number.isFinite(rawWeight) ? Math.max(0, rawWeight) : 1;
214
+ const loopMode = options?.loopMode ?? (typeof options?.loop === "boolean" ? options.loop ? "repeat" : "once" : defaults.loop ? "repeat" : "once");
215
+ return {
216
+ source: options?.source ?? defaults.source,
217
+ loop: loopMode !== "once",
218
+ loopMode,
219
+ repeatCount: options?.repeatCount,
220
+ reverse: !!options?.reverse,
221
+ playbackRate,
222
+ weight,
223
+ balance: Number.isFinite(options?.balance) ? options?.balance ?? 0 : 0,
224
+ blendMode: options?.blendMode ?? (clipOptions?.mixerAdditive ? "additive" : "replace"),
225
+ easing: options?.easing ?? "linear"
226
+ };
227
+ }
228
+ applyPlaybackState(action, state) {
229
+ const signedRate = state.reverse ? -state.playbackRate : state.playbackRate;
230
+ action.setEffectiveTimeScale(signedRate);
231
+ action.setEffectiveWeight(state.weight);
232
+ action.blendMode = state.blendMode === "additive" ? THREE.AdditiveAnimationBlendMode : THREE.NormalAnimationBlendMode;
233
+ const reps = state.repeatCount ?? Infinity;
234
+ if (state.loopMode === "pingpong") {
235
+ action.setLoop(THREE.LoopPingPong, reps);
236
+ } else if (state.loopMode === "once") {
237
+ action.setLoop(THREE.LoopOnce, 1);
238
+ } else {
239
+ action.setLoop(THREE.LoopRepeat, reps);
240
+ }
241
+ action.clampWhenFinished = state.loopMode === "once";
242
+ }
243
+ setPlaybackState(clipName, state) {
244
+ this.playbackState.set(clipName, state);
245
+ this.clipSources.set(clipName, state.source);
246
+ }
247
+ getPlaybackStateSnapshot(clipName, defaults) {
248
+ const existing = this.playbackState.get(clipName);
249
+ if (existing) {
250
+ return { ...existing };
251
+ }
252
+ return this.normalizePlaybackOptions(void 0, defaults);
253
+ }
254
+ mergePlaybackOptions(current, options) {
255
+ if (!options) {
256
+ return current;
257
+ }
258
+ const next = { ...current };
259
+ const clipOptions = options;
260
+ const loopMode = options.loopMode ?? (typeof options.loop === "boolean" ? options.loop ? "repeat" : "once" : void 0);
261
+ if (options.source) next.source = options.source;
262
+ if (loopMode) {
263
+ next.loopMode = loopMode;
264
+ next.loop = loopMode !== "once";
265
+ }
266
+ if (options.repeatCount !== void 0) {
267
+ next.repeatCount = Number.isFinite(options.repeatCount) ? Math.max(0, options.repeatCount ?? 0) : void 0;
268
+ }
269
+ if (typeof options.reverse === "boolean") {
270
+ next.reverse = options.reverse;
271
+ }
272
+ const rate = options.playbackRate ?? options.speed;
273
+ if (rate !== void 0) {
274
+ next.playbackRate = Number.isFinite(rate) ? Math.max(0, Math.abs(rate)) : current.playbackRate;
275
+ }
276
+ const weight = options.weight ?? options.intensity ?? clipOptions?.mixerWeight;
277
+ if (weight !== void 0) {
278
+ next.weight = Number.isFinite(weight) ? Math.max(0, weight) : current.weight;
279
+ }
280
+ if (typeof options.balance === "number" && Number.isFinite(options.balance)) {
281
+ next.balance = Math.max(-1, Math.min(1, options.balance));
282
+ }
283
+ if (options.blendMode) {
284
+ next.blendMode = options.blendMode;
285
+ } else if (typeof clipOptions?.mixerAdditive === "boolean") {
286
+ next.blendMode = clipOptions.mixerAdditive ? "additive" : "replace";
287
+ }
288
+ if (options.easing) {
289
+ next.easing = options.easing;
290
+ }
291
+ return next;
292
+ }
293
+ resolveStartTime(duration, state, explicitStartTime) {
294
+ if (typeof explicitStartTime === "number" && Number.isFinite(explicitStartTime)) {
295
+ return Math.max(0, Math.min(duration, explicitStartTime));
296
+ }
297
+ if (state.reverse && state.loopMode === "once") {
298
+ return duration;
299
+ }
300
+ return 0;
301
+ }
302
+ getOrCreateBakedAction(clipName) {
303
+ const existing = this.animationActions.get(clipName);
304
+ if (existing) {
305
+ return existing;
306
+ }
307
+ this.ensureMixer();
308
+ if (!this.animationMixer) {
309
+ return null;
310
+ }
311
+ const clip = this.animationClips.find((entry) => entry.name === clipName);
312
+ if (!clip || (this.clipSources.get(clipName) ?? "baked") !== "baked") {
313
+ return null;
314
+ }
315
+ const action = this.animationMixer.clipAction(clip);
316
+ this.animationActions.set(clipName, action);
317
+ return action;
318
+ }
206
319
  getMeshNamesForAU(auId, config, explicitMeshNames) {
207
320
  if (explicitMeshNames && explicitMeshNames.length > 0) {
208
321
  return explicitMeshNames;
@@ -245,6 +358,8 @@ var BakedAnimationController = class {
245
358
  this.animationFinishedCallbacks.clear();
246
359
  this.clipActions.clear();
247
360
  this.clipHandles.clear();
361
+ this.clipSources.clear();
362
+ this.playbackState.clear();
248
363
  }
249
364
  loadAnimationClips(clips) {
250
365
  if (!this.host.getModel()) {
@@ -254,6 +369,7 @@ var BakedAnimationController = class {
254
369
  this.ensureMixer();
255
370
  this.animationClips = clips;
256
371
  for (const clip of this.animationClips) {
372
+ this.clipSources.set(clip.name, "baked");
257
373
  if (!this.animationActions.has(clip.name) && this.animationMixer) {
258
374
  const action = this.animationMixer.clipAction(clip);
259
375
  this.animationActions.set(clip.name, action);
@@ -264,53 +380,43 @@ var BakedAnimationController = class {
264
380
  return this.animationClips.map((clip) => ({
265
381
  name: clip.name,
266
382
  duration: clip.duration,
267
- trackCount: clip.tracks.length
383
+ trackCount: clip.tracks.length,
384
+ source: this.clipSources.get(clip.name) ?? "baked"
268
385
  }));
269
386
  }
270
387
  playAnimation(clipName, options = {}) {
271
- const action = this.animationActions.get(clipName);
388
+ const action = this.getOrCreateBakedAction(clipName);
272
389
  if (!action) {
273
390
  console.warn(`Loom3: Animation clip "${clipName}" not found`);
274
391
  return null;
275
392
  }
276
- const {
277
- speed = 1,
278
- intensity = 1,
279
- loop = true,
280
- loopMode = "repeat",
281
- repeatCount,
282
- crossfadeDuration = 0,
283
- clampWhenFinished = true,
284
- startTime = 0
285
- } = options;
286
- action.setEffectiveTimeScale(speed);
287
- action.setEffectiveWeight(intensity);
288
- action.clampWhenFinished = clampWhenFinished;
289
- const reps = repeatCount ?? Infinity;
290
- if (!loop || loopMode === "once") {
291
- action.setLoop(THREE.LoopOnce, 1);
292
- } else if (loopMode === "pingpong") {
293
- action.setLoop(THREE.LoopPingPong, reps);
294
- } else {
295
- action.setLoop(THREE.LoopRepeat, reps);
296
- }
297
- if (startTime > 0) {
298
- action.time = startTime;
393
+ if (!this.getActionId(action)) {
394
+ this.setActionId(action, clipName);
299
395
  }
396
+ const playbackState = this.mergePlaybackOptions(
397
+ this.getPlaybackStateSnapshot(clipName, { loop: true, source: "baked" }),
398
+ options
399
+ );
400
+ const crossfadeDuration = options.crossfadeDuration ?? 0;
401
+ const clampWhenFinished = options.clampWhenFinished ?? playbackState.loopMode === "once";
402
+ const startTime = this.resolveStartTime(action.getClip().duration, playbackState, options.startTime);
403
+ this.applyPlaybackState(action, playbackState);
404
+ action.clampWhenFinished = clampWhenFinished;
300
405
  if (crossfadeDuration > 0) {
301
406
  action.reset();
302
407
  action.fadeIn(crossfadeDuration);
303
408
  } else {
304
409
  action.reset();
305
410
  }
411
+ action.time = startTime;
306
412
  action.play();
307
413
  this.animationActions.set(clipName, action);
308
- this.clipActions.set(clipName, action);
414
+ this.setPlaybackState(clipName, playbackState);
309
415
  let resolveFinished;
310
416
  const finishedPromise = new Promise((resolve) => {
311
417
  resolveFinished = resolve;
312
418
  });
313
- if (!loop || loopMode === "once") {
419
+ if (playbackState.loopMode === "once") {
314
420
  this.animationFinishedCallbacks.set(clipName, () => resolveFinished());
315
421
  }
316
422
  return this.createAnimationHandle(clipName, action, finishedPromise);
@@ -318,8 +424,9 @@ var BakedAnimationController = class {
318
424
  stopAnimation(clipName) {
319
425
  const action = this.animationActions.get(clipName);
320
426
  if (action) {
427
+ const isBaked = (this.clipSources.get(clipName) ?? "baked") === "baked";
321
428
  action.stop();
322
- if (this.animationMixer) {
429
+ if (!isBaked && this.animationMixer) {
323
430
  try {
324
431
  const clip = action.getClip();
325
432
  if (clip) {
@@ -329,7 +436,15 @@ var BakedAnimationController = class {
329
436
  } catch {
330
437
  }
331
438
  }
332
- this.animationActions.delete(clipName);
439
+ if (!isBaked) {
440
+ this.animationActions.delete(clipName);
441
+ this.playbackState.delete(clipName);
442
+ } else {
443
+ try {
444
+ action.paused = false;
445
+ } catch {
446
+ }
447
+ }
333
448
  this.animationFinishedCallbacks.delete(clipName);
334
449
  }
335
450
  const clipAction = this.clipActions.get(clipName);
@@ -347,44 +462,18 @@ var BakedAnimationController = class {
347
462
  }
348
463
  this.clipActions.delete(clipName);
349
464
  }
465
+ if (this.clipActions.get(clipName) === action) {
466
+ this.clipActions.delete(clipName);
467
+ }
350
468
  this.clipHandles.delete(clipName);
351
469
  }
352
470
  stopAllAnimations() {
353
- for (const [name, action] of this.animationActions) {
354
- try {
355
- action.stop();
356
- if (this.animationMixer) {
357
- const clip = action.getClip();
358
- if (clip) {
359
- this.animationMixer.uncacheAction(clip);
360
- this.animationMixer.uncacheClip(clip);
361
- }
362
- }
363
- } catch {
364
- }
365
- try {
366
- this.animationFinishedCallbacks.delete(name);
367
- } catch {
368
- }
369
- }
370
- for (const [name, action] of this.clipActions) {
371
- if (!this.animationActions.has(name)) {
372
- try {
373
- action.stop();
374
- if (this.animationMixer) {
375
- const clip = action.getClip();
376
- if (clip) {
377
- this.animationMixer.uncacheAction(clip);
378
- this.animationMixer.uncacheClip(clip);
379
- }
380
- }
381
- } catch {
382
- }
383
- }
471
+ for (const clipName of /* @__PURE__ */ new Set([
472
+ ...this.animationActions.keys(),
473
+ ...this.clipActions.keys()
474
+ ])) {
475
+ this.stopAnimation(clipName);
384
476
  }
385
- this.animationActions.clear();
386
- this.clipActions.clear();
387
- this.clipHandles.clear();
388
477
  }
389
478
  pauseAnimation(clipName) {
390
479
  const action = this.animationActions.get(clipName);
@@ -413,15 +502,82 @@ var BakedAnimationController = class {
413
502
  }
414
503
  }
415
504
  setAnimationSpeed(clipName, speed) {
416
- const action = this.animationActions.get(clipName);
505
+ const action = this.getOrCreateBakedAction(clipName);
417
506
  if (action) {
418
- action.setEffectiveTimeScale(speed);
507
+ const next = this.getPlaybackStateSnapshot(clipName, {
508
+ loop: true,
509
+ source: this.clipSources.get(clipName) ?? "baked"
510
+ });
511
+ next.playbackRate = Number.isFinite(speed) ? Math.max(0, Math.abs(speed)) : 1;
512
+ this.applyPlaybackState(action, next);
513
+ this.setPlaybackState(clipName, next);
419
514
  }
420
515
  }
421
516
  setAnimationIntensity(clipName, intensity) {
422
- const action = this.animationActions.get(clipName);
517
+ const action = this.getOrCreateBakedAction(clipName);
423
518
  if (action) {
424
- action.setEffectiveWeight(Math.max(0, Math.min(1, intensity)));
519
+ const next = this.getPlaybackStateSnapshot(clipName, {
520
+ loop: true,
521
+ source: this.clipSources.get(clipName) ?? "baked"
522
+ });
523
+ next.weight = Number.isFinite(intensity) ? Math.max(0, intensity) : 1;
524
+ action.setEffectiveWeight(next.weight);
525
+ this.setPlaybackState(clipName, next);
526
+ }
527
+ }
528
+ setAnimationLoopMode(clipName, loopMode) {
529
+ const action = this.getOrCreateBakedAction(clipName);
530
+ if (!action) return;
531
+ const next = this.getPlaybackStateSnapshot(clipName, {
532
+ loop: true,
533
+ source: this.clipSources.get(clipName) ?? "baked"
534
+ });
535
+ next.loopMode = loopMode;
536
+ next.loop = loopMode !== "once";
537
+ this.applyPlaybackState(action, next);
538
+ this.setPlaybackState(clipName, next);
539
+ }
540
+ setAnimationRepeatCount(clipName, repeatCount) {
541
+ const action = this.getOrCreateBakedAction(clipName);
542
+ if (!action) return;
543
+ const next = this.getPlaybackStateSnapshot(clipName, {
544
+ loop: true,
545
+ source: this.clipSources.get(clipName) ?? "baked"
546
+ });
547
+ next.repeatCount = typeof repeatCount === "number" && Number.isFinite(repeatCount) ? Math.max(0, repeatCount) : void 0;
548
+ this.applyPlaybackState(action, next);
549
+ this.setPlaybackState(clipName, next);
550
+ }
551
+ setAnimationReverse(clipName, reverse) {
552
+ const action = this.getOrCreateBakedAction(clipName);
553
+ if (!action) return;
554
+ const next = this.getPlaybackStateSnapshot(clipName, {
555
+ loop: true,
556
+ source: this.clipSources.get(clipName) ?? "baked"
557
+ });
558
+ next.reverse = !!reverse;
559
+ this.applyPlaybackState(action, next);
560
+ this.setPlaybackState(clipName, next);
561
+ }
562
+ setAnimationBlendMode(clipName, blendMode) {
563
+ const action = this.getOrCreateBakedAction(clipName);
564
+ if (!action) return;
565
+ const next = this.getPlaybackStateSnapshot(clipName, {
566
+ loop: true,
567
+ source: this.clipSources.get(clipName) ?? "baked"
568
+ });
569
+ next.blendMode = blendMode;
570
+ this.applyPlaybackState(action, next);
571
+ this.setPlaybackState(clipName, next);
572
+ }
573
+ seekAnimation(clipName, time) {
574
+ const action = this.getOrCreateBakedAction(clipName) ?? this.animationActions.get(clipName);
575
+ if (!action) return;
576
+ const duration = action.getClip().duration;
577
+ action.time = Math.max(0, Math.min(duration, Number.isFinite(time) ? time : 0));
578
+ try {
579
+ this.animationMixer?.update(0);
580
+ } catch {
425
581
  }
426
582
  }
427
583
  setAnimationTimeScale(timeScale) {
@@ -433,15 +589,29 @@ var BakedAnimationController = class {
433
589
  const action = this.animationActions.get(clipName);
434
590
  if (!action) return null;
435
591
  const clip = action.getClip();
592
+ const state = this.playbackState.get(clipName);
593
+ const loopMode = state?.loopMode ?? (action.loop === THREE.LoopPingPong ? "pingpong" : action.loop === THREE.LoopOnce ? "once" : "repeat");
594
+ const playbackRate = state?.playbackRate ?? Math.abs(action.getEffectiveTimeScale());
595
+ const reverse = state?.reverse ?? action.getEffectiveTimeScale() < 0;
436
596
  return {
437
597
  name: clip.name,
598
+ actionId: this.getActionId(action),
599
+ source: state?.source ?? this.clipSources.get(clip.name) ?? "baked",
438
600
  isPlaying: action.isRunning() && !action.paused,
439
601
  isPaused: action.paused,
440
602
  time: action.time,
441
603
  duration: clip.duration,
442
- speed: action.getEffectiveTimeScale(),
443
- weight: action.getEffectiveWeight(),
444
- isLooping: action.loop !== THREE.LoopOnce
604
+ speed: playbackRate,
605
+ playbackRate,
606
+ reverse,
607
+ weight: state?.weight ?? action.getEffectiveWeight(),
608
+ balance: state?.balance ?? 0,
609
+ blendMode: state?.blendMode ?? "replace",
610
+ easing: state?.easing ?? "linear",
611
+ loop: loopMode !== "once",
612
+ loopMode,
613
+ repeatCount: state?.repeatCount,
614
+ isLooping: loopMode !== "once"
445
615
  };
446
616
  }
447
617
  getPlayingAnimations() {
@@ -698,14 +868,14 @@ var BakedAnimationController = class {
698
868
  console.warn("[Loom3] playClip: No model loaded, cannot create mixer");
699
869
  return null;
700
870
  }
701
- const {
702
- loop = false,
703
- loopMode,
704
- repeatCount,
705
- reverse = false,
706
- playbackRate = 1,
707
- mixerWeight
708
- } = options || {};
871
+ const playbackState = this.mergePlaybackOptions(
872
+ this.getPlaybackStateSnapshot(clip.name, {
873
+ loop: false,
874
+ source: options?.source ?? "clip"
875
+ }),
876
+ options
877
+ );
878
+ const startTime = this.resolveStartTime(clip.duration, playbackState, options?.startTime);
709
879
  let action = this.clipActions.get(clip.name);
710
880
  let actionId = this.getActionId(action);
711
881
  if (action && !actionId) {
@@ -719,20 +889,7 @@ var BakedAnimationController = class {
719
889
  if (!existingClip) {
720
890
  this.animationClips.push(clip);
721
891
  }
722
- const timeScale = reverse ? -playbackRate : playbackRate;
723
- action.setEffectiveTimeScale(timeScale);
724
- const weight = typeof mixerWeight === "number" ? mixerWeight : 1;
725
- action.setEffectiveWeight(weight);
726
- const mode = loopMode || (loop ? "repeat" : "once");
727
- action.clampWhenFinished = mode === "once";
728
- const reps = repeatCount ?? Infinity;
729
- if (mode === "pingpong") {
730
- action.setLoop(THREE.LoopPingPong, reps);
731
- } else if (mode === "once") {
732
- action.setLoop(THREE.LoopOnce, 1);
733
- } else {
734
- action.setLoop(THREE.LoopRepeat, reps);
735
- }
892
+ this.applyPlaybackState(action, playbackState);
736
893
  let resolveFinished;
737
894
  const finishedPromise = new Promise((resolve) => {
738
895
  resolveFinished = resolve;
@@ -753,15 +910,24 @@ var BakedAnimationController = class {
753
910
  });
754
911
  finishedPromise.catch(() => cleanup());
755
912
  action.reset();
913
+ action.time = startTime;
756
914
  action.play();
757
915
  this.clipActions.set(clip.name, action);
758
916
  this.animationActions.set(clip.name, action);
759
- console.log(`[Loom3] playClip: Playing "${clip.name}" (rate: ${playbackRate}, loop: ${loop}, actionId: ${actionId})`);
917
+ this.setPlaybackState(clip.name, playbackState);
918
+ console.log(`[Loom3] playClip: Playing "${clip.name}" (rate: ${playbackState.playbackRate}, loop: ${playbackState.loop}, actionId: ${actionId})`);
760
919
  const handle = {
761
920
  clipName: clip.name,
762
921
  actionId,
763
922
  play: () => {
764
923
  action.reset();
924
+ action.time = this.resolveStartTime(
925
+ clip.duration,
926
+ this.getPlaybackStateSnapshot(clip.name, {
927
+ loop: false,
928
+ source: this.clipSources.get(clip.name) ?? playbackState.source
929
+ })
930
+ );
765
931
  action.play();
766
932
  },
767
933
  stop: () => {
@@ -779,6 +945,7 @@ var BakedAnimationController = class {
779
945
  this.clipActions.delete(clip.name);
780
946
  this.animationActions.delete(clip.name);
781
947
  this.animationFinishedCallbacks.delete(clip.name);
948
+ this.playbackState.delete(clip.name);
782
949
  resolveFinished();
783
950
  cleanup();
784
951
  },
@@ -789,21 +956,24 @@ var BakedAnimationController = class {
789
956
  action.paused = false;
790
957
  },
791
958
  setWeight: (w) => {
792
- action.setEffectiveWeight(typeof w === "number" && Number.isFinite(w) ? w : 1);
959
+ const next = this.playbackState.get(clip.name) ?? playbackState;
960
+ next.weight = typeof w === "number" && Number.isFinite(w) ? Math.max(0, w) : 1;
961
+ action.setEffectiveWeight(next.weight);
962
+ this.setPlaybackState(clip.name, next);
793
963
  },
794
964
  setPlaybackRate: (r) => {
795
- const rate = Number.isFinite(r) ? r : 1;
796
- action.setEffectiveTimeScale(rate);
965
+ const next = this.playbackState.get(clip.name) ?? playbackState;
966
+ next.playbackRate = Number.isFinite(r) ? Math.max(0, Math.abs(r)) : 1;
967
+ this.applyPlaybackState(action, next);
968
+ this.setPlaybackState(clip.name, next);
797
969
  },
798
- setLoop: (mode2, repeatCount2) => {
799
- const reps2 = repeatCount2 ?? Infinity;
800
- if (mode2 === "pingpong") {
801
- action.setLoop(THREE.LoopPingPong, reps2);
802
- } else if (mode2 === "once") {
803
- action.setLoop(THREE.LoopOnce, 1);
804
- } else {
805
- action.setLoop(THREE.LoopRepeat, reps2);
806
- }
970
+ setLoop: (mode, repeatCount) => {
971
+ const next = this.playbackState.get(clip.name) ?? playbackState;
972
+ next.loopMode = mode;
973
+ next.loop = mode !== "once";
974
+ next.repeatCount = repeatCount;
975
+ this.applyPlaybackState(action, next);
976
+ this.setPlaybackState(clip.name, next);
807
977
  },
808
978
  setTime: (t) => {
809
979
  const clamped = Math.max(0, Math.min(clip.duration, t));
@@ -825,14 +995,14 @@ var BakedAnimationController = class {
825
995
  if (!clip) {
826
996
  return null;
827
997
  }
828
- return this.playClip(clip, options);
998
+ return this.playClip(clip, { ...options, source: options?.source ?? "snippet" });
829
999
  }
830
1000
  buildClip(clipName, curves, options) {
831
1001
  const clip = this.snippetToClip(clipName, curves, options);
832
1002
  if (!clip) {
833
1003
  return null;
834
1004
  }
835
- return this.playClip(clip, options);
1005
+ return this.playClip(clip, { ...options, source: options?.source ?? "clip" });
836
1006
  }
837
1007
  cleanupSnippet(name) {
838
1008
  if (!this.animationMixer || !this.host.getModel()) return;
@@ -851,6 +1021,7 @@ var BakedAnimationController = class {
851
1021
  this.animationActions.delete(clipName);
852
1022
  this.clipHandles.delete(clipName);
853
1023
  this.animationFinishedCallbacks.delete(clipName);
1024
+ this.playbackState.delete(clipName);
854
1025
  }
855
1026
  }
856
1027
  }
@@ -874,31 +1045,34 @@ var BakedAnimationController = class {
874
1045
  console.log("[Loom3] updateClipParams start", debugSnapshot());
875
1046
  const apply = (action) => {
876
1047
  if (!action) return;
1048
+ const clipName = action.getClip().name;
1049
+ const next = this.playbackState.get(clipName) ?? this.normalizePlaybackOptions(void 0, { loop: false, source: this.clipSources.get(clipName) ?? "clip" });
877
1050
  try {
878
1051
  action.paused = false;
879
1052
  } catch {
880
1053
  }
881
1054
  if (typeof params.weight === "number" && Number.isFinite(params.weight)) {
882
1055
  action.setEffectiveWeight(params.weight);
1056
+ next.weight = Math.max(0, params.weight);
883
1057
  updated = true;
884
1058
  }
885
1059
  if (typeof params.rate === "number" && Number.isFinite(params.rate)) {
886
- const signedRate = params.reverse ? -params.rate : params.rate;
1060
+ next.playbackRate = Math.max(0, Math.abs(params.rate));
1061
+ if (typeof params.reverse === "boolean") {
1062
+ next.reverse = params.reverse;
1063
+ }
1064
+ const signedRate = next.reverse ? -next.playbackRate : next.playbackRate;
887
1065
  action.setEffectiveTimeScale(signedRate);
888
1066
  updated = true;
889
1067
  }
890
1068
  if (typeof params.loop === "boolean" || params.loopMode || params.repeatCount !== void 0) {
891
- const mode = params.loopMode || (params.loop ? "repeat" : "once");
892
- const reps = params.repeatCount ?? Infinity;
893
- if (mode === "pingpong") {
894
- action.setLoop(THREE.LoopPingPong, reps);
895
- } else if (mode === "once") {
896
- action.setLoop(THREE.LoopOnce, 1);
897
- } else {
898
- action.setLoop(THREE.LoopRepeat, reps);
899
- }
1069
+ next.loopMode = params.loopMode || (params.loop ? "repeat" : "once");
1070
+ next.loop = next.loopMode !== "once";
1071
+ next.repeatCount = params.repeatCount;
1072
+ this.applyPlaybackState(action, next);
900
1073
  updated = true;
901
1074
  }
1075
+ this.setPlaybackState(clipName, next);
902
1076
  };
903
1077
  for (const [clipName, action] of this.clipActions.entries()) {
904
1078
  if (matches(clipName, action)) {
@@ -988,14 +1162,13 @@ var BakedAnimationController = class {
988
1162
  }
989
1163
  createAnimationHandle(clipName, action, finishedPromise) {
990
1164
  return {
1165
+ actionId: this.getActionId(action),
991
1166
  stop: () => this.stopAnimation(clipName),
992
1167
  pause: () => this.pauseAnimation(clipName),
993
1168
  resume: () => this.resumeAnimation(clipName),
994
1169
  setSpeed: (speed) => this.setAnimationSpeed(clipName, speed),
995
1170
  setWeight: (weight) => this.setAnimationIntensity(clipName, weight),
996
- seekTo: (time) => {
997
- action.time = Math.max(0, Math.min(time, action.getClip().duration));
998
- },
1171
+ seekTo: (time) => this.seekAnimation(clipName, time),
999
1172
  getState: () => this.getAnimationState(clipName),
1000
1173
  crossfadeTo: (targetClip, dur) => this.crossfadeTo(targetClip, dur),
1001
1174
  finished: finishedPromise
@@ -3619,12 +3792,6 @@ var _Loom3 = class _Loom3 {
3619
3792
  this.animation = animation || new AnimationThree();
3620
3793
  this.compositeRotations = this.config.compositeRotations || COMPOSITE_ROTATIONS;
3621
3794
  this.auToCompositeMap = buildAUToCompositeMap(this.compositeRotations);
3622
- this.hairPhysics = new HairPhysicsController({
3623
- getMeshByName: (name) => this.meshByName.get(name),
3624
- getSelectedHairMeshNames: () => this.config.morphToMesh?.hair || [],
3625
- buildClip: (clipName, curves, options) => this.buildClip(clipName, curves, options),
3626
- cleanupSnippet: (name) => this.cleanupSnippet(name)
3627
- });
3628
3795
  this.bakedAnimations = new BakedAnimationController({
3629
3796
  getModel: () => this.model,
3630
3797
  getMeshes: () => this.meshes,
@@ -3638,6 +3805,13 @@ var _Loom3 = class _Loom3 {
3638
3805
  getAUMixWeight: (auId) => this.getAUMixWeight(auId),
3639
3806
  isMixedAU: (auId) => this.isMixedAU(auId)
3640
3807
  });
3808
+ this.hairPhysics = new HairPhysicsController({
3809
+ getMeshByName: (name) => this.meshByName.get(name),
3810
+ getSelectedHairMeshNames: () => this.config.morphToMesh?.hair || [],
3811
+ // Hair physics needs clip construction, but mixer ownership still lives in BakedAnimationController.
3812
+ buildClip: (clipName, curves, options) => this.bakedAnimations.buildClip(clipName, curves, options),
3813
+ cleanupSnippet: (name) => this.bakedAnimations.cleanupSnippet(name)
3814
+ });
3641
3815
  this.applyHairPhysicsProfileConfig();
3642
3816
  }
3643
3817
  // ============================================================================
@@ -4870,8 +5044,8 @@ var _Loom3 = class _Loom3 {
4870
5044
  }
4871
5045
  });
4872
5046
  }
4873
- // ===========wha=================================================================
4874
- // BAKED ANIMATION CONTROL (Three.js AnimationMixer)
5047
+ // ============================================================================
5048
+ // MIXER / CLIP CONTROL
4875
5049
  // ============================================================================
4876
5050
  loadAnimationClips(clips) {
4877
5051
  this.bakedAnimations.loadAnimationClips(clips);
@@ -4906,6 +5080,21 @@ var _Loom3 = class _Loom3 {
4906
5080
  setAnimationIntensity(clipName, intensity) {
4907
5081
  this.bakedAnimations.setAnimationIntensity(clipName, intensity);
4908
5082
  }
5083
+ setAnimationLoopMode(clipName, loopMode) {
5084
+ this.bakedAnimations.setAnimationLoopMode(clipName, loopMode);
5085
+ }
5086
+ setAnimationRepeatCount(clipName, repeatCount) {
5087
+ this.bakedAnimations.setAnimationRepeatCount(clipName, repeatCount);
5088
+ }
5089
+ setAnimationReverse(clipName, reverse) {
5090
+ this.bakedAnimations.setAnimationReverse(clipName, reverse);
5091
+ }
5092
+ setAnimationBlendMode(clipName, blendMode) {
5093
+ this.bakedAnimations.setAnimationBlendMode(clipName, blendMode);
5094
+ }
5095
+ seekAnimation(clipName, time) {
5096
+ this.bakedAnimations.seekAnimation(clipName, time);
5097
+ }
4909
5098
  setAnimationTimeScale(timeScale) {
4910
5099
  this.bakedAnimations.setAnimationTimeScale(timeScale);
4911
5100
  }