Needle Engine

Changes between version 3.37.6-alpha and 3.37.6-beta
Files changed (8) hide show
  1. plugins/common/buildinfo.js +1 -8
  2. src/engine-components/export/usdz/extensions/Animation.ts +119 -32
  3. src/engine-components/export/usdz/utils/animationutils.ts +18 -0
  4. src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +21 -12
  5. src/engine/engine_lifecycle_functions_internal.ts +8 -4
  6. src/engine/extensions/NEEDLE_animator_controller_model.ts +5 -0
  7. src/engine-components/export/usdz/ThreeUSDZExporter.ts +5 -4
  8. src/engine-components/export/usdz/USDZExporter.ts +9 -2
plugins/common/buildinfo.js CHANGED
@@ -52,14 +52,7 @@
52
52
  hash: filehash.digest('hex'),
53
53
  size: stats.size
54
54
  });
55
- try {
56
- const fullpath = `${ directory } / ${ entry.name }`;
57
- const stats = fs.statSync(fullpath);
58
- info.totalsize += stats.size;
59
- }
60
- catch {
61
- //ignore
62
- }
55
+ info.totalsize += stats.size;
63
56
  }
64
57
  }
65
58
  }
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -24,13 +24,13 @@
24
24
  }
25
25
  return this._start;
26
26
  }
27
- get duration(): number { return this.clip.duration; }
27
+ get duration(): number { return this.clip?.duration ?? TransformData.restPoseClipDuration; }
28
28
 
29
29
  private ext: AnimationExtension;
30
30
  private root: Object3D;
31
- private clip: AnimationClip;
31
+ private clip: AnimationClip | null;
32
32
 
