Needle Engine

Changes between version 3.37.10-alpha.9 and 3.37.11-alpha
Files changed (25) hide show
  1. src/engine-components/export/usdz/extensions/Animation.ts +27 -4
  2. src/engine-components/export/usdz/utils/animationutils.ts +14 -0
  3. src/engine-components/export/usdz/extensions/behavior/AudioExtension.ts +10 -0
  4. src/engine-components/ui/BaseUIComponent.ts +1 -1
  5. src/engine-components/export/usdz/extensions/behavior/Behaviour.ts +39 -8
  6. src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +132 -93
  7. src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts +19 -5
  8. src/engine-components/ContactShadows.ts +47 -27
  9. src/engine/debug/debug.ts +2 -2
  10. src/engine/engine_components.ts +4 -3
  11. src/engine/engine_context.ts +5 -1
  12. src/engine/engine_element_loading.ts +8 -6
  13. src/engine/engine_gltf_builtin_components.ts +3 -0
  14. src/engine/engine_lifecycle_functions_internal.ts +9 -4
  15. src/engine/engine_physics_rapier.ts +8 -10
  16. src/engine/engine_utils_screenshot.ts +2 -2
  17. src/engine-components/ui/RectTransform.ts +3 -3
  18. src/engine/codegen/register_types.ts +2 -2
  19. src/engine-components/Renderer.ts +10 -2
  20. src/engine-components/SceneSwitcher.ts +2 -2
  21. src/engine-components/SpriteRenderer.ts +2 -2
  22. src/engine-components/export/usdz/ThreeUSDZExporter.ts +158 -91
  23. src/engine-components/export/usdz/USDZExporter.ts +38 -10
  24. src/engine-components/export/usdz/extensions/USDZText.ts +0 -48
  25. src/engine-components/export/usdz/extensions/USDZUI.ts +68 -4
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -30,6 +30,9 @@
30
30
  private root: Object3D;
31
31
  private clip: AnimationClip | null;
32
32
 
33
+ // Playback speed. Does not affect how the animation is written, just how fast actions play it back.
34
+ speed?: number;
35
+
33
36
  constructor(ext: AnimationExtension, root: Object3D, clip: AnimationClip | null) {
34
37
  this.ext = ext;
35
38
  this.root = root;
@@ -498,12 +501,32 @@
498
501
  const skeleton = model.skinnedMesh.skeleton;
499
502
 
500
503
  const boneAndInverse = new Array<{bone: Object3D, inverse: Matrix4}>();
501
- for (const index in skeleton.bones) {
502
- const bone = skeleton.bones[index];
503
- const inverse = skeleton.boneInverses[index];
504
- boneAndInverse.push({bone, inverse});
504
+
505
+ const sortedBones:Bone[] = [];
506
+ const uuidsFound:string[] = [];
507
+ for(const bone of skeleton.bones ){
508
+ if (bone.parent!.type !== 'Bone'){
509
+ sortedBones.push(bone);
510
+ const inverse = skeleton.boneInverses[skeleton.bones.indexOf(bone)];
511
+ boneAndInverse.push({bone, inverse});
512
+ uuidsFound.push(bone.uuid);
513
+ }
505
514
  }
506
515
 
516
+ while (uuidsFound.length < skeleton.bones.length){
517
+ for(const sortedBone of sortedBones){
518
+ const children = sortedBone.children as Bone[];
519
+ for (const childBone of children){
520
+ if (uuidsFound.indexOf(childBone.uuid) === -1 && skeleton.bones.indexOf(childBone) !== -1){
521
+ sortedBones.push(childBone);
522
+ const childInverse = skeleton.boneInverses[skeleton.bones.indexOf(childBone)];
523
+ boneAndInverse.push({bone: childBone, inverse: childInverse});
524
+ uuidsFound.push(childBone.uuid);
525
+ }
526
+ }
527
+ }
528
+ }
529
+
507
530
  for (const bone of findStructuralNodesInBoneHierarchy(skeleton.bones)) {
508
531
  boneAndInverse.push({bone, inverse: bone.matrixWorld.clone().invert()});
509
532
  }
src/engine-components/export/usdz/utils/animationutils.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import type { AnimationExtension } from "../extensions/Animation.js";
9
9
  import type { AudioExtension } from "../extensions/behavior/AudioExtension.js";
10
10
  import { PlayAnimationOnClick, PlayAudioOnClick } from "../extensions/behavior/BehaviourComponents.js";
11
+ import { ActionBuilder,BehaviorModel, TriggerBuilder } from "../extensions/behavior/BehavioursBuilder.js";
11
12
 
12
13
  const debug = getParam("debugusdz");
13
14
 
@@ -148,6 +149,10 @@
148
149
 
149
150
  const constructedObjects = new Array<Object3D>();
150
151
 
152
+ if (debug) {
153
+ console.log({ audioSources, playAudioOnClicks });
154
+ }
155
+
151
156
  // Remove all audio sources that are already referenced from existing PlayAudioOnClick components
152
157
  for (const player of playAudioOnClicks) {
153
158
  if (!player.target) continue;
@@ -164,6 +169,7 @@
164
169
  const newComponent = new PlayAudioOnClick();
165
170
  newComponent.target = audioSource;
166
171
  newComponent.name = "PlayAudioOnClick_implicitAtStart_";
172
+ newComponent.trigger = "start";
167
173
  const go = new Object3D();
168
174
  GameObject.addComponent(go, newComponent);
169
175
  console.log("implicit PlayAudioOnStart", go, newComponent);
@@ -175,4 +181,12 @@
175
181
  }
176
182
 
177
183
  return constructedObjects;
184
+ }
185
+
186
+ export function disableObjectsAtStart(objects: Array<Object3D>) {
187
+ const newComponent = new BehaviorModel("DisableAtStart",
188
+ TriggerBuilder.sceneStartTrigger(),
189
+ ActionBuilder.fadeAction(objects, 0, false),
190
+ )
191
+ return newComponent;
178
192
  }
src/engine-components/export/usdz/extensions/behavior/AudioExtension.ts CHANGED
@@ -46,6 +46,16 @@
46
46
 
47
47
  if (!this.files.some(f => f.path === audioSource.clip)) {
48
48
  this.files.push({ path: audioSource.clip, name: safeClipNameWithExt });
49
+
50
+ const lowerCase = safeClipNameWithExt.toLowerCase();
51
+ // Warn for non-supported audio formats.
52
+ // See https://openusd.org/release/wp_usdaudio.html#id9
53
+ if (_context.quickLookCompatible &&
54
+ !lowerCase.endsWith(".mp3") &&
55
+ !lowerCase.endsWith(".wav") &&
56
+ !lowerCase.endsWith(".m4a")) {
57
+ console.error("Audio file " + audioSource.clip + " from " + audioSource.name + " is not an MP3 or WAV file. QuickLook may not support playing it.");
58
+ }
49
59
  }
50
60
 
51
61
  // Turns out that in visionOS versions, using UsdAudio together with preliminary behaviours
src/engine-components/ui/BaseUIComponent.ts CHANGED
@@ -148,7 +148,7 @@
148
148
  if (needsUpdate)
149
149
  ThreeMeshUI.update();
150
150
 
151
- if (debug) console.log(this.shadowComponent)
151
+ if (debug) console.warn("Added shadow component", this.shadowComponent);
152
152
  }
153
153
 