33
- constructor(ext: AnimationExtension, root: Object3D, clip: AnimationClip) {
33
+ constructor(ext: AnimationExtension, root: Object3D, clip: AnimationClip | null) {
34
34
  this.ext = ext;
35
35
  this.root = root;
36
36
  this.clip = clip;
@@ -38,7 +38,7 @@
38
38
  }
39
39
 
40
40
  export class TransformData {
41
- clip?: AnimationClip;
41
+ clip: AnimationClip | null;
42
42
  pos?: KeyframeTrack;
43
43
  rot?: KeyframeTrack;
44
44
  scale?: KeyframeTrack;
@@ -55,7 +55,7 @@
55
55
  static animationDurationPadding = 1 / 60;
56
56
  static restPoseClipDuration = 1 / 60;
57
57
 
58
- constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | undefined) {
58
+ constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | null) {
59
59
  this.root = root;
60
60
  this.target = target;
61
61
  this.clip = clip;
@@ -177,10 +177,18 @@
177
177
 
178
178
  get extensionName(): string { return "animation" }
179
179
 
180
+ /** For each animated object, contains time/pos/rot/scale samples in the format that USD needs,
181
+ * ready to be written to the .usda file.
182
+ */
180
183
  private dict: AnimationDict = new Map();
181
- private rootTargetMap = new Map<Object3D, Object3D[]>();
184
+ /** Map of all roots (Animation/Animator or scene) and all targets that they animate.
185
+ * We need that info so that we can ensure that each target has the same number of TransformData entries
186
+ * so that switching between animations doesn't result in data "leaking" to another clip.
187
+ */
188
+ private rootTargetMap = new Map<Object3D, Array<Object3D>>();
182
189
  private rootAndClipToRegisteredAnimationMap = new Map<string, RegisteredAnimationInfo>();
183
- private rootToRegisteredClipCount = new Map<Object3D, number>();
190
+ /** Clips registered for each root */
191
+ private rootToRegisteredClip = new Map<Object3D, Array<AnimationClip>>();
184
192
 
185
193
  private serializers: SerializeAnimation[] = [];
186
194
 
@@ -214,14 +222,16 @@
214
222
 
215
223
  getClipCount(root: Object3D): number {
216
224
  // don't count the rest pose
217
- let currentCount = this.rootToRegisteredClipCount.get(root);
225
+ let currentCount = this.rootToRegisteredClip.get(root)?.length ?? 0;
218
226
  if (this.injectRestPoses) currentCount = currentCount ? currentCount - 1 : 0;
219
227
  return currentCount ?? 0;
220
228
  }
221
229
 
222
230
  // TODO why do we have this here and on TransformData? Can RegisteredAnimationInfo not cache this value?
223
231
  // TODO we probably want to assert here that this is the same value on all nodes
224
- getStartTime01(root: Object3D, clip: AnimationClip) {
232
+ getStartTime01(root: Object3D, clip: AnimationClip | null) {
233
+ // This is a rest pose clip, it always starts at 0
234
+ if (!clip) return 0;
225
235
 
226
236
  const targets = this.rootTargetMap.get(root);
227
237
  if (!targets) return 0;
@@ -241,14 +251,38 @@
241
251
  }
242
252
 
243
253
  // The same clip could be registered for different roots. All of them need written animation data then.
244
- // The same root could have multiple clips registered to it.
245
- registerAnimation(root: Object3D, clip: AnimationClip): RegisteredAnimationInfo | null {
246
- if (!clip || !root) return null;
254
+ // The same root could have multiple clips registered to it. If it does, the clips need to write
255
+ // independent time data, so that playing back an animation on that root doesn't result in data "leaking"/"overlapping".
256
+ // The structure we need is:
257
+ // - MyRoot
258
+ // Animator
259
+ // - Clip1: CubeScale (only animates MyCube), duration: 3s
260
+ // - Clip2: SphereRotation (only animates MySphere), duration: 2s
261
+ // - MyCube
262
+ // - MySphere
263
+ // Results in:
264
+ // - MyRoot
265
+ // - MyCube
266
+ // - # rest clip (0..0.1)
267
+ // - # CubeScale (0.2..3.2)
268
+ // - # rest clip for SphereRotation (3.3..5.3)
269
+ // - MySphere
270
+ // - # rest clip (0..0.1)
271
+ // - # rest clip for CubeScale (0.2..3.2)
272
+ // - # SphereRotation (3.3..5.3)
273
+
274
+ /** Register an AnimationClip for a specific root object.
275
+ * @param root The root object that the animation clip is targeting.
276
+ * @param clip The animation clip to register. If null, a rest pose is registered.
277
+ * @returns The registered animation info, which contains the start time and duration of the clip.
278
+ */
279
+ registerAnimation(root: Object3D, clip: AnimationClip | null): RegisteredAnimationInfo | null {
280
+ if(!root) return null;
247
281
  if (!this.rootTargetMap.has(root)) this.rootTargetMap.set(root, []);
248
282
 
249
283
  // if we registered that exact pair already, just return the info
250
284
  // no proper tuples in JavaScript, but we can use the uuids for a unique key here
251
- const hash = root.uuid + clip.uuid;
285
+ const hash = root.uuid + (clip?.uuid ?? "-rest");
252
286
  if (this.rootAndClipToRegisteredAnimationMap.has(hash)) {
253
287
  return this.rootAndClipToRegisteredAnimationMap.get(hash)!;
254
288
  }
@@ -261,11 +295,11 @@
261
295
  // in animation 0 and some data overrides each other incorrectly.
262
296
  // When we don't inject a rest clip, we start at 0.
263
297
  const startIndex = this.injectRestPoses ? 1 : 0;
264
- const currentCount = this.rootToRegisteredClipCount.get(root) ?? startIndex;
298
+ const currentCount = (this.rootToRegisteredClip.get(root)?.length ?? 0) + startIndex;
265
299
 
266
300
  const targets = this.rootTargetMap.get(root);
267
301
  const unregisteredNodesForThisClip = new Set(targets);
268
- if (clip.tracks) {
302
+ if (clip && clip.tracks) {
269
303
  for (const track of clip.tracks) {
270
304
  const parsedPath = PropertyBinding.parseTrackName(track.name);
271
305
  const animationTarget = PropertyBinding.findNode(root, parsedPath.nodeName);
@@ -298,19 +332,13 @@
298
332
  // TODO most likely doesn't work for overlapping clips (clips where a root is a child of another root)
299
333
  // TODO in that case we may need to pad globally, not per root
300
334
 
301
- // check if we have a rest pose already
335
+ // Inject a rest pose if we don't have it already
302
336
  if (this.injectRestPoses && !transformDataForTarget[0]) {
303
- const model = new TransformData(null, animationTarget, undefined);
304
- transformDataForTarget[0] = model;
337
+ transformDataForTarget[0] = new TransformData(null, animationTarget, null);
305
338
  }
306
339
  // These all need to be at the same index, otherwise our padding went wrong
307
340
  let model = transformDataForTarget[currentCount];
308
341
 
309
- // validation (for debugging)
310
- // let modelFoundByClip = transformDataForTarget.find(x => x.clip === clip);
311
- // let modelFoundByClipIndex = modelFoundByClip ? transformDataForTarget.indexOf(modelFoundByClip) : -1;
312
- // console.assert(model === modelFoundByClip, "We should find the same model by index and by clip; " + currentCount + " !== " + modelFoundByClipIndex);
313
-
314
342
  if (!model) {
315
343
  model = new TransformData(root, animationTarget, clip);
316
344
  transformDataForTarget[currentCount] = model;
@@ -322,14 +350,17 @@
322
350
  }
323
351
  }
324
352
 
353
+ if (debug) console.log("Unregistered nodes for this clip", unregisteredNodesForThisClip, "clip", clip, "at slot", currentCount, "for root", root, "targets", targets)
354
+
325
355
  // add padding for nodes that are not animated by this clip
326
356
  for (const target of unregisteredNodesForThisClip) {
327
357
  const transformDataForTarget = this.dict.get(target);
328
358
  if (!transformDataForTarget) continue;
329
359
 
330
- // inject rest pose
360
+ // Inject rest pose if these nodes don't have it yet for some reason – this is likely a bug
331
361
  if (this.injectRestPoses && !transformDataForTarget[0]) {
332
- const model = new TransformData(null, target, undefined);
362
+ console.warn("Adding rest pose for ", target, clip, "at slot", currentCount, "This is likely a bug, should have been added earlier.");
363
+ const model = new TransformData(null, target, null);
333
364
  transformDataForTarget[0] = model;
334
365
  }
335
366
 
@@ -345,7 +376,13 @@
345
376
  // This doesnt work if we have clips animating multiple objects
346
377
  const info = new RegisteredAnimationInfo(this, root, clip);
347
378
  this.rootAndClipToRegisteredAnimationMap.set(hash, info);
348
- this.rootToRegisteredClipCount.set(root, currentCount + 1);
379
+
380
+ if (debug) console.log({root, clip, info});
381
+ if (clip) {
382
+ const registered = this.rootToRegisteredClip.get(root);
383
+ if (!registered) this.rootToRegisteredClip.set(root, [clip]);
384
+ else registered.push(clip);
385
+ }
349
386
  return info;
350
387
  }
351
388
 
@@ -354,6 +391,49 @@
354
391
  }
355
392
 
356
393
  onAfterBuildDocument(_context: any) {
394
+
395
+ // Validation: go through all roots, check if their data and lengths are consistent.
396
+ // There are some cases that we can patch up here, especially if non-overlapping animations have resulted in "holes"
397
+ // in TransformData where only now we know what the matching animation clip actually is.
398
+ if (debug) console.log("Animation data", { dict: this.dict, rootTargetMap: this.rootTargetMap, rootToRegisteredClip: this.rootToRegisteredClip});
399
+
400
+ for (const root of this.rootTargetMap.keys()) {
401
+ const targets = this.rootTargetMap.get(root);
402
+
403
+ if (!targets) continue;
404
+
405
+ // The TransformData[] arrays here should have the same length, and the same durations
406
+ let arrayLength: number | undefined = undefined;
407
+ const durations: Array<number | undefined> = [];
408
+ for (const target of targets) {
409
+ const datas = this.dict.get(target);
410
+ if (!datas) {
411
+ console.error("No data found for target on USDZ export – please report a bug!", target);
412
+ continue;
413
+ }
414
+
415
+ if (arrayLength === undefined) arrayLength = datas?.length;
416
+ if (arrayLength !== datas?.length) console.error("Different array lengths for targets – please report a bug!", datas);
417
+
418
+ for (let i = 0; i < datas.length; i++) {
419
+ let data = datas[i];
420
+ if (!data) {
421
+ // If we don't have TransformData for this object yet, we're emitting a rest pose with the duration
422
+ // of the matching clip that was registered for this root.
423
+ const index = i - (this.injectRestPoses ? 1 : 0);
424
+ datas[i] = new TransformData(null, target, this.rootToRegisteredClip.get(root)![index]);
425
+ data = datas[i];
426
+ }
427
+
428
+ if (!durations[i]) durations[i] = data.getDuration();
429
+ else if (durations[i] !== data.getDuration()) {
430
+ console.error("Different durations for targets – please report a bug!", datas);
431
+ continue;
432
+ }
433
+ }
434
+ }
435
+ }
436
+
357
437
  for (const ser of this.serializers) {
358
438
  const parent = ser.model?.parent;
359
439
  const isEmptyParent = parent?.isDynamic === true;
@@ -567,7 +647,7 @@
567
647
  // if we don't have animation data, create an empty one – 
568
648
  // it will automatically map to the rest pose, albeit inefficiently
569
649
  else
570
- emptyTransformData = new TransformData(null, bone, undefined);
650
+ emptyTransformData = new TransformData(null, bone, null);
571
651
 
572
652
  for (let i = 0; i < count; i++) {
573
653
  const transformData = boneEntryInData ? boneEntryInData[i] : emptyTransformData!;
@@ -751,7 +831,7 @@
751
831
  if (arr0) {
752
832
  for (let i = 0; i < arr0.length; i++) {
753
833
  if (arr0[i] !== undefined) continue;
754
- arr0[i] = new TransformData(null, this.object, undefined);
834
+ arr0[i] = new TransformData(null, this.object, null);
755
835
  }
756
836
  }
757
837
 
@@ -802,6 +882,11 @@
802
882
  currentStartTime += arr[i].getDuration() + TransformData.animationDurationPadding;
803
883
  }
804
884
 
885
+ const formatter = Intl.NumberFormat("en", {
886
+ maximumFractionDigits: 2,
887
+ minimumFractionDigits: 0,
888
+ });
889
+
805
890
  for (let i = 0; i < arr.length; i++) {
806
891
  const transformData = arr[i];
807
892
  if (!transformData) continue;
@@ -814,17 +899,19 @@
814
899
  continue;
815
900
  }
816
901
 
817
- if (debug) {
902
+ // if (debug) // writing out the clip name and duration is useful even when not debugging
903
+ {
818
904
  const clipName = transformData.clip?.name ?? "rest";
819
905
  const duration = transformData.getDuration();
820
- console.log("Write .timeSamples:", clipName, startTime, duration, arr);
821
- writer.appendLine("# " + clipName + ": start=" + (startTime * transformData.frameRate).toFixed(3) + ", length=" + (duration * transformData.frameRate).toFixed(3) + ", frames=" + transformData.getFrames());
906
+ if (debug) console.log("Write .timeSamples:", clipName, startTime, duration, arr);
907
+ writer.appendLine("# " + clipName + ": start=" + formatter.format(startTime * transformData.frameRate) + ", length=" + formatter.format(duration * transformData.frameRate) + ", frames=" + transformData.getFrames());
822
908
  }
823
909
 
824
910
  for (const { time, translation, rotation, scale } of transformData.getValues(timesArray)) {
825
911
  composedTransform.compose(translation, rotation, scale);
826
912
 
827
- const line = `${(startTime + time) * transformData.frameRate}: ${buildMatrix(composedTransform)},`;
913
+ const timeStr = formatter.format((startTime + time) * transformData.frameRate);
914
+ const line = `${timeStr}: ${buildMatrix(composedTransform)},`;
828
915
  writer.appendLine(line);
829
916
  }
830
917
  }
src/engine-components/export/usdz/utils/animationutils.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  const animationClips: { root: Object3D, clips: AnimationClip[] }[] = [];
27
27
  const animators = GameObject.getComponentsInChildren(root, Animator);
28
28
  const animationComponents = GameObject.getComponentsInChildren(root, Animation);
29
+ const animatorsWithPlayAtStart = new Array<Animator | Animation>();
29
30
 
30
31
  const constructedObjects = new Array<Object3D>();
31
32
 
@@ -39,6 +40,13 @@
39
40
  if (!activeState) continue;
40
41
  if (!activeState.motion || !activeState.motion.clip) continue;
41
42
  if (activeState.motion.clip.tracks?.length < 1) continue;
43
+ // We could skip if the current state has already ended playing
44
+ // but there's an edge case where we'd do this exactly in a transition,
45
+ // and also it's a bit weird when someone has watched the animation in the browser already
46
+ // and then it doesn't play in AR, so this is disabled for now.
47
+ // if (animator.getCurrentStateInfo()?.normalizedTime == 1) continue;
48
+
49
+ if (animatorsWithPlayAtStart.includes(animator)) continue;
42
50
 
43
51
  // Create a PlayAnimationOnClick component that will play the animation on start
44
52
  // This is a bit hacky right now (we're using the internal "start" trigger)
@@ -50,6 +58,8 @@
50
58
  const go = new Object3D();
51
59
  GameObject.addComponent(go, newComponent);
52
60
  constructedObjects.push(go);
61
+
62
+ animatorsWithPlayAtStart.push(animator);
53
63
 
54
64
  // the behaviour can be anywhere in the hierarchy
55
65
  root.add(go);
@@ -85,6 +95,7 @@
85
95
  for (const animationComponent of animationComponents) {
86
96
  if (!animationComponent || !animationComponent.clip ||!animationComponent.enabled) continue;
87
97
  if (!animationComponent.playAutomatically) continue;
98
+ if (animatorsWithPlayAtStart.includes(animationComponent)) continue;
88
99
 
89
100
  // Create a PlayAnimationOnClick component that will play the animation on start
90
101
  // This is a bit hacky right now (we're using the internal "start" trigger)
@@ -97,6 +108,8 @@
97
108
  GameObject.addComponent(go, newComponent);
98
109
  constructedObjects.push(go);
99
110
 
111
+ animatorsWithPlayAtStart.push(animationComponent);
112
+
100
113
  // the behaviour can be anywhere in the hierarchy
101
114
  root.add(go);
102
115
  }
@@ -130,6 +143,8 @@
130
143
  export function registerAudioSourcesImplictly(root: Object3D, _ext: AudioExtension): Array<Object3D> {
131
144
  const audioSources = GameObject.getComponentsInChildren(root, AudioSource);
132
145
  const playAudioOnClicks = GameObject.getComponentsInChildren(root, PlayAudioOnClick);
146
+ const audioWithPlayAtStart = new Array<AudioSource>();
147
+
133
148
 
134
149
  const constructedObjects = new Array<Object3D>();
135
150
 
@@ -144,6 +159,7 @@
144
159
  for (const audioSource of audioSources) {
145
160
  if (!audioSource || !audioSource.clip) continue;
146
161
  if (audioSource.volume <= 0) continue;
162
+ if (audioWithPlayAtStart.includes(audioSource)) continue;
147
163
 
148
164
  const newComponent = new PlayAudioOnClick();
149
165
  newComponent.target = audioSource;
@@ -153,6 +169,8 @@
153
169
  console.log("implicit PlayAudioOnStart", go, newComponent);
154
170
  constructedObjects.push(go);
155
171
 
172
+ audioWithPlayAtStart.push(audioSource);
173
+
156
174
  root.add(go);
157
175
  }
158
176
 
src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { Behaviour, GameObject } from "../../../../Component.js";
13
13
  import type { IPointerClickHandler, PointerEventData } from "../../../../ui/PointerEvents.js";
14
14
  import { ObjectRaycaster,Raycaster } from "../../../../ui/Raycaster.js";
15
- import { USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
15
+ import { makeNameSafeForUSD,USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
16
16
  import { AnimationExtension, RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
17
17
  import { AudioExtension } from "./AudioExtension.js";
18
18
  import type { BehaviorExtension, UsdzBehaviour } from "./Behaviour.js";
@@ -297,6 +297,10 @@
297
297
  );
298
298
  }
299
299
 
300
+ private static getMaterialName(material: Material) {
301
+ return makeNameSafeForUSD(material.name || 'Material') + "_" + material.id;
302
+ }
303
+
300
304
  static variantSwitchIndex: number = 0;
301
305
  private createVariants() {
302
306
  if (!this.variantMaterial) return null;
@@ -304,8 +308,8 @@
304
308
  const variantModels: USDObject[] = [];
305
309
  for (const target of this.targetModels) {
306
310
  const variant = target.clone();
307
- variant.name += " variant_" + this.variantMaterial.name + "_" + ChangeMaterialOnClick.variantSwitchIndex++;
308
- variant.name = variant.name.replace(/\s/g, "_");
311
+ variant.name += "_Variant_" + ChangeMaterialOnClick.variantSwitchIndex++ + "_" + ChangeMaterialOnClick.getMaterialName(this.variantMaterial);
312
+ variant.displayName = variant.displayName + ": Variant with material " + this.variantMaterial.name;
309
313
  variant.material = this.variantMaterial;
310
314
  variant.geometry = target.geometry;
311
315
  variant.matrix = target.matrix;
@@ -811,14 +815,14 @@
811
815
  if (model.uuid === this.target?.uuid) {
812
816
  const sequence = ActionBuilder.sequence();
813
817
 
814
- if (this.animationSequence !== undefined)
818
+ if (this.animationSequence && this.animationSequence.length > 0)
815
819
  {
816
820
  for (const anim of this.animationSequence) {
817
821
  sequence.addAction(getOrCacheAction(model, anim));
818
822
  }
819
823
  }
820
824
 
821
- if (this.animationLoopAfterSequence !== undefined) {
825
+ if (this.animationLoopAfterSequence && this.animationLoopAfterSequence.length > 0) {
822
826
  // only make a new action group if there's already stuff in the existing one
823
827
  const loopSequence = sequence.actions.length == 0 ? sequence : ActionBuilder.sequence();
824
828
  for (const anim of this.animationLoopAfterSequence) {
@@ -829,7 +833,7 @@
829
833
  sequence.addAction(loopSequence);
830
834
  }
831
835
 
832
- const playAnimationOnTap = new BehaviorModel("tap " + this.name + " for " + this.stateName + " on " + this.target?.name,
836
+ const playAnimationOnTap = new BehaviorModel("_tap_" + this.name + "_toPlayClip_" + this.stateName + "_on_" + this.target?.name,
833
837
  this.trigger == "tap" ? TriggerBuilder.tapTrigger(this.selfModel) : TriggerBuilder.sceneStartTrigger(),
834
838
  sequence
835
839
  );
@@ -847,7 +851,7 @@
847
851
 
848
852
  this.stateAnimationModel = model;
849
853
 
850
- if (this.animation && this.animation.clip) {
854
+ if (this.animation) {
851
855
  this.animationSequence = new Array<RegisteredAnimationInfo>();
852
856
  this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
853
857
  const anim = ext.registerAnimation(this.target, this.animation.clip);
@@ -920,6 +924,15 @@
920
924
  }
921
925
  }
922
926
 
927
+ // Special case: someone's trying to play an empty clip without any motion data, no loops or anything.
928
+ // In that case, we simply go to the rest clip – this is a common case for "idle" states.
929
+ if (statesUntilLoop.length === 1 && (!statesUntilLoop[0].motion?.clip || statesUntilLoop[0].motion?.clip.tracks?.length === 0)) {
930
+ this.animationSequence = new Array<RegisteredAnimationInfo>();
931
+ const anim = ext.registerAnimation(this.target, null);
932
+ if (anim) this.animationSequence.push(anim);
933
+ return;
934
+ }
935
+
923
936
  // filter out any states that don't have motion data
924
937
  statesUntilLoop = statesUntilLoop.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
925
938
  statesLooping = statesLooping.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
@@ -932,11 +945,7 @@
932
945
 
933
946
  const addStateToSequence = (state: State, sequence: Array<RegisteredAnimationInfo>) => {
934
947
  if (!this.target) return;
935
- if (!state.motion?.clip) {
936
- console.warn("No clip found for state " + state.name + " on " + this.animator?.name + ", can't export animation data");
937
- return;
938
- }
939
- const anim = ext.registerAnimation(this.target, state.motion.clip);
948
+ const anim = ext.registerAnimation(this.target, state.motion.clip ?? null);
940
949
  if (anim) sequence.push(anim);
941
950
  else console.warn("Couldn't register animation for state " + state.name + " on " + this.animator?.name);
942
951
  };
src/engine/engine_lifecycle_functions_internal.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Context,FrameEvent } from "./engine_context.js";
1
+ import { type Context, FrameEvent } from "./engine_context.js";
2
2
  import type { ContextEvent } from "./engine_context_registry.js";
3
3
  import { safeInvoke } from "./engine_generic_utils.js";
4
4
 
@@ -45,10 +45,14 @@
45
45
  }
46
46
  }
47
47
 
48
-
48
+ const bufferArray = new Array<LifecycleMethod>();
49
49
  function invoke(ctx: Context, methods: Array<LifecycleMethod>) {
50
- for (let i = methods.length - 1; i >= 0; i--) {
51
- const method = methods[i];
50
+ bufferArray.length = 0;
51
+ for (let i = 0; i < methods.length; i++) {
52
+ bufferArray.push(methods[i]);
53
+ }
54
+ for (let i = 0; i < bufferArray.length; i++) {
55
+ const method = bufferArray[i];
52
56
  safeInvoke(method, ctx);
53
57
  }
54
58
  }
src/engine/extensions/NEEDLE_animator_controller_model.ts CHANGED
@@ -75,6 +75,10 @@
75
75
  readonly speed: number;
76
76
  /** The current action playing. It can be used to modify the action */
77
77
  readonly action: AnimationAction | null;
78
+ /**
79
+ * If the state has any transitions
80
+ */
81
+ readonly hasTransitions: boolean;
78
82
 
79
83
  constructor(state: State, normalizedTime: number, length: number, speed: number) {
80
84
  this.name = state.name;
@@ -83,6 +87,7 @@
83
87
  this.length = length;
84
88
  this.speed = speed;
85
89
  this.action = state.motion.action || null;
90
+ this.hasTransitions = state.transitions?.length > 0 || false;
86
91
  }
87
92
  }
88
93
 
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -1152,10 +1152,11 @@
1152
1152
  return makeNameSafe(bone.name || 'bone_' + bone.uuid);
1153
1153
  }
1154
1154
 
1155
- function getGeometryName(geometry: BufferGeometry, fallbackName: string) {
1156
- // TODO using object names here breaks instancing...
1157
- // Likely need to remove fallbackName again
1158
- return makeNameSafe(geometry.name || fallbackName || 'Geometry') + "_" + geometry.id;
1155
+ function getGeometryName(geometry: BufferGeometry, _fallbackName: string) {
1156
+ // Using object names here breaks instancing...
1157
+ // So we removed fallbackName again. Downside: geometries don't have nice names...
1158
+ // A better workaround would be that we actually name them on glTF import (so the geometry has a name, not the object)
1159
+ return makeNameSafe(geometry.name || 'Geometry') + "_" + geometry.id;
1159
1160
  }
1160
1161
 
1161
1162
  function getMaterialName(material: Material) {
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -222,9 +222,14 @@
222
222
  return null;
223
223
  }
224
224
 
225
+ if (!this.objectToExport) {
226
+ console.warn("No object to export", this);
227
+ return null;
228
+ }
229
+
225
230
  const blob = await this.export(this.objectToExport);
226
231
  if (!blob) {
227
- console.warn("No object to export", this);
232
+ console.error("USDZ generation failed. Please report a bug", this);
228
233
  return null;
229
234
  }
230
235
 
@@ -255,6 +260,7 @@
255
260
  if (taskForThisObject) {
256
261
  return taskForThisObject;
257
262
  }
263
+
258
264
  // start the export
259
265
  const task = this.internalExport(objectToExport);
260
266
  // store the task
@@ -263,8 +269,9 @@
263
269
  return task.then((blob) => {
264
270
  this._currentExportTasks.delete(objectToExport);
265
271
  return blob;
266
- }).catch((_) => {
272
+ }).catch((e) => {
267
273
  this._currentExportTasks.delete(objectToExport);
274
+ console.error("Error during USDZ export – please report a bug!", e);
268
275
  return null;
269
276
  });
270
277
  }