154
154
  protected setShadowComponentOwner(current: ThreeMeshUI.MeshUIBaseElement | Object3D | null | undefined) {
src/engine-components/export/usdz/extensions/behavior/Behaviour.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { getParam } from "../../../../../engine/engine_utils.js";
2
2
  import { GameObject } from "../../../../Component.js";
3
3
  import type { IUSDExporterExtension } from "../../Extension.js";
4
- import { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
5
- import { BehaviorModel } from "./BehavioursBuilder.js";
4
+ import type { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
5
+ import { AudioExtension } from "./AudioExtension.js";
6
+ import type { BehaviorModel } from "./BehavioursBuilder.js";
6
7
 
7
8
  const debug = getParam("debugusdzbehaviours");
8
9
 
@@ -26,11 +27,26 @@
26
27
  this.behaviours.push(beh);
27
28
  }
28
29
 
30
+ /** Register audio clip for USDZ export. The clip will be embedded in the resulting file. */
31
+ addAudioClip(clipUrl: string) {
32
+ if (!clipUrl) return "";
33
+ if (typeof clipUrl !== "string") return "";
34
+
35
+ const clipName = AudioExtension.getName(clipUrl);
36
+ const filesKey = "audio/" + clipName;
37
+
38
+ this.audioClips.push({clipUrl, filesKey});
39
+
40
+ return filesKey;
41
+ }
42
+
29
43
  behaviourComponents: Array<UsdzBehaviour> = [];
30
44
  private behaviourComponentsCopy: Array<UsdzBehaviour> = [];
45
+ private audioClips: Array<{clipUrl: string, filesKey: string}> = [];
46
+ private audioClipsCopy: Array<{clipUrl: string, filesKey: string}> = [];
31
47
 
32
-
33
- onBeforeBuildDocument(context) {
48
+ onBeforeBuildDocument(context: USDZExporterContext) {
49
+ if (!context.root) return Promise.resolve();
34
50
  const beforeCreateDocumentPromises : Array<Promise<any>> = [];
35
51
  context.root.traverse(e => {
36
52
  GameObject.foreachComponent(e, (comp) => {
@@ -63,16 +79,18 @@
63
79
  }
64
80
  }
65
81
 
66
- onAfterBuildDocument(context) {
82
+ onAfterBuildDocument(context: USDZExporterContext) {
67
83
  for (const beh of this.behaviourComponents) {
68
84
  if (typeof beh.afterCreateDocument === "function")
69
85
  beh.afterCreateDocument(this, context);
70
86
  }
71
87
  this.behaviourComponentsCopy = this.behaviourComponents.slice();
72
88
  this.behaviourComponents.length = 0;
89
+ this.audioClipsCopy = this.audioClips.slice();
90
+ this.audioClips.length = 0;
73
91
  }
74
92
 
75
- onAfterHierarchy(context, writer: USDWriter) {
93
+ onAfterHierarchy(context: USDZExporterContext, writer: USDWriter) {
76
94
  if (this.behaviours?.length) {
77
95
  writer.beginBlock('def Scope "Behaviors"');
78
96
 
@@ -83,7 +101,7 @@
83
101
  }
84
102
  }
85
103
 
86
- async onAfterSerialize(context) {
104
+ async onAfterSerialize(context: USDZExporterContext) {
87
105
  if (debug) console.log("onAfterSerialize behaviours", this.behaviourComponentsCopy)
88
106
 
89
107
  for (const beh of this.behaviourComponentsCopy) {
@@ -100,8 +118,21 @@
100
118
  }
101
119
  }
102
120
 
121
+ for (const { clipUrl, filesKey } of this.audioClipsCopy) {
122
+
123
+ // if the clip was already added, don't add it again
124
+ if (context.files[filesKey]) return;
125
+
126
+ const audio = await fetch(clipUrl);
127
+ const audioBlob = await audio.blob();
128
+ const arrayBuffer = await audioBlob.arrayBuffer();
129
+ const audioData: Uint8Array = new Uint8Array(arrayBuffer)
130
+ context.files[filesKey] = audioData;
131
+ }
132
+
103
133
  // cleanup
104
- this.behaviours.length = 0;
134
+ this.behaviourComponentsCopy.length = 0;
135
+ this.audioClipsCopy.length = 0;
105
136
  }
106
137
 
107
138
  // combine behaviours that have tap triggers on the same object
src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts CHANGED
@@ -524,7 +524,7 @@
524
524
  // add InputTargetComponent for VisionOS direct/indirect interactions
525
525
  const addInputTargetComponent = (model: USDObject) => {
526
526
  const empty = USDObject.createEmpty();
527
- empty.name = "InputTarget";
527
+ empty.name = "InputTarget_" + empty.name;
528
528
  empty.displayName = undefined;
529
529
  empty.type = "RealityKitComponent";
530
530
  empty.onSerialize = (writer: USDWriter) => {
@@ -574,24 +574,26 @@
574
574
  export class HideOnStart extends Behaviour implements UsdzBehaviour {
575
575
 
576
576
  start() {
577
- this.gameObject.visible = false;
577
+ GameObject.setActive(this.gameObject, false);
578
578
  }
579
579
 
580
580
  createBehaviours(ext, model, _context) {
581
- if (model.uuid === this.gameObject.uuid)
582
- ext.addBehavior(new BehaviorModel("HideOnStart_" + this.gameObject.name,
583
- TriggerBuilder.sceneStartTrigger(),
584
- ActionBuilder.fadeAction(model, 0, false)
585
- ));
581
+ if (model.uuid === this.gameObject.uuid) {
582
+ // we only want to mark the object as HideOnStart if it's still hidden
583
+ if (!this.wasVisible) {
584
+ ext.addBehavior(new BehaviorModel("HideOnStart_" + this.gameObject.name,
585
+ TriggerBuilder.sceneStartTrigger(),
586
+ ActionBuilder.fadeAction(model, 0, false)
587
+ ));
588
+ }
589
+ }
586
590
  }
587
591
 
592
+ private wasVisible: boolean = false;
593
+
588
594
  beforeCreateDocument() {
589
- this.gameObject.visible = true;
595
+ this.wasVisible = GameObject.isActiveSelf(this.gameObject);
590
596
  }
591
-
592
- afterCreateDocument() {
593
- this.gameObject.visible = false;
594
- }
595
597
  }
596
598
 
597
599
  export class EmphasizeOnClick extends Behaviour implements UsdzBehaviour {
@@ -633,6 +635,9 @@
633
635
  @serializable()
634
636
  toggleOnClick: boolean = false;
635
637
 
638
+ // Not exposed, but used for implicit playback of PlayOnAwake audio sources
639
+ trigger: "tap" | "start" = "tap";
640
+
636
641
  start(): void {
637
642
  ensureRaycaster(this.gameObject);
638
643
  }
@@ -672,7 +677,7 @@
672
677
  }
673
678
  }
674
679
 
675
- createBehaviours(ext, model, _context) {
680
+ createBehaviours(ext: BehaviorExtension, model: USDObject, _context: USDZExporterContext) {
676
681
  if (!this.target && !this.clip) return;
677
682
  if (model.uuid === this.gameObject.uuid) {
678
683
 
@@ -694,16 +699,20 @@
694
699
  // unclear why, but having a useless tap action (nothing to tap on) "fixes" it.
695
700
  anyChildHasGeometry = true;
696
701
 
697
- let playAction: IBehaviorElement = ActionBuilder.playAudioAction(playbackTarget, "audio/" + clipName, "play", volume, auralMode);
702
+ const audioClip = ext.addAudioClip(clipUrl);
703
+ // const stopAction: IBehaviorElement = ActionBuilder.playAudioAction(playbackTarget, audioClip, "stop", volume, auralMode);
704
+ let playAction: IBehaviorElement = ActionBuilder.playAudioAction(playbackTarget, audioClip, "play", volume, auralMode);
698
705
  if (this.target && this.target.loop)
699
706
  playAction = ActionBuilder.sequence(playAction).makeLooping();
700
707
 
701
- if (anyChildHasGeometry)
708
+ const behaviorName = (this.name ? "_" + this.name : "");
709
+
710
+ if (anyChildHasGeometry && this.trigger === "tap")
702
711
  {
703
712
  // does not seem to work in iOS / QuickLook...
704
713
  // TODO use play "type" which can be start/stop/pause
705
714
  if (this.toggleOnClick) (playAction as ActionModel).multiplePerformOperation = "stop";
706
- const playClipOnTap = new BehaviorModel("playAudio " + this.name,
715
+ const playClipOnTap = new BehaviorModel("playAudio" + behaviorName,
707
716
  TriggerBuilder.tapTrigger(this.gameObject),
708
717
  playAction,
709
718
  );
@@ -712,13 +721,13 @@
712
721
 
713
722
  // automatically play audio on start too if the referenced AudioSource has playOnAwake enabled
714
723
  if (this.target && this.target.playOnAwake && this.target.enabled) {
715
- if (anyChildHasGeometry) {
724
+ if (anyChildHasGeometry && this.trigger === "tap") {
716
725
  // HACK Currently (20240509) we MUST not emit this behaviour if we're also expecting the tap trigger to work.
717
- // Seems to be a regression in QuickLook...
726
+ // Seems to be a regression in QuickLook... audio clips can't be stopped anymore as soon as they start playing.
718
727
  console.warn("USDZExport: Audio sources that are played on tap can't also auto-play at scene start due to a QuickLook bug.");
719
728
  }
720
729
  else {
721
- const playClipOnStart = new BehaviorModel("playAudioOnStart" + (this.name ? "_" + this.name : ""),
730
+ const playClipOnStart = new BehaviorModel("playAudioOnStart" + behaviorName,
722
731
  TriggerBuilder.sceneStartTrigger(),
723
732
  playAction,
724
733
  );
@@ -727,24 +736,6 @@
727
736
  }
728
737
  }
729
738
  }
730
-
731
- async afterSerialize(_ext, context) {
732
- if (!this.target && !this.clip) return;
733
- const clipUrl = this.clip ? this.clip : this.target ? this.target.clip : undefined;
734
- if (!clipUrl) return;
735
- if (typeof clipUrl !== "string") return;
736
-
737
- const clipName = AudioExtension.getName(clipUrl);
738
- const filesKey = "audio/" + clipName;
739
- // if the clip was already added, don't add it again
740
- if (context.files[filesKey]) return;
741
-
742
- const audio = await fetch(clipUrl);
743
- const audioBlob = await audio.blob();
744
- const arrayBuffer = await audioBlob.arrayBuffer();
745
- const audioData: Uint8Array = new Uint8Array(arrayBuffer)
746
- context.files[filesKey] = audioData;
747
- }
748
739
  }
749
740
 
750
741
  export class PlayAnimationOnClick extends Behaviour implements IPointerClickHandler, UsdzBehaviour, UsdzAnimation {
@@ -785,7 +776,8 @@
785
776
  private animationSequence? = new Array<RegisteredAnimationInfo>();
786
777
  private animationLoopAfterSequence? = new Array<RegisteredAnimationInfo>();
787
778
 
788
- createBehaviours(_ext, model, _context) {
779
+ createBehaviours(_ext: BehaviorExtension, model: USDObject, _context: USDZExporterContext) {
780
+
789
781
  if (model.uuid === this.gameObject.uuid)
790
782
  this.selfModel = model;
791
783
  }
@@ -827,38 +819,17 @@
827
819
  PlayAnimationOnClick.rootsWithExclusivePlayback.add(this.target);
828
820
  }
829
821
 
830
- const getOrCacheAction = (model: USDObject, anim: RegisteredAnimationInfo) => {
831
- let action = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == anim.start && a.duration == anim.duration);
832
- if (!action) {
833
- action = ActionBuilder.startAnimationAction(model, anim.start, anim.duration) as ActionModel;
834
- PlayAnimationOnClick.animationActions.push(action);
835
- }
836
- return action;
837
- }
822
+ const behaviorName = (this.name ? this.name : "");
838
823
 
839
824
  document.traverse(model => {
840
825
  if (model.uuid === this.target?.uuid) {
841
- const sequence = ActionBuilder.sequence();
826
+ const sequence = PlayAnimationOnClick.getActionForSequences(
827
+ model,
828
+ this.animationSequence,
829
+ this.animationLoopAfterSequence
830
+ );
842
831
 
843
- if (this.animationSequence && this.animationSequence.length > 0)
844
- {
845
- for (const anim of this.animationSequence) {
846
- sequence.addAction(getOrCacheAction(model, anim));
847
- }
848
- }
849
-
850
- if (this.animationLoopAfterSequence && this.animationLoopAfterSequence.length > 0) {
851
- // only make a new action group if there's already stuff in the existing one
852
- const loopSequence = sequence.actions.length == 0 ? sequence : ActionBuilder.sequence();
853
- for (const anim of this.animationLoopAfterSequence) {
854
- loopSequence.addAction(getOrCacheAction(model, anim));
855
- }
856
- loopSequence.makeLooping();
857
- if (sequence !== loopSequence)
858
- sequence.addAction(loopSequence);
859
- }
860
-
861
- const playAnimationOnTap = new BehaviorModel("_tap_" + this.name + "_toPlayClip_" + this.stateName + "_on_" + this.target?.name,
832
+ const playAnimationOnTap = new BehaviorModel(this.trigger + "_" + behaviorName + "_toPlayAnimation_" + this.stateName + "_on_" + this.target?.name,
862
833
  this.trigger == "tap" ? TriggerBuilder.tapTrigger(this.selfModel) : TriggerBuilder.sceneStartTrigger(),
863
834
  sequence
864
835
  );
@@ -871,19 +842,66 @@
871
842
  });
872
843
  }
873
844
 
874
- createAnimation(ext: AnimationExtension, model: USDObject, _context: USDZExporterContext) {
875
- if (!this.target || (!this.animator && !this.animation)) return;
845
+ static getActionForSequences(model: USDObject, animationSequence?: Array<RegisteredAnimationInfo>, animationLoopAfterSequence?: Array<RegisteredAnimationInfo>) {
876
846
 
877
- this.stateAnimationModel = model;
847
+ const getOrCacheAction = (model: USDObject, anim: RegisteredAnimationInfo) => {
848
+ let action = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == anim.start && a.duration == anim.duration && a.animationSpeed == anim.speed);
849
+ if (!action) {
850
+ action = ActionBuilder.startAnimationAction(model, anim.start, anim.duration, anim.speed) as ActionModel;
851
+ PlayAnimationOnClick.animationActions.push(action);
852
+ }
853
+ return action;
854
+ }
878
855
 
879
- if (this.animation) {
880
- this.animationSequence = new Array<RegisteredAnimationInfo>();
881
- this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
882
- const anim = ext.registerAnimation(this.target, this.animation.clip);
883
- if (anim) this.animationLoopAfterSequence.push(anim);
884
- return;
856
+ const sequence = ActionBuilder.sequence();
857
+
858
+ if (animationSequence && animationSequence.length > 0)
859
+ {
860
+ for (const anim of animationSequence) {
861
+ sequence.addAction(getOrCacheAction(model, anim));
862
+ }
885
863
  }
886
864
 
865
+ if (animationLoopAfterSequence && animationLoopAfterSequence.length > 0) {
866
+ // only make a new action group if there's already stuff in the existing one
867
+ const loopSequence = sequence.actions.length == 0 ? sequence : ActionBuilder.sequence();
868
+ for (const anim of animationLoopAfterSequence) {
869
+ loopSequence.addAction(getOrCacheAction(model, anim));
870
+ }
871
+ loopSequence.makeLooping();
872
+ if (sequence !== loopSequence)
873
+ sequence.addAction(loopSequence);
874
+ }
875
+
876
+ return sequence;
877
+ }
878
+
879
+ static getAndRegisterAnimationSequences(ext: AnimationExtension, target: GameObject, stateName?: string):
880
+ { animationSequence: Array<RegisteredAnimationInfo>, animationLoopAfterSequence: Array<RegisteredAnimationInfo> } | undefined {
881
+
882
+ if (!target) return undefined;
883
+
884
+ const animator = target.getComponent(Animator);
885
+ const animation = target.getComponent(Animation);
886
+
887
+ if (!animator && !animation) return undefined;
888
+
889
+ if (animator && !stateName) {
890
+ throw new Error("PlayAnimationOnClick: No stateName specified for animator " + animator.name + " on " + target.name);
891
+ }
892
+
893
+ let animationSequence: Array<RegisteredAnimationInfo> = [];
894
+ let animationLoopAfterSequence: Array<RegisteredAnimationInfo> = [];
895
+
896
+ if (animation) {
897
+ const anim = ext.registerAnimation(target, animation.clip);
898
+ if (anim) animationLoopAfterSequence.push(anim);
899
+ return {
900
+ animationSequence,
901
+ animationLoopAfterSequence
902
+ }
903
+ }
904
+
887
905
  // If there's a separate state specified to play after this one, we
888
906
  // play it automatically. Theoretically an animator state machine flow could be encoded here.
889
907
 
@@ -893,8 +911,8 @@
893
911
  // - (0 > 1 > 1) should keep looping (1).
894
912
  // - (0 > 1 > 2 > 3 > 2) should keep looping (2,3).
895
913
  // - (0 > 1 > 2 > 3 > 0) should keep looping (0,1,2,3).
896
- const runtimeController = this.animator?.runtimeAnimatorController;
897
- let currentState = runtimeController?.findState(this.stateName);
914
+ const runtimeController = animator?.runtimeAnimatorController;
915
+ let currentState = runtimeController?.findState(stateName);
898
916
  let statesUntilLoop: State[] = [];
899
917
  let statesLooping: State[] = [];
900
918
 
@@ -940,22 +958,22 @@
940
958
  const firstStateInLoop = visitedStates.indexOf(currentState);
941
959
  statesUntilLoop = visitedStates.slice(0, firstStateInLoop); // can be empty, which means we're looping all
942
960
  statesLooping = visitedStates.slice(firstStateInLoop); // can be empty, which means nothing is looping
943
- if (debug) console.log("found loop from " + this.stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
961
+ if (debug) console.log("found loop from " + stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
944
962
  }
945
963
  else {
946
964
  statesUntilLoop = visitedStates;
947
965
  statesLooping = [];
948
- if (debug) console.log("found no loop from " + this.stateName, "states", statesUntilLoop);
966
+ if (debug) console.log("found no loop from " + stateName, "states", statesUntilLoop);
949
967
  }
950
968
  }
951
969
 
952
970
  // Special case: someone's trying to play an empty clip without any motion data, no loops or anything.
953
971
  // In that case, we simply go to the rest clip – this is a common case for "idle" states.
954
972
  if (statesUntilLoop.length === 1 && (!statesUntilLoop[0].motion?.clip || statesUntilLoop[0].motion?.clip.tracks?.length === 0)) {
955
- this.animationSequence = new Array<RegisteredAnimationInfo>();
956
- const anim = ext.registerAnimation(this.target, null);
957
- if (anim) this.animationSequence.push(anim);
958
- return;
973
+ animationSequence = new Array<RegisteredAnimationInfo>();
974
+ const anim = ext.registerAnimation(target, null);
975
+ if (anim) animationSequence.push(anim);
976
+ return undefined;
959
977
  }
960
978
 
961
979
  // filter out any states that don't have motion data
@@ -964,32 +982,53 @@
964
982
 
965
983
  // If none of the found states have motion, we need to warn
966
984
  if (statesUntilLoop.length === 0 && statesLooping.length === 0) {
967
- console.warn("No clips found for state " + this.stateName + " on " + this.animator?.name + ", can't export animation data");
968
- return;
985
+ console.warn("No clips found for state " + stateName + " on " + animator?.name + ", can't export animation data");
986
+ return undefined;
969
987
  }
970
988
 
971
989
  const addStateToSequence = (state: State, sequence: Array<RegisteredAnimationInfo>) => {
972
- if (!this.target) return;
973
- const anim = ext.registerAnimation(this.target, state.motion.clip ?? null);
974
- if (anim) sequence.push(anim);
975
- else console.warn("Couldn't register animation for state " + state.name + " on " + this.animator?.name);
990
+ if (!target) return;
991
+ const anim = ext.registerAnimation(target, state.motion.clip ?? null);
992
+ if (anim) {
993
+ anim.speed = state.speed;
994
+ sequence.push(anim);
995
+ }
996
+ else console.warn("Couldn't register animation for state " + state.name + " on " + animator?.name);
976
997
  };
977
998
 
978
999
  // Register all the animation states we found.
979
1000
  if (statesUntilLoop.length > 0) {
980
- this.animationSequence = new Array<RegisteredAnimationInfo>();
1001
+ animationSequence = new Array<RegisteredAnimationInfo>();
981
1002
  for (const state of statesUntilLoop) {
982
- addStateToSequence(state, this.animationSequence);
1003
+ addStateToSequence(state, animationSequence);
983
1004
  }
984
1005
  }
985
1006
  if (statesLooping.length > 0) {
986
- this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
1007
+ animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
987
1008
  for (const state of statesLooping) {
988
- addStateToSequence(state, this.animationLoopAfterSequence);
1009
+ addStateToSequence(state, animationLoopAfterSequence);
989
1010
  }
990
1011
  }
1012
+
1013
+ return {
1014
+ animationSequence,
1015
+ animationLoopAfterSequence
1016
+ }
991
1017
  }
992
1018
 
1019
+ createAnimation(ext: AnimationExtension, model: USDObject, _context: USDZExporterContext) {
1020
+
1021
+ if (!this.target || (!this.animator && !this.animation)) return;
1022
+
1023
+ const result = PlayAnimationOnClick.getAndRegisterAnimationSequences(ext, this.target, this.stateName);
1024
+ if (!result) return;
1025
+
1026
+ this.animationSequence = result.animationSequence;
1027
+ this.animationLoopAfterSequence = result.animationLoopAfterSequence;
1028
+
1029
+ this.stateAnimationModel = model;
1030
+ }
1031
+
993
1032
  }
994
1033
 
995
1034
  export class PreliminaryAction extends Behaviour {
src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts CHANGED
@@ -75,6 +75,10 @@
75
75
  let str = "[ ";
76
76
  for (let i = 0; i < targetObject.length; i++) {
77
77
  let obj = targetObject[i];
78
+ if (!obj) {
79
+ console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?");
80
+ continue;
81
+ }
78
82
  if (typeof obj === "string")
79
83
  str += obj;
80
84
  else if (typeof obj === "object") {
@@ -82,6 +86,10 @@
82
86
  if (obj.isObject3D) {
83
87
  //@ts-ignore
84
88
  obj = document.findById(obj.uuid);
89
+ if (!obj) {
90
+ console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?");
91
+ continue;
92
+ }
85
93
  }
86
94
  const res = (obj as any).getPath?.call(obj) as string;
87
95
  str += res;
@@ -313,8 +321,14 @@
313
321
  if (typeof this.affectedObjects !== "string") this.affectedObjects = resolve(this.affectedObjects, document);
314
322
  writer.appendLine('rel affectedObjects = ' + this.affectedObjects);
315
323
  }
316
- if (typeof this.duration === "number")
317
- writer.appendLine(`double duration = ${this.duration} `);
324
+ if (typeof this.duration === "number") {
325
+ if (typeof this.animationSpeed === "number" && this.animationSpeed !== 1) {
326
+ writer.appendLine(`double duration = ${this.duration / this.animationSpeed} `);
327
+ }
328
+ else {
329
+ writer.appendLine(`double duration = ${this.duration} `);
330
+ }
331
+ }
318
332
  if (this.easeType)
319
333
  writer.appendLine(`token easeType = "${this.easeType}"`);
320
334
  if (this.tokenId)
@@ -337,7 +351,7 @@
337
351
  writer.appendLine(`double start = ${this.start} `);
338
352
  }
339
353
  if (typeof this.animationSpeed === "number") {
340
- writer.appendLine(`double animationSpeed = ${this.animationSpeed} `);
354
+ writer.appendLine(`double animationSpeed = ${this.animationSpeed.toFixed(2)} `);
341
355
  }
342
356
  if (typeof this.reversed === "boolean") {
343
357
  writer.appendLine(`bool reversed = ${this.reversed}`)
@@ -469,10 +483,10 @@
469
483
  return act;
470
484
  }
471
485
 
472
- static lookAtCameraAction(targets: Target, duration: number = 9999999999999, front?: Vec3, up?: Vec3): ActionModel {
486
+ static lookAtCameraAction(targets: Target, duration?: number, front?: Vec3, up?: Vec3): ActionModel {
473
487
  const act = new ActionModel(targets);
474
488
  act.tokenId = "LookAtCamera";
475
- act.duration = duration;
489
+ act.duration = duration === undefined ? 9999999999999 : duration;
476
490
  act.front = front ?? Vec3.forward;
477
491
  // 0,0,0 is a special case for "free look"
478
492
  // 0,1,0 is for "y-locked look-at"
src/engine-components/ContactShadows.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CustomBlending, DoubleSide, FrontSide, Group, Matrix4, MaxEquation, Mesh, MeshBasicMaterial, MeshDepthMaterial, MinEquation, Object3D, OrthographicCamera, PlaneGeometry, RenderItem, ShaderMaterial, Vector3, WebGLRenderTarget } from "three";
1
+ import { BackSide, CustomBlending, DoubleSide, FrontSide, Group, Matrix4, MaxEquation, Mesh, MeshBasicMaterial, MeshDepthMaterial, MinEquation, Object3D, OrthographicCamera, PlaneGeometry, RenderItem, ShaderMaterial, Vector3, WebGLRenderTarget } from "three";
2
2
  import { HorizontalBlurShader } from 'three/examples/jsm/shaders/HorizontalBlurShader.js';
3
3
  import { VerticalBlurShader } from 'three/examples/jsm/shaders/VerticalBlurShader.js';
4
4
 
@@ -10,7 +10,7 @@
10
10
  import { getBoundingBox } from "../engine/engine_three_utils.js";
11
11
  import { getParam } from "../engine/engine_utils.js"
12
12
  import { setCustomVisibility } from "../engine/js-extensions/Layers.js";
13
- import { Behaviour } from "./Component.js";
13
+ import { Behaviour, GameObject } from "./Component.js";
14
14
 
15
15
  const debug = getParam("debugcontactshadows");
16
16
 
@@ -26,7 +26,7 @@
26
26
  */
27
27
  export class ContactShadows extends Behaviour {
28
28
 
29
- private static _instance: ContactShadows;
29
+ private static _instances: Map<Context, ContactShadows> = new Map();
30
30
  /**
31
31
  * Create contact shadows for the scene. Automatically fits the shadows to the scene.
32
32
  * The instance of contact shadows will be created only once.
@@ -34,17 +34,22 @@
34
34
  * @returns The instance of the contact shadows.
35
35
  */
36
36
  static auto(context?: Context): ContactShadows {
37
- if (!this._instance || this._instance.destroyed) {
37
+ if (!context) context = Context.Current;
38
+ if (!context) {
39
+ throw new Error("No context provided and no current context set.");
40
+ }
41
+ let instance = this._instances.get(context);
42
+ if (!instance || instance.destroyed) {
38
43
  const obj = new Object3D();
39
- this._instance = addComponent(obj, ContactShadows, {
44
+ instance = addComponent(obj, ContactShadows, {
40
45
  autoFit: false,
41
46
  occludeBelowGround: false
42
47
  });
48
+ this._instances.set(context, instance);
43
49
  }
44
- if (!context) context = Context.Current;
45
- context.scene.add(this._instance.gameObject);
46
- this._instance.fitShadows();
47
- return this._instance;
50
+ context.scene.add(instance.gameObject);
51
+ instance.fitShadows();
52
+ return instance;
48
53
  }
49
54
 
50
55
  /**
@@ -59,7 +64,7 @@
59
64
  @serializable()
60
65
  blur: number = 4.0;
61
66
  @serializable()
62
- occludeBelowGround: boolean = true;
67
+ occludeBelowGround: boolean = false;
63
68
  @serializable()
64
69
  backfaceShadows: boolean = true;
65
70
 
@@ -77,6 +82,8 @@
77
82
  private horizontalBlurMaterial?: ShaderMaterial;
78
83
  private verticalBlurMaterial?: ShaderMaterial;
79
84
 
85
+ private textureSize = 512;
86
+
80
87
  /**
81
88
  * Call to fit the shadows to the scene.
82
89
  */
@@ -85,15 +92,23 @@
85
92
  setAutoFitEnabled(this.gameObject, false);
86
93
  const box = getBoundingBox(this.context.scene.children, [this.gameObject]);
87
94
  // expand box in all directions (except below ground)
88
- // 0.01 expands by 1% in each direction
89
- const expandFactor = .01;
90
- box.expandByVector(new Vector3(expandFactor, 0, expandFactor));
95
+ // 0.75 expands by 75% in each direction
96
+ // The "32" is pretty much heuristically determined – adjusting the value until we don't get a visible border anymore.
97
+ const expandFactor = Math.max(0.5, this.blur / 32);
98
+ const sizeX = box.max.x - box.min.x;
99
+ const sizeZ = box.max.z - box.min.z;
100
+ box.expandByVector(new Vector3(expandFactor * sizeX, 0, expandFactor * sizeZ));
101
+ if (debug) Gizmos.DrawWireBox3(box, 0xffff00, 60);
102
+ if (this.gameObject.parent) {
103
+ // transform box from world space into parent space
104
+ box.applyMatrix4((this.gameObject.parent as GameObject).matrixWorld.clone().invert());
105
+ }
91
106
  const min = box.min;
92
107
  const offset = Math.max(0.00001, (box.max.y - min.y) * .002);
93
108
  box.max.y += offset;
94
- if (debug) Gizmos.DrawWireBox3(box, 0xffff00, 60);
95
109
  this.gameObject.position.set((min.x + box.max.x) / 2, min.y - offset, (min.z + box.max.z) / 2);
96
110
  this.gameObject.scale.set(box.max.x - min.x, box.max.y - min.y, box.max.z - min.z);
111
+ this.gameObject.matrixWorldNeedsUpdate = true;
97
112
  }
98
113
 
99
114
  awake() {
@@ -104,17 +119,16 @@
104
119
  /** @internal */
105
120
  start(): void {
106
121
  if (debug) console.log("Create ContactShadows on " + this.gameObject.name, this)
107
- const textureSize = 512;
108
122
 
109
123
  this.shadowGroup = new Group();
110
124
  this.gameObject.add(this.shadowGroup);
111
125
 
112
126
  // the render target that will show the shadows in the plane texture
113
- this.renderTarget = new WebGLRenderTarget(textureSize, textureSize);
127
+ this.renderTarget = new WebGLRenderTarget(this.textureSize, this.textureSize);
114
128
  this.renderTarget.texture.generateMipmaps = false;
115
129
 
116
130
  // the render target that we will use to blur the first render target
117
- this.renderTargetBlur = new WebGLRenderTarget(textureSize, textureSize);
131
+ this.renderTargetBlur = new WebGLRenderTarget(this.textureSize, this.textureSize);
118
132
  this.renderTargetBlur.texture.generateMipmaps = false;
119
133
 
120
134
  // make a plane and make it face up
@@ -144,6 +158,7 @@
144
158
  color: 0x000000,
145
159
  transparent: true,
146
160
  depthWrite: false,
161
+ side: FrontSide,
147
162
  });
148
163
  this.plane = new Mesh(planeGeometry, planeMaterial);
149
164
  this.plane.scale.y = - 1;
@@ -155,6 +170,7 @@
155
170
  depthWrite: true,
156
171
  stencilWrite: true,
157
172
  colorWrite: false,
173
+ side: BackSide,
158
174
  }))
159
175
  // .rotateX(Math.PI)
160
176
  .translateY(-0.0001);
@@ -291,14 +307,11 @@
291
307
  // and reset the override material
292
308
  scene.overrideMaterial = null;
293
309
 
294
- const size = this.gameObject.scale.x;
295
-
296
- // take the size of the scene into account - for larger scenes we want less blur to keep it somewhat consistent
297
- const blurAmount = Math.min(this.blur, this.blur / size);
310
+ const blurAmount = Math.max(this.blur, 0.05);
298
311
 
299
- // a second pass to reduce the artifacts
300
- // (0.4 is the minimum blur amout so that the artifacts are gone)
301
- this.blurShadow(blurAmount * 0.4);
312
+ // two-pass blur to reduce the artifacts
313
+ this.blurShadow(blurAmount * 2);
314
+ this.blurShadow(blurAmount * 0.5);
302
315
 
303
316
  this.shadowGroup.visible = false;
304
317
  if (this.occluderMesh) this.occluderMesh.visible = this.occludeBelowGround;
@@ -312,7 +325,7 @@
312
325
  }
313
326
 
314
327
  // renderTarget --> blurPlane (horizontalBlur) --> renderTargetBlur --> blurPlane (verticalBlur) --> renderTarget
315
- private blurShadow(amount) {
328
+ private blurShadow(amount: number) {
316
329
  if (!this.blurPlane || !this.shadowCamera ||
317
330
  !this.renderTarget || !this.renderTargetBlur ||
318
331
  !this.horizontalBlurMaterial || !this.verticalBlurMaterial)
@@ -320,10 +333,17 @@
320
333
 
321
334
  this.blurPlane.visible = true;
322
335
 
336
+ // Correct for contact shadow plane aspect ratio.
337
+ // since we have a separable blur, we can just adjust the blur amount for X and Z individually
338
+ const ws = this.gameObject.worldScale;
339
+ const avg = (ws.x + ws.z) / 2;
340
+ const aspectX = ws.z / avg;
341
+ const aspectZ = ws.x / avg;
342
+
323
343
  // blur horizontally and draw in the renderTargetBlur
324
344
  this.blurPlane.material = this.horizontalBlurMaterial;
325
345
  (this.blurPlane.material as ShaderMaterial).uniforms.tDiffuse.value = this.renderTarget.texture;
326
- this.horizontalBlurMaterial.uniforms.h.value = amount * 1 / 256;
346
+ this.horizontalBlurMaterial.uniforms.h.value = amount * 1 / this.textureSize * aspectX;
327
347
 
328
348
  const renderer = this.context.renderer;
329
349
 
@@ -334,7 +354,7 @@
334
354
  // blur vertically and draw in the main renderTarget
335
355
  this.blurPlane.material = this.verticalBlurMaterial;
336
356
  (this.blurPlane.material as ShaderMaterial).uniforms.tDiffuse.value = this.renderTargetBlur.texture;
337
- this.verticalBlurMaterial.uniforms.v.value = amount * 1 / 256;
357
+ this.verticalBlurMaterial.uniforms.v.value = amount * 1 / this.textureSize * aspectZ;
338
358
 
339
359
  renderer.setRenderTarget(this.renderTarget);
340
360
  renderer.render(this.blurPlane, this.shadowCamera);
src/engine/debug/debug.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isLocalNetwork } from "../engine_networking_utils.js";
2
2
  import { getParam } from "../engine_utils.js";
3
3
  import { showDebugConsole } from "./debug_console.js";
4
- import { addLog, clearMessages, LogType, setAllowOverlayMessages, setAllowBalloonMessages } from "./debug_overlay.js";
4
+ import { addLog, clearMessages, LogType, setAllowBalloonMessages, setAllowOverlayMessages } from "./debug_overlay.js";
5
5
 
6
6
  export { showDebugConsole }
7
7
  export {
@@ -9,8 +9,8 @@
9
9
  /** @deprecated use clearBalloonMessages instead */
10
10
  clearMessages as clearOverlayMessages,
11
11
  LogType,
12
+ setAllowBalloonMessages,
12
13
  setAllowOverlayMessages,
13
- setAllowBalloonMessages,
14
14
  };
15
15
  export { enableSpatialConsole } from "./debug_spatial_console.js";
16
16
 
src/engine/engine_components.ts CHANGED
@@ -59,8 +59,8 @@
59
59
  componentInstance.guid = idProvider.generateUUID();
60
60
  }
61
61
  apply(obj);
62
- // componentInstance.transform = obj;
63
- registerComponent(componentInstance);
62
+ // register the component - make sure to provide the component instance context (if assigned)
63
+ registerComponent(componentInstance, componentInstance.context);
64
64
  try {
65
65
  if (callAwake && componentInstance.__internalAwake) {
66
66
  updateActiveInHierarchyWithoutEventCall(obj);
@@ -108,7 +108,8 @@
108
108
  componentInstance.guid = idProvider.generateUUID();
109
109
  }
110
110
  if(init) componentInstance._internalInit(init);
111
- registerComponent(componentInstance);
111
+ // Register the component - make sure to provide the component instance context (if assigned)
112
+ registerComponent(componentInstance, componentInstance.context);
112
113
  return componentInstance;
113
114
  }
114
115
 
src/engine/engine_context.ts CHANGED
@@ -109,7 +109,11 @@
109
109
  console.error("Registered script is not a Needle Engine component. \nThe script will be ignored. Please make sure your component extends \"Behaviour\" imported from \"@needle-tools/engine\"\n", script);
110
110
  return;
111
111
  }
112
- const new_scripts = context?.new_scripts ?? Context.Current.new_scripts;
112
+ if (!context) {
113
+ context = Context.Current;
114
+ if (debug) console.warn("> Registering component without context");
115
+ }
116
+ const new_scripts = context?.new_scripts;
113
117
  if (!new_scripts.includes(script)) {
114
118
  new_scripts.push(script);
115
119
  }
src/engine/engine_element_loading.ts CHANGED
@@ -241,14 +241,16 @@
241
241
  const logoSize = 120;
242
242
  logo.style.width = `${logoSize}px`;
243
243
  logo.style.height = `${logoSize}px`;
244
- logo.style.padding = "20px";
245
- logo.style.margin = "-20px";
246
- logo.style.marginBottom = "-10px";
244
+ logo.style.paddingTop = "20px";
245
+ logo.style.paddingBottom = "10px";
246
+ logo.style.margin = "0px";
247
247
  logo.style.userSelect = "none";
248
248
  logo.style.objectFit = "contain";
249
- logo.style.transition = "transform 2s ease-out, opacity 1s ease-in-out";
250
- logo.style.transform = "translateY(10px)";
249
+ logo.style.transition = "transform 1.5s ease-out, opacity .3s ease-in-out";
250
+ logo.style.transform = "translateY(30px)";
251
+ logo.style.opacity = "0.05";
251
252
  setTimeout(() => {
253
+ logo.style.opacity = "1";
252
254
  logo.style.transform = "translateY(0px)";
253
255
  }, 1);
254
256
  logo.src = needleLogoOnlySVG;
@@ -334,7 +336,7 @@
334
336
  this._messageContainer = messageContainer;
335
337
  messageContainer.style.display = "flex";
336
338
  messageContainer.style.fontSize = ".8rem";
337
- messageContainer.style.paddingTop = ".2rem";
339
+ messageContainer.style.paddingTop = ".1rem";
338
340
  // messageContainer.style.border = "1px solid rgba(255,255,255,.1)";
339
341
  messageContainer.style.justifyContent = "center";
340
342
  details.appendChild(messageContainer);
src/engine/engine_gltf_builtin_components.ts CHANGED
@@ -220,6 +220,9 @@
220
220
  // assign basic fields
221
221
  assign(instance, compData, context.implementationInformation);
222
222
 
223
+ // make sure we assign the Needle Engine Context because Context.Current is unreliable when loading multiple <needle-engine> elements at the same time due to async processes
224
+ instance.context = context.context;
225
+
223
226
  // assign the guid of the original instance
224
227
  if ("guid" in compData)
225
228
  instance[editorGuidKeyName] = compData.guid;
src/engine/engine_lifecycle_functions_internal.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { type Context, FrameEvent } from "./engine_context.js";
2
- import type { ContextEvent } from "./engine_context_registry.js";
2
+ import { ContextEvent } from "./engine_context_registry.js";
3
3
  import { safeInvoke } from "./engine_generic_utils.js";
4
4
 
5
5
  export declare type LifecycleMethod = (ctx: Context) => void;
6
6
  export declare type Event = ContextEvent | FrameEvent;
7
7
 
8
8
  const allMethods = new Map<Event, Array<LifecycleMethod>>();
9
+ const _started = new WeakSet<Context>();
9
10
 
10
11
  /** register a function to be called during the Needle Engine frame event at a specific point
11
12
  * @param cb the function to call
@@ -35,10 +36,14 @@
35
36
  const methods = allMethods.get(evt);
36
37
  if (methods) {
37
38
  if (methods.length > 0) {
38
- let array = methods;
39
+ const array = methods;
39
40
  if (evt === FrameEvent.Start) {
40
- array = [...methods];
41
- methods.length = 0;
41
+ if (_started.has(ctx)) {
42
+ return;
43
+ }
44
+ else {
45
+ _started.add(ctx);
46
+ }
42
47
  }
43
48
  invoke(ctx, array);
44
49
  }
src/engine/engine_physics_rapier.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  Vec2,
22
22
  Vec3,
23
23
  } from './engine_types.js';
24
- import { Collision,ContactPoint } from './engine_types.js';
24
+ import { Collision, ContactPoint } from './engine_types.js';
25
25
  import { SphereOverlapResult } from './engine_types.js';
26
26
  import { CircularBuffer, getParam } from "./engine_utils.js"
27
27
 
@@ -166,7 +166,7 @@
166
166
  addForce(rigidbody: IRigidbody, force: Vec3, wakeup: boolean) {
167
167
  this.validate();
168
168
  const body = this.internal_getRigidbody(rigidbody);
169
- if(body) body.addForce(force, wakeup)
169
+ if (body) body.addForce(force, wakeup)
170
170
  else console.warn("Rigidbody doesn't exist: can not apply force");
171
171
  }
172
172
  addImpulse(rigidbody: IRigidbody, force: Vec3, wakeup: boolean) {
@@ -206,14 +206,14 @@
206
206
  applyImpulse(rb: IRigidbody, vec: Vec3, wakeup: boolean) {
207
207
  this.validate();
208
208
  const body = this.internal_getRigidbody(rb);
209
- if(body) body.applyImpulse(vec, wakeup);
209
+ if (body) body.applyImpulse(vec, wakeup);
210
210
  else console.warn("Rigidbody doesn't exist: can not apply impulse");
211
211
  }
212
212
 
213
213
  wakeup(rb: IRigidbody) {
214
214
  this.validate();
215
215
  const body = this.internal_getRigidbody(rb);
216
- if(body) body.wakeUp();
216
+ if (body) body.wakeUp();
217
217
  else console.warn("Rigidbody doesn't exist: can not wake up");
218
218
  }
219
219
  isSleeping(rb: IRigidbody) {
@@ -224,13 +224,13 @@
224
224
  setAngularVelocity(rb: IRigidbody, vec: Vec3, wakeup: boolean) {
225
225
  this.validate();
226
226
  const body = this.internal_getRigidbody(rb);
227
- if(body) body.setAngvel(vec, wakeup);
227
+ if (body) body.setAngvel(vec, wakeup);
228
228
  else console.warn("Rigidbody doesn't exist: can not set angular velocity");
229
229
  }
230
230
  setLinearVelocity(rb: IRigidbody, vec: Vec3, wakeup: boolean) {
231
231
  this.validate();
232
232
  const body = this.internal_getRigidbody(rb);
233
- if(body) body.setLinvel(vec, wakeup);
233
+ if (body) body.setLinvel(vec, wakeup);
234
234
  else console.warn("Rigidbody doesn't exist: can not set linear velocity");
235
235
  }
236
236
 
@@ -651,8 +651,7 @@
651
651
  positions = this._meshCache.get(key)!;
652
652
  }
653
653
  else {
654
- console.warn(`Your MeshCollider \"${collider.name}\" is scaled (${scale.x}, ${scale.y}, ${scale.z})\nthis is not optimal for performance since this isn't supported by the Rapier physics engine yet. Consider applying the scale to the collider mesh`);
655
- // showBalloonWarning("Your model is using scaled mesh colliders which is not optimal for performance: " + mesh.name + ", consider using unscaled objects");
654
+ if (debugPhysics || isDevEnvironment()) console.warn(`Your MeshCollider \"${collider.name}\" is scaled: consider applying the scale to the collider mesh instead (${scale.x}, ${scale.y}, ${scale.z})`);
656
655
  const scaledPositions = new Float32Array(positions.length);
657
656
  for (let i = 0; i < positions.length; i += 3) {
658
657
  scaledPositions[i] = positions[i] * scale.x;
@@ -1213,8 +1212,7 @@
1213
1212
  this._tempCenterPos.z = center.z;
1214
1213
  getWorldScale(collider.gameObject, this._tempCenterVec);
1215
1214
  this._tempCenterPos.multiply(this._tempCenterVec);
1216
- if (!collider.attachedRigidbody)
1217
- {
1215
+ if (!collider.attachedRigidbody) {
1218
1216
  getWorldQuaternion(collider.gameObject, this._tempCenterQuaternion);
1219
1217
  this._tempCenterPos.applyQuaternion(this._tempCenterQuaternion);
1220
1218
  }
src/engine/engine_utils_screenshot.ts CHANGED
@@ -75,7 +75,8 @@
75
75
 
76
76
  if (!opts) opts = {}
77
77
 
78
- let { context, width, height, mimeType, camera } = opts;
78
+ let { context, width, height, camera } = opts;
79
+ const { mimeType } = opts;
79
80
 
80
81
  if (!context) {
81
82
  context = ContextRegistry.Current as Context;
@@ -126,7 +127,6 @@
126
127
  // render now
127
128
  context.renderNow();
128
129
 
129
- console.log("Screenshot taken", { width, height, mimeType, camera });
130
130
  const dataUrl = canvas.toDataURL(mimeType);
131
131
  return dataUrl;
132
132
  }
src/engine-components/ui/RectTransform.ts CHANGED
@@ -135,14 +135,14 @@
135
135
  super.onEnable();
136
136
  this.addShadowComponent(this.rectBlock);
137
137
  this._transformNeedsUpdate = true;
138
- this.Canvas?.registerTransform(this);
138
+ this.canvas?.registerTransform(this);
139
139
  // this.onApplyTransform("enable");
140
140
  }
141
141
 
142
142
  onDisable() {
143
143
  super.onDisable();
144
144
  this.removeShadowComponent();
145
- this.Canvas?.unregisterTransform(this);
145
+ this.canvas?.unregisterTransform(this);
146
146
  }
147
147
 
148
148
  onParentRectTransformChanged(comp: IRectTransform) {
@@ -218,7 +218,7 @@
218
218
  // calc anchors and offset and apply
219
219
  tempVec.set(0, 0, 0);
220
220
  this.applyAnchoring(tempVec);
221
- if(this.Canvas?.screenspace) tempVec.z += .1;
221
+ if(this.canvas?.screenspace) tempVec.z += .1;
222
222
  else tempVec.z += .01;
223
223
  tempMatrix.identity();
224
224
  tempMatrix.setPosition(tempVec.x, tempVec.y, tempVec.z);
src/engine/codegen/register_types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
2
  import { TypeStore } from "./../engine_typestore.js"
3
-
3
+
4
4
  // Import types
5
5
  import { __Ignore } from "../../engine-components/codegen/components.js";
6
6
  import { ActionBuilder } from "../../engine-components/export/usdz/extensions/behavior/BehavioursBuilder.js";
@@ -220,7 +220,7 @@
220
220
  import { XRFlag } from "../../engine-components/webxr/XRFlag.js";
221
221
  import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
222
222
  import { XRState } from "../../engine-components/webxr/XRFlag.js";
223
-
223
+
224
224
  // Register types
225
225
  TypeStore.add("__Ignore", __Ignore);
226
226
  TypeStore.add("ActionBuilder", ActionBuilder);
src/engine-components/Renderer.ts CHANGED
@@ -325,6 +325,8 @@
325
325
  private _sharedMaterials!: SharedMaterialArray;
326
326
  private _originalMaterials?: Material[];
327
327
 
328
+ private _probeAnchorLastFrame?: Object3D;
329
+
328
330
  // this is just available during deserialization
329
331
  private set sharedMaterials(_val: Array<Material | null>) {
330
332
  // TODO: elements in the array might be missing at the moment which leads to problems if an index is serialized
@@ -630,6 +632,11 @@
630
632
  return;
631
633
  }
632
634
 
635
+ if(this._probeAnchorLastFrame !== this.probeAnchor) {
636
+ this._reflectionProbe?.onUnset(this);
637
+ this.updateReflectionProbe();
638
+ }
639
+
633
640
  if (debugRenderer == this.name && this.gameObject instanceof Mesh) {
634
641
  this.gameObject.geometry.computeBoundingSphere();
635
642
  const tempCenter = getTempVector(this.gameObject.geometry.boundingSphere.center).applyMatrix4(this.gameObject.matrixWorld);
@@ -749,7 +756,7 @@
749
756
  }
750
757
 
751
758
  if (this.reflectionProbeUsage !== ReflectionProbeUsage.Off && this._reflectionProbe) {
752
- this._reflectionProbe.onUnset(this)
759
+ this._reflectionProbe.onUnset(this);
753
760
  }
754
761
  }
755
762
 
@@ -777,11 +784,12 @@
777
784
  // handle reflection probe
778
785
  this._reflectionProbe = null;
779
786
  if (this.reflectionProbeUsage !== ReflectionProbeUsage.Off) {
780
- if (!this.probeAnchor) return;
781
787
  // update the reflection probe right before rendering
782
788
  // if we do it immediately the reflection probe might not be enabled yet
783
789
  // (since this method is called from onEnable)
784
790
  this.startCoroutine(this._updateReflectionProbe(), FrameEvent.LateUpdate);
791
+
792
+ this._probeAnchorLastFrame = this.probeAnchor;
785
793
  }
786
794
  }
787
795
  private *_updateReflectionProbe() {
src/engine-components/SceneSwitcher.ts CHANGED
@@ -111,10 +111,10 @@
111
111
  * The scenes that can be loaded by the SceneSwitcher.
112
112
  */
113
113
  @serializable(AssetReference)
114
- scenes!: AssetReference[];
114
+ scenes: AssetReference[] = [];
115
115
 
116
116
  @serializable(AssetReference)
117
- loadingScene!: AssetReference;
117
+ loadingScene?: AssetReference;
118
118
 
119
119
  /** the url parameter that is set/used to store the currently loaded scene in, set to "" to disable */
120
120
  @serializable()
src/engine-components/SpriteRenderer.ts CHANGED
@@ -289,8 +289,8 @@
289
289
 
290
290
  if (sprite.texture && !mat.wireframe) {
291
291
  let tex = sprite.texture;
292
- // the sprite renderer modifies the textue offset
293
- // so we need to clone the texture
292
+ // the sprite renderer modifies the texture offset and scale
293
+ // so we need to clone the texture
294
294
  // if the same texture is used multiple times
295
295
  if (tex[$spriteTexOwner] !== undefined && tex[$spriteTexOwner] !== this && this.spriteFrames > 1) {
296
296
  tex = sprite!.texture = tex.clone();
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -32,8 +32,10 @@
32
32
  WebGLRenderTarget} from 'three';
33
33
  import * as fflate from 'three/examples/jsm/libs/fflate.module.js';
34
34
 
35
+ import { VERSION } from "../../../engine/engine_constants.js";
35
36
  import type { OffscreenCanvasExt } from '../../../engine/engine_shims.js';
36
37
  import { Progress } from '../../../engine/engine_time_utils.js';
38
+ import { BehaviorExtension } from '../../api.js';
37
39
  import type { IUSDExporterExtension } from './Extension.js';
38
40
  import type { AnimationExtension } from './extensions/Animation.js';
39
41
 
@@ -95,6 +97,7 @@
95
97
  name: string;
96
98
  type?: string; // by default, Xform is used
97
99
  displayName?: string;
100
+ visibility?: "inherited" | "invisible"; // defaults to "inherited" in USD
98
101
  matrix: Matrix4;
99
102
  private _isDynamic: boolean;
100
103
  get isDynamic() { return this._isDynamic; }
@@ -314,7 +317,7 @@
314
317
  findById( uuid: string ) {
315
318
 
316
319
  let found = false;
317
- function search( current ) {
320
+ function search( current: USDObject ) {
318
321
 
319
322
  if ( found ) return;
320
323
  if ( current.uuid === uuid ) {
@@ -328,6 +331,7 @@
328
331
 
329
332
  for ( const child of current.children ) {
330
333
 
334
+ if (!child) continue;
331
335
  const res = search( child );
332
336
  if ( res ) return res;
333
337
 
@@ -342,12 +346,21 @@
342
346
  }
343
347
 
344
348
 
345
- buildHeader( { startTimeCode, endTimeCode } ) {
346
-
349
+ buildHeader( _context: USDZExporterContext ) {
350
+ const animationExtension = _context.extensions?.find( ext => ext?.extensionName === 'animation' ) as AnimationExtension | undefined;
351
+ const behaviorExtension = _context.extensions?.find( ext => ext?.extensionName === 'Behaviour' ) as BehaviorExtension | undefined;
352
+ const startTimeCode = animationExtension?.getStartTimeCode() ?? 0;
353
+ const endTimeCode = animationExtension?.getEndTimeCode() ?? 0;
354
+
347
355
  return `#usda 1.0
348
356
  (
349
357
  customLayerData = {
350
- string creator = "Needle Engine USDZExporter"
358
+ string creator = "Needle Engine ${VERSION}"
359
+ dictionary Needle = {
360
+ bool animations = ${animationExtension ? 1 : 0}
361
+ bool interactive = ${behaviorExtension ? 1 : 0}
362
+ bool quickLookCompatible = ${_context.quickLookCompatible ? 1 : 0}
363
+ }
351
364
  }
352
365
  defaultPrim = "${makeNameSafe( this.name )}"
353
366
  metersPerUnit = 1
@@ -356,6 +369,7 @@
356
369
  endTimeCode = ${endTimeCode}
357
370
  timeCodesPerSecond = 60
358
371
  framesPerSecond = 60
372
+ doc = """Generated by Needle Engine USDZ Exporter ${VERSION}"""
359
373
  )
360
374
  `;
361
375
 
@@ -460,6 +474,7 @@
460
474
  exporter: USDZExporter;
461
475
  extensions: Array<IUSDExporterExtension> = [];
462
476
  quickLookCompatible: boolean;
477
+ exportInvisible: boolean;
463
478
  materials: Map<string, Material>;
464
479
  textures: TextureMap;
465
480
  files: { [path: string]: Uint8Array | [Uint8Array, fflate.ZipOptions] | null | any }
@@ -467,14 +482,19 @@
467
482
  output: string;
468
483
  animations: AnimationClip[];
469
484
 
470
- constructor( root: Object3D | undefined, exporter: USDZExporter, extensions, quickLookCompatible ) {
485
+ constructor( root: Object3D | undefined, exporter: USDZExporter, options: {
486
+ extensions?: Array<IUSDExporterExtension>,
487
+ quickLookCompatible: boolean,
488
+ exportInvisible: boolean,
489
+ } ) {
471
490
 
472
491
  this.root = root;
473
492
  this.exporter = exporter;
474
- this.quickLookCompatible = quickLookCompatible;
493
+ this.quickLookCompatible = options.quickLookCompatible;
494
+ this.exportInvisible = options.exportInvisible;
475
495
 
476
- if ( extensions )
477
- this.extensions = extensions;
496
+ if ( options.extensions )
497
+ this.extensions = options.extensions;
478
498
 
479
499
  this.materials = new Map();
480
500
  this.textures = {};
@@ -503,6 +523,7 @@
503
523
  quickLookCompatible: boolean = false;
504
524
  extensions: any[] = [];
505
525
  maxTextureSize: number = 4096;
526
+ exportInvisible: boolean = false;
506
527
  }
507
528
 
508
529
  class USDZExporter {
@@ -522,7 +543,7 @@
522
543
  options = Object.assign( new USDZExporterOptions(), options );
523
544
 
524
545
  this.sceneAnchoringOptions = options;
525
- const context = new USDZExporterContext( scene, this, options.extensions, options.quickLookCompatible );
546
+ const context = new USDZExporterContext( scene, this, options );
526
547
  this.extensions = context.extensions;
527
548
 
528
549
  const files = context.files;
@@ -541,7 +562,9 @@
541
562
  Progress.report('export-usdz', "Reparent bones to common ancestor");
542
563
  // HACK let's find all skeletons and reparent them to their skelroot / armature / uppermost bone parent
543
564
  const reparentings: Array<{ object: Object3D, originalParent: Object3D | null, newParent: Object3D }> = [];
544
- scene?.traverseVisible(object => {
565
+ scene?.traverse(object => {
566
+ if (!options.exportInvisible && !object.visible) return;
567
+
545
568
  if (object instanceof SkinnedMesh) {
546
569
  const bones = object.skeleton.bones as Bone[];
547
570
 
@@ -559,7 +582,7 @@
559
582
  }
560
583
 
561
584
  Progress.report('export-usdz', "Traversing hierarchy");
562
- if(scene) traverseVisible( scene, context.document, context, this.keepObject);
585
+ if(scene) traverse( scene, context.document, context, this.keepObject);
563
586
 
564
587
  Progress.report('export-usdz', "Invoking onAfterBuildDocument");
565
588
  await invokeAll( context, 'onAfterBuildDocument' );
@@ -585,12 +608,8 @@
585
608
 
586
609
  // Moved into parseDocument callback for proper defaultPrim encapsulation
587
610
  // context.output += buildMaterials( materials, textures, options.quickLookCompatible );
588
-
589
- const animationExtension: AnimationExtension | undefined = context.extensions.find( ext => ext.extensionName === 'animation' ) as AnimationExtension;
590
- const startTimeCode = animationExtension?.getStartTimeCode() ?? 0;
591
- const endTimeCode = animationExtension?.getEndTimeCode() ?? 0;
592
611
 
593
- const header = context.document.buildHeader( { startTimeCode, endTimeCode } );
612
+ const header = context.document.buildHeader( context );
594
613
  const final = header + '\n' + context.output;
595
614
 
596
615
  // full output file
@@ -690,10 +709,10 @@
690
709
 
691
710
  }
692
711
 
693
- function traverseVisible( object: Object3D, parentModel: USDObject, context: USDZExporterContext, keepObject?: (object: Object3D) => boolean ) {
712
+ function traverse( object: Object3D, parentModel: USDObject, context: USDZExporterContext, keepObject?: (object: Object3D) => boolean ) {
694
713
 
695
- if ( ! object.visible ) return;
696
-
714
+ if (!context.exportInvisible && !object.visible) return;
715
+
697
716
  let model: USDObject | undefined = undefined;
698
717
  let geometry: BufferGeometry | undefined = undefined;
699
718
  let material: Material | Material[] | undefined = undefined;
@@ -703,6 +722,8 @@
703
722
  material = object.material;
704
723
  }
705
724
 
725
+ // API for an explicit choice to discard this object – for example, a geometry that should not be exported,
726
+ // but childs should still be exported.
706
727
  if (keepObject && !keepObject(object)) {
707
728
  geometry = undefined;
708
729
  material = undefined;
@@ -729,6 +750,7 @@
729
750
  if ( model ) {
730
751
 
731
752
  model.displayName = object.name;
753
+ model.visibility = object.visible ? undefined : "invisible";
732
754
 
733
755
  if ( parentModel ) {
734
756
 
@@ -764,7 +786,7 @@
764
786
 
765
787
  for ( const ch of object.children ) {
766
788
 
767
- traverseVisible( ch, parentModel, context, keepObject );
789
+ traverse( ch, parentModel, context, keepObject );
768
790
 
769
791
  }
770
792
 
@@ -858,7 +880,7 @@
858
880
  if ( ! ( geometryFileName in context.files ) ) {
859
881
 
860
882
  const action = () => {
861
- const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones );
883
+ const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones, context.quickLookCompatible );
862
884
  context.files[ geometryFileName ] = buildUSDFileAsString( meshObject, context);
863
885
  };
864
886
 
@@ -1208,6 +1230,7 @@
1208
1230
  const objType = isSkinnedMesh ? 'SkelRoot' : 'Xform';
1209
1231
  const apiSchemas = isSkinnedMesh ? '"MaterialBindingAPI", "SkelBindingAPI"' : '"MaterialBindingAPI"';
1210
1232
 
1233
+ writer.appendLine();
1211
1234
  if ( geometry ) {
1212
1235
  writer.beginBlock( `def ${objType} "${name}"`, "(", false );
1213
1236
  // NE-4084: To use the doubleSided workaround with skeletal meshes we'd have to
@@ -1257,6 +1280,9 @@
1257
1280
  if (model.type === undefined)
1258
1281
  writer.appendLine( 'uniform token[] xformOpOrder = ["xformOp:transform"]' );
1259
1282
 
1283
+ if (model.visibility !== undefined)
1284
+ writer.appendLine(`token visibility = "${model.visibility}"`);
1285
+
1260
1286
  if ( camera ) {
1261
1287
 
1262
1288
  if ( 'isOrthographicCamera' in camera && camera.isOrthographicCamera ) {
@@ -1321,9 +1347,9 @@
1321
1347
 
1322
1348
  // Mesh
1323
1349
 
1324
- function buildMeshObject( geometry: BufferGeometry, bonesArray: Bone[] = [] ) {
1350
+ function buildMeshObject( geometry: BufferGeometry, bonesArray: Bone[] = [], quickLookCompatible: boolean = true) {
1325
1351
 
1326
- const mesh = buildMesh( geometry, bonesArray );
1352
+ const mesh = buildMesh( geometry, bonesArray, quickLookCompatible );
1327
1353
  return `
1328
1354
  def "Geometry"
1329
1355
  ${mesh}
@@ -1331,7 +1357,7 @@
1331
1357
 
1332
1358
  }
1333
1359
 
1334
- function buildMesh( geometry, bones: Bone[] = [] ) {
1360
+ function buildMesh( geometry, bones: Bone[] = [], quickLookCompatible: boolean = true) {
1335
1361
 
1336
1362
  const name = 'Geometry';
1337
1363
  const attributes = geometry.attributes;
@@ -1348,9 +1374,24 @@
1348
1374
  let sortedSkinIndexAttribute: BufferAttribute | null = attributes.skinIndex;
1349
1375
  let bonesArray = "";
1350
1376
  if (hasBones) {
1377
+ const uuidsFound:string[] = [];
1378
+ for(const bone of bones ){
1379
+ if (bone.parent!.type !== 'Bone'){
1380
+ sortedBones.push({bone: bone, index: bones.indexOf(bone)});
1381
+ uuidsFound.push(bone.uuid);
1382
+ }
1383
+ }
1351
1384
 
1352
- for ( const index in bones ) {
1353
- sortedBones.push( { bone: bones[index], index: parseInt(index) } );
1385
+ while (uuidsFound.length < bones.length){
1386
+ for(const sortedBone of sortedBones){
1387
+ const children = sortedBone.bone.children as Bone[];
1388
+ for (const childBone of children){
1389
+ if (uuidsFound.indexOf(childBone.uuid) === -1 && bones.indexOf(childBone) !== -1){
1390
+ sortedBones.push({bone: childBone, index: bones.indexOf(childBone)});
1391
+ uuidsFound.push(childBone.uuid);
1392
+ }
1393
+ }
1394
+ }
1354
1395
  }
1355
1396
 
1356
1397
  // add structural nodes to the list of bones
@@ -1359,7 +1400,7 @@
1359
1400
  }
1360
1401
 
1361
1402
  // sort bones by path – need to be sorted in the same order as during mesh export
1362
- const assumedRoot = bones[0].parent!;
1403
+ const assumedRoot = sortedBones[0].bone.parent!;
1363
1404
  sortedBones.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
1364
1405
  bonesArray = sortedBones.map( x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"" ).join( ', ' );
1365
1406
 
@@ -1398,9 +1439,10 @@
1398
1439
  {
1399
1440
  int[] faceVertexCounts = [${buildMeshVertexCount( geometry )}]
1400
1441
  int[] faceVertexIndices = [${buildMeshVertexIndices( geometry )}]
1401
- normal3f[] normals = [${buildVector3Array( attributes.normal, count )}] (
1442
+ ${attributes.normal || quickLookCompatible ? // in QuickLook, normals are required, otherwise double-sided rendering doesn't work.
1443
+ `normal3f[] normals = [${buildVector3Array( attributes.normal, count )}] (
1402
1444
  interpolation = "vertex"
1403
- )
1445
+ )` : '' }
1404
1446
  point3f[] points = [${buildVector3Array( attributes.position, count )}]
1405
1447
  ${attributes.uv ?
1406
1448
  `texCoord2f[] primvars:st = [${buildVector2Array( attributes.uv, count )}] (
@@ -1431,7 +1473,7 @@
1431
1473
  uniform token subdivisionScheme = "none"
1432
1474
  }
1433
1475
  }
1434
-
1476
+ ${quickLookCompatible ? `
1435
1477
  # This is a workaround for QuickLook/RealityKit not supporting the doubleSided attribute. We're adding a second
1436
1478
  # geometry definition here, that uses the same mesh data but appends extra faces with reversed winding order.
1437
1479
  def "${name}_doubleSided" (
@@ -1444,8 +1486,8 @@
1444
1486
  int[] faceVertexIndices = [${buildMeshVertexIndices( geometry ) + ", " + buildMeshVertexIndices( geometry, true )}]
1445
1487
  }
1446
1488
  }
1489
+ ` : '' }
1447
1490
  `;
1448
-
1449
1491
  }
1450
1492
 
1451
1493
  function buildMeshVertexCount( geometry ) {
@@ -1495,8 +1537,8 @@
1495
1537
 
1496
1538
  if ( attribute === undefined ) {
1497
1539
 
1498
- console.warn( 'USDZExporter: Normals missing.' );
1499
- return Array( count ).fill( '(0, 0, 0)' ).join( ', ' );
1540
+ console.warn( 'USDZExporter: Attribute is missing. Results may be undefined.' );
1541
+ return Array( count ).fill( '(0, 0, 1)' ).join( ', ' );
1500
1542
 
1501
1543
  }
1502
1544
 
@@ -1575,13 +1617,11 @@
1575
1617
 
1576
1618
  }
1577
1619
 
1578
- return `def "Materials"
1620
+ return `
1621
+ def "Materials"
1579
1622
  {
1580
1623
  ${array.join( '' )}
1581
- }
1582
-
1583
- `;
1584
-
1624
+ }`;
1585
1625
  }
1586
1626
 
1587
1627
  /** Slot of the exported texture. Some slots (e.g. normal, opacity) require additional processing. */
@@ -1595,9 +1635,12 @@
1595
1635
  const inputs: Array<string> = [];
1596
1636
  const samplers: Array<string> = [];
1597
1637
  const materialName = getMaterialName(material);
1638
+ const usedUVChannels: Set<number> = new Set();
1598
1639
 
1599
1640
  function texName(tex: Texture) {
1600
- return makeNameSafe(tex.name) + '_' + tex.id;
1641
+ // If we have a source, we only want to use the source's id, not the texture's id
1642
+ // to avoid exporting the same underlying data multiple times.
1643
+ return makeNameSafe(tex.name) + '_' + (tex.source?.id ?? tex.id);
1601
1644
  }
1602
1645
 
1603
1646
  function buildTexture( texture: Texture, mapType: MapType, color: Color | undefined = undefined, opacity: number | undefined = undefined ) {
@@ -1628,6 +1671,7 @@
1628
1671
  textures[ id ] = { texture, scale: scaleToApply };
1629
1672
 
1630
1673
  const uv = texture.channel > 0 ? 'st' + texture.channel : 'st';
1674
+ usedUVChannels.add(texture.channel);
1631
1675
 
1632
1676
  const isRGBA = formatsWithAlphaChannel.includes( texture.format );
1633
1677
 
@@ -1655,6 +1699,10 @@
1655
1699
  // This is NOT correct yet in QuickLook, but comes close for a range of models.
1656
1700
  // It becomes more incorrect the bigger the offset is
1657
1701
 
1702
+ // sanitize repeat values to avoid NaNs
1703
+ if (repeat.x === 0) repeat.x = 0.0001;
1704
+ if (repeat.y === 0) repeat.y = 0.0001;
1705
+
1658
1706
  offset.x = offset.x / repeat.x;
1659
1707
  offset.y = offset.y / repeat.y;
1660
1708
 
@@ -1681,42 +1729,41 @@
1681
1729
  const normalBiasZString = (1 - normalScale).toFixed( PRECISION );
1682
1730
 
1683
1731
  return `
1684
- ${needsTextureTransform ? `def Shader "Transform2d_${mapType}" (
1685
- sdrMetadata = {
1686
- string role = "math"
1687
- }
1688
- )
1689
- {
1690
- uniform token info:id = "UsdTransform2d"
1691
- float2 inputs:in.connect = ${textureTransformInput}
1692
- float2 inputs:scale = ${buildVector2( repeat )}
1693
- float2 inputs:translation = ${buildVector2( offset )}
1694
- float inputs:rotation = ${(rotation / Math.PI * 180).toFixed( PRECISION )}
1695
- float2 outputs:result
1696
- }
1697
- ` : '' }
1698
- def Shader "${name}_${mapType}"
1699
- {
1700
- uniform token info:id = "UsdUVTexture"
1701
- asset inputs:file = @textures/${id}.${isRGBA ? 'png' : 'jpg'}@
1702
- token inputs:sourceColorSpace = "${ texture.colorSpace === 'srgb' ? 'sRGB' : 'raw' }"
1703
- float2 inputs:st.connect = ${needsTextureTransform ? textureTransformOutput : textureTransformInput}
1704
- ${needsTextureScale ? `
1705
- float4 inputs:scale = (${color ? color.r + ', ' + color.g + ', ' + color.b : '1, 1, 1'}, ${opacity})
1706
- ` : `` }
1707
- ${needsNormalScaleAndBias ? `
1708
- float4 inputs:scale = (${normalScaleValueString}, ${normalScaleValueString}, ${normalScaleValueString}, 1)
1709
- float4 inputs:bias = (${normalBiasString}, ${normalBiasString}, ${normalBiasZString}, 0)
1710
- ` : `` }
1711
- token inputs:wrapS = "${ WRAPPINGS[ texture.wrapS ] }"
1712
- token inputs:wrapT = "${ WRAPPINGS[ texture.wrapT ] }"
1713
- float outputs:r
1714
- float outputs:g
1715
- float outputs:b
1716
- float3 outputs:rgb
1717
- ${material.transparent || material.alphaTest > 0.0 ? 'float outputs:a' : ''}
1718
- }`;
1719
-
1732
+ ${needsTextureTransform ? `def Shader "Transform2d_${mapType}" (
1733
+ sdrMetadata = {
1734
+ string role = "math"
1735
+ }
1736
+ )
1737
+ {
1738
+ uniform token info:id = "UsdTransform2d"
1739
+ float2 inputs:in.connect = ${textureTransformInput}
1740
+ float2 inputs:scale = ${buildVector2( repeat )}
1741
+ float2 inputs:translation = ${buildVector2( offset )}
1742
+ float inputs:rotation = ${(rotation / Math.PI * 180).toFixed( PRECISION )}
1743
+ float2 outputs:result
1744
+ }
1745
+ ` : '' }
1746
+ def Shader "${name}_${mapType}"
1747
+ {
1748
+ uniform token info:id = "UsdUVTexture"
1749
+ asset inputs:file = @textures/${id}.${isRGBA ? 'png' : 'jpg'}@
1750
+ token inputs:sourceColorSpace = "${ texture.colorSpace === 'srgb' ? 'sRGB' : 'raw' }"
1751
+ float2 inputs:st.connect = ${needsTextureTransform ? textureTransformOutput : textureTransformInput}
1752
+ ${needsTextureScale ? `
1753
+ float4 inputs:scale = (${color ? color.r + ', ' + color.g + ', ' + color.b : '1, 1, 1'}, ${opacity})
1754
+ ` : `` }
1755
+ ${needsNormalScaleAndBias ? `
1756
+ float4 inputs:scale = (${normalScaleValueString}, ${normalScaleValueString}, ${normalScaleValueString}, 1)
1757
+ float4 inputs:bias = (${normalBiasString}, ${normalBiasString}, ${normalBiasZString}, 0)
1758
+ ` : `` }
1759
+ token inputs:wrapS = "${ WRAPPINGS[ texture.wrapS ] }"
1760
+ token inputs:wrapT = "${ WRAPPINGS[ texture.wrapT ] }"
1761
+ float outputs:r
1762
+ float outputs:g
1763
+ float outputs:b
1764
+ float3 outputs:rgb
1765
+ ${material.transparent || material.alphaTest > 0.0 ? 'float outputs:a' : ''}
1766
+ }`;
1720
1767
  }
1721
1768
 
1722
1769
  let effectiveOpacity = ( material.transparent || material.alphaTest ) ? material.opacity : 1;
@@ -1734,7 +1781,16 @@
1734
1781
 
1735
1782
  inputs.push( `${pad}color3f inputs:diffuseColor.connect = ${materialRoot}/${materialName}/${texName(material.map)}_diffuse.outputs:rgb>` );
1736
1783
 
1737
- if ( material.transparent ) {
1784
+ // Enforce alpha hashing in QuickLook for unlit materials
1785
+ if (material instanceof MeshBasicMaterial && material.transparent && material.alphaTest == 0.0 && quickLookCompatible) {
1786
+ inputs.push( `${pad}float inputs:opacity.connect = ${materialRoot}/${materialName}/${texName(material.map)}_diffuse.outputs:a>` );
1787
+ haveConnectedOpacity = true;
1788
+ // see below – QuickLook applies alpha hashing instead of pure blending when
1789
+ // both opacity and opacityThreshold are connected
1790
+ inputs.push( `${pad}float inputs:opacityThreshold = ${0.0000000001}` );
1791
+ haveConnectedOpacityThreshold = true;
1792
+ }
1793
+ else if ( material.transparent ) {
1738
1794
 
1739
1795
  inputs.push( `${pad}float inputs:opacity.connect = ${materialRoot}/${materialName}/${texName(material.map)}_diffuse.outputs:a>` );
1740
1796
  haveConnectedOpacity = true;
@@ -1756,7 +1812,7 @@
1756
1812
 
1757
1813
  }
1758
1814
 
1759
- if ( material.alphaHash ) {
1815
+ if ( material.alphaHash && quickLookCompatible) {
1760
1816
 
1761
1817
  // Seems we can do this to basically enforce alpha hashing / dithered transparency in QuickLook –
1762
1818
  // works completely different in usdview though...
@@ -1880,21 +1936,34 @@
1880
1936
 
1881
1937
  }
1882
1938
 
1939
+ // check if usedUVChannels contains any data besides exactly 0 or 1
1940
+ if (usedUVChannels.size > 2) {
1941
+ console.warn('USDZExporter: Material ' + material.name + ' uses more than 2 UV channels. Currently, only UV0 and UV1 are supported.');
1942
+ }
1943
+ else if (usedUVChannels.size === 2) {
1944
+ // check if it's exactly 0 and 1
1945
+ if (!usedUVChannels.has(0) || !usedUVChannels.has(1)) {
1946
+ console.warn('USDZExporter: Material ' + material.name + ' uses UV channels other than 0 and 1. Currently, only UV0 and UV1 are supported.');
1947
+ }
1948
+ }
1949
+
1883
1950
  return `
1884
- def Material "${materialName}" (
1885
- ${material.name ? `displayName = "${material.name}"` : ''}
1886
- )
1951
+
1952
+ def Material "${materialName}" ${material.name ?`(
1953
+ displayName = "${material.name}"
1954
+ )` : ''}
1887
1955
  {
1956
+ token outputs:surface.connect = ${materialRoot}/${materialName}/PreviewSurface.outputs:surface>
1957
+
1888
1958
  def Shader "PreviewSurface"
1889
1959
  {
1890
1960
  uniform token info:id = "UsdPreviewSurface"
1891
1961
  ${inputs.join( '\n' )}
1892
- int inputs:useSpecularWorkflow = 0
1962
+ int inputs:useSpecularWorkflow = ${material instanceof MeshBasicMaterial ? '1' : '0'}
1893
1963
  token outputs:surface
1894
1964
  }
1895
-
1896
- token outputs:surface.connect = ${materialRoot}/${materialName}/PreviewSurface.outputs:surface>
1897
-
1965
+ ${samplers.length > 0 ? `
1966
+ ${usedUVChannels.has(0) ? `
1898
1967
  def Shader "uvReader_st"
1899
1968
  {
1900
1969
  uniform token info:id = "UsdPrimvarReader_float2"
@@ -1902,7 +1971,8 @@
1902
1971
  float2 inputs:fallback = (0.0, 0.0)
1903
1972
  float2 outputs:result
1904
1973
  }
1905
-
1974
+ ` : ''}
1975
+ ${usedUVChannels.has(1) ? `
1906
1976
  def Shader "uvReader_st2"
1907
1977
  {
1908
1978
  uniform token info:id = "UsdPrimvarReader_float2"
@@ -1910,12 +1980,9 @@
1910
1980
  float2 inputs:fallback = (0.0, 0.0)
1911
1981
  float2 outputs:result
1912
1982
  }
1913
-
1914
- ${samplers.join( '\n' )}
1915
-
1916
- }
1917
- `;
1918
-
1983
+ ` : ''}
1984
+ ${samplers.join( '\n' )}` : ''}
1985
+ }`;
1919
1986
  }
1920
1987
 
1921
1988
  function buildColor( color ) {
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { WebARSessionRoot } from "../../webxr/WebARSessionRoot.js";
12
12
  import { WebXR } from "../../webxr/WebXR.js";
13
13
  import { WebXRButtonFactory } from "../../webxr/WebXRButtons.js";
14
- import { XRFlag, XRState, XRStateFlag } from "../../webxr/XRFlag.js";
14
+ import { XRState, XRStateFlag } from "../../webxr/XRFlag.js";
15
15
  import type { IUSDExporterExtension } from "./Extension.js";
16
16
  import { AnimationExtension } from "./extensions/Animation.js"
17
17
  import { AudioExtension } from "./extensions/behavior/AudioExtension.js";
@@ -20,7 +20,7 @@
20
20
  import { TextExtension } from "./extensions/USDZText.js";
21
21
  import { USDZUIExtension } from "./extensions/USDZUI.js";
22
22
  import { USDZExporter as ThreeUSDZExporter } from "./ThreeUSDZExporter.js";
23
- import { registerAnimatorsImplictly, registerAudioSourcesImplictly } from "./utils/animationutils.js";
23
+ import { disableObjectsAtStart, registerAnimatorsImplictly, registerAudioSourcesImplictly } from "./utils/animationutils.js";
24
24
  import { ensureQuicklookLinkIsCreated } from "./utils/quicklook.js";
25
25
 
26
26
  const debug = getParam("debugusdz");
@@ -337,15 +337,15 @@
337
337
  XRState.Global.Set(XRStateFlag.AR);
338
338
 
339
339
  const exporter = new ThreeUSDZExporter();
340
- const extensions: any = [...this.extensions]
341
-
342
- // collect animators and their clips
340
+ // We're creating a new animation extension on each export to avoid issues with multiple exports.
341
+ // TODO we probably want to do that with all the extensions...
342
+ // Ordering of extensions is important
343
343
  const animExt = new AnimationExtension(this.quickLookCompatible);
344
- extensions.push(animExt);
344
+ const extensions: any = [animExt, ...this.extensions]
345
345
 
346
346
  const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport };
347
347
  Progress.report("export-usdz", "Invoking before-export");
348
- this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs }))
348
+ this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs }));
349
349
 
350
350
  // make sure we apply the AR scale
351
351
  this.applyWebARSessionRoot();
@@ -369,14 +369,40 @@
369
369
  //@ts-ignore
370
370
  exporter.debug = debug;
371
371
  exporter.keepObject = (object) => {
372
- // TODO We need to take more care with disabled renderers. This currently breaks when any renderer is disabled
373
- // and then enabled at runtime by e.g. SetActiveOnClick, requiring extra work to enable them before export,
374
- // cache their state, and then reset their state after export.
372
+ // This explicitly removes geometry and material data from disabled renderers.
373
+ // Note that this is different to the object itself being active –
374
+ // here, we have an active object with a disabled renderer.
375
375
  const renderer = GameObject.getComponent(object, Renderer)
376
376
  if (renderer && !renderer.enabled) return false;
377
377
  return true;
378
378
  }
379
379
 
380
+ // Collect invisible objects so that we can disable them if
381
+ // - we're exporting for QuickLook
382
+ // - and interactive behaviors are allowed.
383
+ // When exporting for regular USD, we're supporting the "visibility" attribute anyways.
384
+ const objectsToDisableAtSceneStart = new Array<Object3D>();
385
+ if (this.objectToExport && this.quickLookCompatible && this.interactive) {
386
+ this.objectToExport.traverse((obj) => {
387
+ if (!obj.visible) {
388
+ objectsToDisableAtSceneStart.push(obj);
389
+ }
390
+ });
391
+ }
392
+
393
+ const behaviorExt = this.extensions.find(ext => ext.extensionName === "Behaviour");
394
+ if (this.interactive && behaviorExt) {
395
+ //@ts-ignore
396
+ behaviorExt.addBehavior(disableObjectsAtStart(objectsToDisableAtSceneStart));
397
+ }
398
+
399
+ let exportInvisible = true;
400
+ // The only case where we want to strip out invisible objects is
401
+ // when we're exporting for QuickLook and we're NOT adding interactive behaviors,
402
+ // since QuickLook on iOS does not support "visibility" tokens.
403
+ if (this.quickLookCompatible && !this.interactive)
404
+ exportInvisible = false;
405
+
380
406
  // sanitize anchoring types
381
407
  if (this.anchoringType !== "plane" && this.anchoringType !== "none" && this.anchoringType !== "image" && this.anchoringType !== "face")
382
408
  this.anchoringType = "plane";
@@ -384,6 +410,7 @@
384
410
  this.planeAnchoringAlignment = "horizontal";
385
411
 
386
412
  Progress.report("export-usdz", "Invoking exporter.parse");
413
+
387
414
  //@ts-ignore
388
415
  const arraybuffer = await exporter.parse(this.objectToExport, {
389
416
  ar: {
@@ -397,6 +424,7 @@
397
424
  extensions: extensions,
398
425
  quickLookCompatible: this.quickLookCompatible,
399
426
  maxTextureSize: this.maxTextureSize,
427
+ exportInvisible: exportInvisible,
400
428
  });
401
429
 
402
430
  const blob = new Blob([arraybuffer], { type: 'model/vnd.usdz+zip' });
src/engine-components/export/usdz/extensions/USDZText.ts CHANGED
@@ -148,54 +148,6 @@
148
148
  return "text";
149
149
  }
150
150
 
151
- // HACK we should clean this up, text export has moved to USDZUI.ts and is
152
- // integrated into the hierarchy now
153
- onExportObject(_object: Object3D, _model: USDObject, _context: USDZExporterContext) {
154
-
155
- return;
156
-
157
- /*
158
- const text = GameObject.getComponent(object, Text);
159
- if (text) {
160
- const rt = GameObject.getComponent(object, RectTransform);
161
- let width = 100;
162
- let height = 100;
163
- if (rt) {
164
- width = rt.width;
165
- height = rt.height;
166
-
167
- // Take the matrix from the three mesh ui object:
168
- const mat = rt.shadowComponent!.matrix;
169
- model.matrix.copy(mat);
170
- // model.matrix.premultiply(rotateYAxisMatrix);
171
- model.matrix.premultiply(invertX);
172
- }
173
- const newModel = model.clone();
174
- newModel.matrix = rotateYAxisMatrix.clone();
175
- if (rt) // Not ideal but works for now:
176
- newModel.matrix.premultiply(invertX);
177
-
178
- const color = new Color().copySRGBToLinear(text.color);
179
- newModel.material = new MeshStandardMaterial({ color: color, emissive: color });
180
- model.add(newModel);
181
-
182
- // model.matrix.scale(new Vector3(100, 100, 100));
183
- newModel.addEventListener("serialize", (writer: USDWriter, _context: USDZExporterContext) => {
184
- let txt = text.text;
185
- txt = txt.replace(/\n/g, "\\n");
186
- const textObj = TextBuilder.multiLine(txt, width, height, HorizontalAlignment.center, VerticalAlignment.bottom, TextWrapMode.flowing);
187
- this.setTextAlignment(textObj, text.alignment);
188
- this.setOverflow(textObj, text);
189
- if (newModel.material)
190
- textObj.material = newModel.material;
191
- textObj.pointSize = this.convertToTextSize(text.fontSize);
192
- textObj.depth = .001;
193
- textObj.writeTo(undefined, writer);
194
- });
195
- }
196
- */
197
- }
198
-
199
151
  exportText(object: Object3D, newModel: USDObject, _context: USDZExporterContext) {
200
152
 
201
153
  const text = GameObject.getComponent(object, Text);
src/engine-components/export/usdz/extensions/USDZUI.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Color, Mesh, MeshBasicMaterial, Object3D } from "three";
2
2
 
3
3
  import { GameObject } from "../../../Component.js";
4
- import { $shadowDomOwner } from "../../../ui/BaseUIComponent.js";
4
+ import { $shadowDomOwner, BaseUIComponent } from "../../../ui/BaseUIComponent.js";
5
5
  import { Canvas } from "../../../ui/Canvas.js";
6
6
  import { RenderMode } from "../../../ui/Canvas.js";
7
7
  import { CanvasGroup } from "../../../ui/CanvasGroup.js";
8
8
  import { RectTransform } from "../../../ui/RectTransform.js";
9
+ import { Text } from "../../../ui/Text.js";
9
10
  import type { IUSDExporterExtension } from "../Extension.js";
10
11
  import { USDObject, USDZExporterContext } from "../ThreeUSDZExporter.js";
11
12
  import { TextExtension } from "./USDZText.js";
@@ -20,14 +21,73 @@
20
21
  onExportObject(object: Object3D, model: USDObject, _context: USDZExporterContext) {
21
22
  const canvas = GameObject.getComponent(object, Canvas);
22
23
 
23
- if (canvas && canvas.activeAndEnabled && canvas.renderMode === RenderMode.WorldSpace) {
24
+ if (canvas && canvas.enabled && canvas.renderMode === RenderMode.WorldSpace) {
25
+
24
26
  const textExt = new TextExtension();
25
27
  const rt = GameObject.getComponent(object, RectTransform);
26
28
  const canvasGroup = GameObject.getComponent(object, CanvasGroup);
27
29
 
30
+ // we have to do some temporary changes (enable UI component so that they're building
31
+ // their shadow hierarchy, then revert them back to the original state)
32
+ const revertActions = new Array<() => void>();
33
+
28
34
  let width = 100;
29
35
  let height = 100;
30
36
  if (rt) {
37
+
38
+ // Workaround: since UI components are only present in the shadow hierarchy when objects are on,
39
+ // we need to enable them temporarily to export them including their potential child components.
40
+ // For example, Text can have multiple child objects (e.g. rich text, panels, ...)
41
+ if (!GameObject.isActiveSelf(object)) {
42
+ const wasActive = GameObject.isActiveSelf(object);
43
+ GameObject.setActive(object, true);
44
+ rt.onEnable();
45
+ rt.updateTransform();
46
+
47
+ revertActions.push(() => {
48
+ rt.onDisable();
49
+ GameObject.setActive(object, wasActive);
50
+ });
51
+ }
52
+
53
+ object.traverse((child) => {
54
+ if (!GameObject.isActiveInHierarchy(child)) {
55
+ const wasActive = GameObject.isActiveSelf(child);
56
+ GameObject.setActive(child, true);
57
+ const baseUIComponent = GameObject.getComponent(child, BaseUIComponent);
58
+ if (baseUIComponent) {
59
+ baseUIComponent.onEnable();
60
+ revertActions.push(() => {
61
+ baseUIComponent.onDisable();
62
+ });
63
+ }
64
+
65
+ const rectTransform = GameObject.getComponent(child, RectTransform);
66
+ if (rectTransform) {
67
+ rectTransform.onEnable();
68
+ rectTransform.updateTransform();
69
+ // This method bypasses the checks for whether the object is enabled etc.
70
+ // so that we can ensure even a disabled object has the correct layout.
71
+ rectTransform["onApplyTransform"]();
72
+ revertActions.push(() => {
73
+ rectTransform.onDisable();
74
+ });
75
+ }
76
+
77
+ const text = GameObject.getComponent(child, Text);
78
+ if (text) {
79
+ text.onEnable();
80
+ revertActions.push(() => {
81
+ text.onDisable();
82
+ });
83
+ }
84
+
85
+ revertActions.push(() => {
86
+ GameObject.setActive(child, wasActive);
87
+ });
88
+ }
89
+ });
90
+
31
91
  width = rt.width;
32
92
  height = rt.height;
33
93
 
@@ -38,15 +98,14 @@
38
98
  if (shadowComponent) {
39
99
  const mat = shadowComponent.matrix;
40
100
  shadowRootModel.matrix.copy(mat);
41
- // shadowRootModel.matrix.premultiply(invertX);
42
101
 
43
- // TODO build map of parent GOs to USDObjects so we can reparent while traversing
44
102
  const usdObjectMap = new Map<Object3D, USDObject>();
45
103
  const opacityMap = new Map<Object3D, number>();
46
104
  usdObjectMap.set(shadowComponent, shadowRootModel);
47
105
  opacityMap.set(shadowComponent, canvasGroup ? canvasGroup.alpha : 1);
48
106
 
49
107
  shadowComponent.traverse((child) => {
108
+ // console.log("traversing UI shadow components", shadowComponent.name + " ->" + child.name);
50
109
  if (child === shadowComponent) return;
51
110
 
52
111
  const childModel = USDObject.createEmpty();
@@ -109,6 +168,11 @@
109
168
  });
110
169
  }
111
170
  }
171
+
172
+ // revert temporary changes that we did here
173
+ for (const revert of revertActions) {
174
+ revert();
175
+ }
112
176
  }
113
177
  }