Needle Engine

Changes between version 3.32.23-alpha and 3.32.24-alpha
Files changed (52) hide show
  1. src/engine-components/export/usdz/extensions/Animation.ts +41 -5
  2. src/engine-components/export/usdz/utils/animationutils.ts +36 -2
  3. src/engine-components/api.ts +1 -0
  4. src/engine/api.ts +1 -0
  5. src/engine-components/export/usdz/extensions/behavior/AudioExtension.ts +46 -22
  6. src/engine-components/AudioSource.ts +1 -0
  7. src/engine-components/webxr/Avatar.ts +2 -2
  8. src/engine-components/export/usdz/extensions/behavior/Behaviour.ts +1 -1
  9. src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +35 -13
  10. src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts +2 -2
  11. src/engine-components/ui/Canvas.ts +1 -1
  12. src/engine-components/Component.ts +3 -3
  13. src/engine/debug/debug_console.ts +3 -3
  14. src/engine/debug/debug.ts +1 -0
  15. src/engine-components/DragControls.ts +20 -2
  16. src/engine-components/Duplicatable.ts +1 -1
  17. src/engine/engine_addressables.ts +1 -1
  18. src/engine/engine_context.ts +1 -1
  19. src/engine/engine_create_objects.ts +2 -2
  20. src/engine/engine_input.ts +15 -5
  21. src/engine/engine_lifecycle_api.ts +1 -1
  22. src/engine/engine_math.ts +33 -0
  23. src/engine/engine_networking_instantiate.ts +1 -1
  24. src/engine/engine_serialization_builtin_serializer.ts +4 -2
  25. src/engine/engine_serialization_core.ts +1 -1
  26. src/engine/engine_types.ts +1 -0
  27. src/engine-components/ui/EventSystem.ts +5 -5
  28. src/engine/extensions/extensions.ts +2 -2
  29. src/engine/xr/internal.ts +2 -2
  30. src/engine-components/Light.ts +1 -1
  31. src/engine/xr/NeedleXRController.ts +1 -1
  32. src/engine/xr/NeedleXRSession.ts +6 -2
  33. src/engine-components/utils/OpenURL.ts +13 -9
  34. src/engine-components-experimental/networking/PlayerSync.ts +1 -1
  35. src/engine-components/ui/PointerEvents.ts +3 -3
  36. src/engine-components/ui/Raycaster.ts +1 -1
  37. src/engine-components/export/usdz/ThreeUSDZExporter.ts +202 -82
  38. src/engine-components/export/usdz/utils/timeutils.ts +0 -20
  39. src/engine-components/export/usdz/USDZExporter.ts +56 -12
  40. src/engine-components/export/usdz/extensions/USDZText.ts +2 -3
  41. src/engine-components/webxr/WebARCameraBackground.ts +1 -1
  42. src/engine-components/webxr/WebARSessionRoot.ts +53 -19
  43. src/engine-components/webxr/WebXR.ts +1 -1
  44. src/engine-components/webxr/WebXRImageTracking.ts +1 -1
  45. src/engine-components/webxr/WebXRPlaneTracking.ts +1 -1
  46. src/engine-components/webxr/WebXRRig.ts +3 -3
  47. src/engine-components/webxr/controllers/XRControllerFollow.ts +1 -1
  48. src/engine-components/webxr/controllers/XRControllerModel.ts +2 -2
  49. src/engine-components/webxr/controllers/XRControllerMovement.ts +4 -4
  50. src/engine/xr/XRRig.ts +1 -1
  51. src/engine/debug/debug_spatial_console.ts +395 -0
  52. src/engine/engine_time_utils.ts +238 -0
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -49,7 +49,11 @@
49
49
  private duration = 0;
50
50
  private useRootMotion = false;
51
51
 
52
- static animationDurationPadding = 1;
52
+ /** This value can theoretically be anything – a value of 1 is good to clearly see animation gaps.
53
+ * For production, a value of 1/60 is enough, since the files can then still properly play back at 60fps.
54
+ */
55
+ static animationDurationPadding = 1 / 60;
56
+ static restPoseClipDuration = 1 / 60;
53
57
 
54
58
  constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | undefined) {
55
59
  this.root = root;
@@ -59,7 +63,7 @@
59
63
  // this is a rest pose clip.
60
64
  // we assume duration 1/60 and no tracks, and when queried for times we just return [0, duration]
61
65
  if (!clip) {
62
- this.duration = 1 / this.frameRate;
66
+ this.duration = TransformData.restPoseClipDuration;
63
67
  }
64
68
  else {
65
69
  this.duration = clip.duration;
@@ -171,7 +175,6 @@
171
175
 
172
176
  export class AnimationExtension implements IUSDExporterExtension {
173
177
 
174
-
175
178
  get extensionName(): string { return "animation" }
176
179
 
177
180
  private dict: AnimationDict = new Map();
@@ -181,9 +184,9 @@
181
184
 
182
185
  private serializers: SerializeAnimation[] = [];
183
186
 
184
- // determines if we inject a rest pose clip for each root - only makes sense for QuickLook
187
+ /** Determines if we inject a rest pose clip for each root - only makes sense for QuickLook */
185
188
  injectRestPoses = false;
186
- // determines if we inject a PlayAnimationOnClick component with "scenestart" trigger - only makes sense for QuickLook
189
+ /** Determines if we inject a PlayAnimationOnClick component with "scenestart" trigger - only makes sense for QuickLook */
187
190
  injectImplicitBehaviours = false;
188
191
 
189
192
  constructor(quickLookCompatible: boolean) {
@@ -191,6 +194,24 @@
191
194
  this.injectImplicitBehaviours = quickLookCompatible;
192
195
  }
193
196
 
197
+ getStartTimeCode() {
198
+ // return the end time + padding of the rest clip, if any exists
199
+ if (!this.injectRestPoses) return 0;
200
+ if (this.rootAndClipToRegisteredAnimationMap.size === 0) return 0;
201
+ return (TransformData.restPoseClipDuration + TransformData.animationDurationPadding) * 60;
202
+ }
203
+
204
+ /** Returns the end time code, based on 60 frames per second, for all registered animations.
205
+ * This matches the highest time value in the USDZ file. */
206
+ getEndTimeCode() {
207
+ let max = 0;
208
+ for (const [_, info] of this.rootAndClipToRegisteredAnimationMap) {
209
+ const end = info.start + info.duration;
210
+ if (end > max) max = end;
211
+ }
212
+ return max * 60;
213
+ }
214
+
194
215
  getClipCount(root: Object3D): number {
195
216
  // don't count the rest pose
196
217
  let currentCount = this.rootToRegisteredClipCount.get(root);
@@ -642,6 +663,17 @@
642
663
 
643
664
  const timeSampleObjects = createAllTimeSampleObjects ( boneAndInverse.map( x => x.bone ) );
644
665
 
666
+ if (debug) {
667
+ // find the first..last value in the time samples
668
+ let min = 10000000;
669
+ let max = 0;
670
+ for (const key of timeSampleObjects.position?.keys() ?? []) {
671
+ min = Math.min(min, key);
672
+ max = Math.max(max, key);
673
+ }
674
+ console.log("Time samples", min, max, timeSampleObjects);
675
+ }
676
+
645
677
  writer.beginBlock( `def SkelAnimation "_anim"` );
646
678
 
647
679
  // TODO if we include blendshapes we likely need subdivision?
@@ -739,6 +771,10 @@
739
771
  let currentStartTime = 0;
740
772
  for (let i = 0; i < arr.length; i++) {
741
773
  startTimes.push(currentStartTime);
774
+ if (arr[i] === undefined) {
775
+ console.error("Got an undefined transform data, this is likely a bug.", object, arr);
776
+ continue;
777
+ }
742
778
  currentStartTime += arr[i].getDuration() + TransformData.animationDurationPadding;
743
779
  }
744
780
 
src/engine-components/export/usdz/utils/animationutils.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  import { getParam } from "../../../../engine/engine_utils.js";
4
4
  import { Animation } from "../../../Animation.js";
5
5
  import { Animator } from "../../../Animator.js";
6
+ import { AudioSource } from "../../../AudioSource.js";
6
7
  import { Behaviour, GameObject } from "../../../Component.js";
7
- import { AnimationExtension } from "../extensions/Animation.js";
8
- import { PlayAnimationOnClick } from "../extensions/behavior/BehaviourComponents.js";
8
+ import type { AnimationExtension } from "../extensions/Animation.js";
9
+ import type { AudioExtension } from "../extensions/behavior/AudioExtension.js";
10
+ import { PlayAnimationOnClick, PlayAudioOnClick } from "../extensions/behavior/BehaviourComponents.js";
9
11
 
10
12
  const debug = getParam("debugusdz");
11
13
 
@@ -99,4 +101,36 @@
99
101
  }
100
102
 
101
103
  return constructedObjects;
104
+ }
105
+
106
+ export function registerAudioSourcesImplictly(root: Object3D, _ext: AudioExtension): Array<Object3D> {
107
+ const audioSources = GameObject.getComponentsInChildren(root, AudioSource);
108
+ const playAudioOnClicks = GameObject.getComponentsInChildren(root, PlayAudioOnClick);
109
+
110
+ const constructedObjects = new Array<Object3D>();
111
+
112
+ // Remove all audio sources that are already referenced from existing PlayAudioOnClick components
113
+ for (const player of playAudioOnClicks) {
114
+ if (!player.target) continue;
115
+ const index = audioSources.indexOf(player.target);
116
+ if (index > -1) audioSources.splice(index, 1);
117
+ }
118
+
119
+ // for the remaning ones, we want to build a PlayAudioOnClick component
120
+ for (const audioSource of audioSources) {
121
+ if (!audioSource || !audioSource.clip) continue;
122
+ if (audioSource.volume <= 0) continue;
123
+
124
+ const newComponent = new PlayAudioOnClick();
125
+ newComponent.target = audioSource;
126
+ newComponent.name = "PlayAudioOnClick_implicitAtStart_";
127
+ const go = new Object3D();
128
+ GameObject.addComponent(go, newComponent);
129
+ console.log("implicit PlayAudioOnStart", go, newComponent);
130
+ constructedObjects.push(go);
131
+
132
+ root.add(go);
133
+ }
134
+
135
+ return constructedObjects;
102
136
  }
src/engine-components/api.ts CHANGED
@@ -16,5 +16,6 @@
16
16
  import "./CameraUtils.js"
17
17
  import "./AnimationUtils.js"
18
18
 
19
+ export { DragMode } from "./DragControls.js"
19
20
  export { ParticleSystemBaseBehaviour, type QParticle, type QParticleBehaviour } from "./ParticleSystem.js"
20
21
  export { ParticleSystemShapeType } from "./ParticleSystemModules.js"
src/engine/api.ts CHANGED
@@ -43,6 +43,7 @@
43
43
  export * from "./engine_texture.js";
44
44
  export * from "./engine_three_utils.js";
45
45
  export * from "./engine_time.js";
46
+ export * from "./engine_time_utils.js";
46
47
  export * from "./engine_types.js";
47
48
  export { registerType,TypeStore } from "./engine_typestore.js";
48
49
  export { prefix,validate } from "./engine_util_decorator.js";
src/engine-components/export/usdz/extensions/behavior/AudioExtension.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  import { Object3D } from "three";
2
2
 
3
+ import { getParam } from "../../../../../engine/engine_utils.js";
3
4
  import { AudioSource } from "../../../../AudioSource.js";
4
5
  import { GameObject } from "../../../../Component.js";
5
6
  import type { IUSDExporterExtension } from "../../Extension.js";
6
- import { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
7
+ import { makeNameSafeForUSD,USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
7
8
 
9
+ const debug = getParam("debugusdz");
10
+
8
11
  export class AudioExtension implements IUSDExporterExtension {
9
12
 
13
+ static getName(clip: string) {
14
+ const clipExt = clip.split(".").pop();
15
+ const fileWithoutExt = clip.split(".").slice(0, -1).join(".");
16
+ let clipName = fileWithoutExt.split("/").pop()?.replace(".", "_");
17
+ if (!clipName) {
18
+ clipName = "Audio_" + Math.random().toString(36).substring(2, 15);
19
+ }
20
+ return makeNameSafeForUSD(clipName) + "." + clipExt;
21
+ }
22
+
10
23
  get extensionName(): string {
11
24
  return "Audio";
12
25
  }
13
26
 
14
- private files: string[] = [];
27
+ private files = new Array<{ path: string, name: string }>();
15
28
 
16
29
  onExportObject?(object: Object3D, model : USDObject, _context: USDZExporterContext) {
17
30
  // check if this object has an audio source, add the relevant schema in that case.
@@ -27,25 +40,33 @@
27
40
  if (!audioSource.playOnAwake)
28
41
  continue;
29
42
 
30
- const clipName = audioSource.clip.split("/").pop();
31
- // TODO: ensure audio clip doesnt start with number
32
-
43
+ const clipName = audioSource.clip.split("/").pop() || "Audio";
44
+ const safeClipNameWithExt = AudioExtension.getName(audioSource.clip);
45
+ const safeClipName = makeNameSafeForUSD(safeClipNameWithExt);
33
46
 
34
- // TODO: store clipname in file and use in onAfterSerialize instead of creating it again!
35
- if (!this.files.includes(audioSource.clip)) {
36
- this.files.push(audioSource.clip);
47
+ if (!this.files.some(f => f.path === audioSource.clip)) {
48
+ this.files.push({ path: audioSource.clip, name: safeClipNameWithExt });
37
49
  }
38
50
 
39
- model.addEventListener('serialize', (writer: USDWriter, _context: USDZExporterContext) => {
40
- writer.appendLine();
41
- writer.beginBlock(`def SpatialAudio "${model.name}"`);
42
- writer.appendLine(`uniform asset filePath = @audio/${clipName}@`);
43
- writer.appendLine(`uniform token auralMode = "${ audioSource.spatialBlend > 0 ? "spatial" : "nonSpatial" }"`);
44
- // theoretically we could do timeline-like audio sequencing with this.
45
- writer.appendLine(`uniform token playbackMode = "${audioSource.loop ? "loopFromStage" : "onceFromStart" }"`);
46
- writer.appendLine(`uniform float gain = ${audioSource.volume}`);
47
- writer.closeBlock();
48
- });
51
+ // Turns out that in visionOS versions, using UsdAudio together with preliminary behaviours
52
+ // is not supported. So we need to NOT export SpatialAudio in those cases, and just rely
53
+ // on Preliminary_Behavior to play the audio.
54
+ if (!_context.quickLookCompatible)
55
+ {
56
+ model.addEventListener('serialize', (writer: USDWriter, _context: USDZExporterContext) => {
57
+ writer.appendLine();
58
+ writer.beginBlock(`def SpatialAudio "${safeClipName}"`, "(", false );
59
+ writer.appendLine(`displayName = "${clipName}"`);
60
+ writer.closeBlock( ")" );
61
+ writer.beginBlock();
62
+ writer.appendLine(`uniform asset filePath = @audio/${safeClipNameWithExt}@`);
63
+ writer.appendLine(`uniform token auralMode = "${ audioSource.spatialBlend > 0 ? "spatial" : "nonSpatial" }"`);
64
+ // theoretically we could do timeline-like audio sequencing with this.
65
+ writer.appendLine(`uniform token playbackMode = "${audioSource.loop ? "loopFromStage" : "onceFromStart" }"`);
66
+ writer.appendLine(`uniform float gain = ${audioSource.volume}`);
67
+ writer.closeBlock();
68
+ });
69
+ }
49
70
  }
50
71
  }
51
72
  }
@@ -53,17 +74,20 @@
53
74
  async onAfterSerialize(context: USDZExporterContext) {
54
75
  // write the files to the context.
55
76
  for (const file of this.files) {
77
+ const key = "audio/" + file.name;
78
+ if (context.files[key]) {
79
+ if(debug) console.warn("Audio file with name " + key + " already exists in the context. Skipping.");
80
+ continue;
81
+ }
56
82
 
57
- const clipName = file.split("/").pop();
58
-
59
83
  // convert file (which is a path) to a blob.
60
- const audio = await fetch(file);
84
+ const audio = await fetch(file.path);
61
85
  const audioBlob = await audio.blob();
62
86
  const arrayBuffer = await audioBlob.arrayBuffer();
63
87
 
64
88
  const audioData: Uint8Array = new Uint8Array(arrayBuffer)
65
89
 
66
- context.files["audio/" + clipName] = audioData;
90
+ context.files[key] = audioData;
67
91
  }
68
92
  }
69
93
  }
src/engine-components/AudioSource.ts CHANGED
@@ -313,6 +313,7 @@
313
313
  sound.setDistanceModel('linear');
314
314
  break;
315
315
  case AudioRolloffMode.Custom:
316
+ console.warn("Custom rolloff for AudioSource is not supported: " + this.name);
316
317
  break;
317
318
  }
318
319
 
src/engine-components/webxr/Avatar.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  import { AssetReference } from "../../engine/engine_addressables.js";
4
4
  import { ObjectUtils, PrimitiveType } from "../../engine/engine_create_objects.js";
5
5
  import { serializable } from "../../engine/engine_serialization_decorator.js";
6
- import { IGameObject } from "../../engine/engine_types.js";
6
+ import type { IGameObject } from "../../engine/engine_types.js";
7
7
  import { getParam,PromiseAllWithErrors } from "../../engine/engine_utils.js";
8
- import { NeedleXREventArgs, NeedleXRSession, NeedleXRUtils } from "../../engine/xr/index.js";
8
+ import { type NeedleXREventArgs, NeedleXRSession, NeedleXRUtils } from "../../engine/xr/index.js";
9
9
  import { PlayerState } from "../../engine-components-experimental/networking/PlayerSync.js";
10
10
  import { Behaviour, GameObject } from "../Component.js";
11
11
  import { SyncedTransform } from "../SyncedTransform.js";
src/engine-components/export/usdz/extensions/behavior/Behaviour.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
5
5
  import { BehaviorModel } from "./BehavioursBuilder.js";
6
6
 
7
- const debug = getParam("debugusdz");
7
+ const debug = getParam("debugusdzbehaviours");
8
8
 
9
9
  export interface UsdzBehaviour {
10
10
  createBehaviours?(ext: BehaviorExtension, model: USDObject, context: USDZExporterContext): void;
src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { isDevEnvironment, showBalloonWarning } from "../../../../../engine/debug/index.js";
4
4
  import { serializable } from "../../../../../engine/engine_serialization_decorator.js";
5
5
  import { getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../../../../../engine/engine_three_utils.js";
6
+ import { getParam } from "../../../../../engine/engine_utils.js";
6
7
  import type { State } from "../../../../../engine/extensions/NEEDLE_animator_controller_model.js";
7
8
  import { NEEDLE_progressive } from "../../../../../engine/extensions/NEEDLE_progressive.js";
8
9
  import { Animator } from "../../../../Animator.js";
@@ -13,9 +14,12 @@
13
14
  import { ObjectRaycaster,Raycaster } from "../../../../ui/Raycaster.js";
14
15
  import { USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
15
16
  import { AnimationExtension, RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
17
+ import { AudioExtension } from "./AudioExtension.js";
16
18
  import type { BehaviorExtension, UsdzBehaviour } from "./Behaviour.js";
17
- import { ActionBuilder, ActionModel, AuralMode, BehaviorModel, GroupActionModel, type IBehaviorElement, MotionType, MultiplePerformOperation,PlayAction, Space, TriggerBuilder } from "./BehavioursBuilder.js";
19
+ import { ActionBuilder, ActionModel, AuralMode, BehaviorModel, type IBehaviorElement, MotionType, MultiplePerformOperation,PlayAction, Space, TriggerBuilder } from "./BehavioursBuilder.js";
18
20
 
21
+ const debug = getParam("debugusdzbehaviours");
22
+
19
23
  function ensureRaycaster(obj: GameObject) {
20
24
  if (!obj) return;
21
25
  if (!obj.getComponentInParent(Raycaster)) {
@@ -651,11 +655,16 @@
651
655
  if (typeof clipUrl !== "string") return;
652
656
 
653
657
  const playbackTarget = this.target ? this.target.gameObject : this.gameObject;
654
- const clipName = clipUrl.split("/").pop();
658
+ const clipName = AudioExtension.getName(clipUrl);
655
659
  const volume = this.target ? this.target.volume : 1;
656
660
  const auralMode = this.target && this.target.spatialBlend == 0 ? AuralMode.NonSpatial : AuralMode.Spatial;
657
661
 
658
- // regular tap trigger
662
+ // This checks if any child is clickable – if yes, the tap trigger is added; if not, we omit it.
663
+ let anyChildHasGeometry = false;
664
+ this.gameObject.traverse(c => {
665
+ if (c instanceof Mesh && c.visible) anyChildHasGeometry = true;
666
+ });
667
+ if (anyChildHasGeometry)
659
668
  {
660
669
  let playAction: IBehaviorElement = ActionBuilder.playAudioAction(playbackTarget, "audio/" + clipName, PlayAction.Play, volume, auralMode);
661
670
  // does not seem to work in iOS / QuickLook...
@@ -674,7 +683,7 @@
674
683
  let playAction: IBehaviorElement = ActionBuilder.playAudioAction(playbackTarget, "audio/" + clipName, PlayAction.Play, volume, auralMode);
675
684
  if (this.target.loop)
676
685
  playAction = ActionBuilder.sequence(playAction).makeLooping();
677
- const playClipOnStart = new BehaviorModel("playAudioOnStart " + this.name,
686
+ const playClipOnStart = new BehaviorModel("playAudioOnStart" + (this.name ? "_" + this.name : ""),
678
687
  TriggerBuilder.sceneStartTrigger(),
679
688
  playAction,
680
689
  );
@@ -688,15 +697,17 @@
688
697
  const clipUrl = this.clip ? this.clip : this.target ? this.target.clip : undefined;
689
698
  if (!clipUrl) return;
690
699
  if (typeof clipUrl !== "string") return;
691
- const clipName = clipUrl.split("/").pop();
692
700
 
701
+ const clipName = AudioExtension.getName(clipUrl);
702
+ const filesKey = "audio/" + clipName;
703
+ // if the clip was already added, don't add it again
704
+ if (context.files[filesKey]) return;
705
+
693
706
  const audio = await fetch(clipUrl);
694
707
  const audioBlob = await audio.blob();
695
708
  const arrayBuffer = await audioBlob.arrayBuffer();
696
-
697
709
  const audioData: Uint8Array = new Uint8Array(arrayBuffer)
698
-
699
- context.files["audio/" + clipName] = audioData;
710
+ context.files[filesKey] = audioData;
700
711
  }
701
712
  }
702
713
 
@@ -821,7 +832,7 @@
821
832
  });
822
833
  }
823
834
 
824
- createAnimation(ext, model, _context) {
835
+ createAnimation(ext: AnimationExtension, model: USDObject, _context: USDZExporterContext) {
825
836
  if (!this.target || !this.animator) return;
826
837
 
827
838
  // If there's a separate state specified to play after this one, we
@@ -880,12 +891,12 @@
880
891
  const firstStateInLoop = visitedStates.indexOf(currentState);
881
892
  statesUntilLoop = visitedStates.slice(0, firstStateInLoop); // can be empty, which means we're looping all
882
893
  statesLooping = visitedStates.slice(firstStateInLoop); // can be empty, which means nothing is looping
883
- console.log("found loop from " + this.stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
894
+ if (debug) console.log("found loop from " + this.stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
884
895
  }
885
896
  else {
886
897
  statesUntilLoop = visitedStates;
887
898
  statesLooping = [];
888
- console.log("found no loop from " + this.stateName, "states", statesUntilLoop);
899
+ if (debug) console.log("found no loop from " + this.stateName, "states", statesUntilLoop);
889
900
  }
890
901
  }
891
902
 
@@ -900,17 +911,28 @@
900
911
  }
901
912
  this.stateAnimationModel = model;
902
913
 
914
+ const addStateToSequence = (state: State, sequence: Array<RegisteredAnimationInfo>) => {
915
+ if (!this.target) return;
916
+ if (!state.motion?.clip) {
917
+ console.warn("No clip found for state " + state.name + " on " + this.animator?.name + ", can't export animation data");
918
+ return;
919
+ }
920
+ const anim = ext.registerAnimation(this.target, state.motion.clip);
921
+ if (anim) sequence.push(anim);
922
+ else console.warn("Couldn't register animation for state " + state.name + " on " + this.animator?.name);
923
+ };
924
+
903
925
  // Register all the animation states we found.
904
926
  if (statesUntilLoop.length > 0) {
905
927
  this.animationSequence = new Array<RegisteredAnimationInfo>();
906
928
  for (const state of statesUntilLoop) {
907
- this.animationSequence.push(ext.registerAnimation(this.target, state.motion.clip));
929
+ addStateToSequence(state, this.animationSequence);
908
930
  }
909
931
  }
910
932
  if (statesLooping.length > 0) {
911
933
  this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
912
934
  for (const state of statesLooping) {
913
- this.animationLoopAfterSequence.push(ext.registerAnimation(this.target, state.motion.clip));
935
+ addStateToSequence(state, this.animationLoopAfterSequence);
914
936
  }
915
937
  }
916
938
  }
src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts CHANGED
@@ -404,12 +404,12 @@
404
404
  export class ActionBuilder {
405
405
 
406
406
  static sequence(...params: IBehaviorElement[]) {
407
- const group = new GroupActionModel("group_" + GroupActionModel.getId(), params);
407
+ const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params);
408
408
  return group.makeSequence();
409
409
  }
410
410
 
411
411
  static parallel(...params: IBehaviorElement[]) {
412
- const group = new GroupActionModel("group_" + GroupActionModel.getId(), params);
412
+ const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params);
413
413
  return group.makeParallel();
414
414
  }
415
415
 
src/engine-components/ui/Canvas.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { serializable } from "../../engine/engine_serialization_decorator.js";
6
6
  import { FrameEvent } from "../../engine/engine_setup.js";
7
7
  import { delayForFrames, getParam } from "../../engine/engine_utils.js";
8
- import { NeedleXREventArgs } from "../../engine/xr/index.js";
8
+ import { type NeedleXREventArgs } from "../../engine/xr/index.js";
9
9
  import { Camera } from "../Camera.js";
10
10
  import { GameObject } from "../Component.js";
11
11
  import { BaseUIComponent, UIRootComponent } from "./BaseUIComponent.js";
src/engine-components/Component.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  import { isDevEnvironment } from "../engine/debug/index.js";
4
4
  import { addNewComponent, destroyComponentInstance, findObjectOfType, findObjectsOfType, getComponent, getComponentInChildren, getComponentInParent, getComponents, getComponentsInChildren, getComponentsInParent, getOrAddComponent, moveComponentInstance, removeComponent } from "../engine/engine_components.js";
5
5
  import { activeInHierarchyFieldName } from "../engine/engine_constants.js";
6
- import { destroy, findByGuid, foreachComponent, HideFlags, IInstantiateOptions, instantiate, isActiveInHierarchy, isActiveSelf, isDestroyed, isUsingInstancing, markAsInstancedRendered, setActive } from "../engine/engine_gameobject.js";
6
+ import { destroy, findByGuid, foreachComponent, HideFlags, type IInstantiateOptions, instantiate, isActiveInHierarchy, isActiveSelf, isDestroyed, isUsingInstancing, markAsInstancedRendered, setActive } from "../engine/engine_gameobject.js";
7
7
  import * as main from "../engine/engine_mainloop_utils.js";
8
8
  import { syncDestroy, syncInstantiate } from "../engine/engine_networking_instantiate.js";
9
9
  import { Context, FrameEvent } from "../engine/engine_setup.js";
10
10
  import * as threeutils from "../engine/engine_three_utils.js";
11
11
  import type { Collision, Constructor, ConstructorConcrete, GuidsMap, ICollider, IComponent, IGameObject, SourceIdentifier } from "../engine/engine_types.js";
12
- import { INeedleXRSessionEventReceiver, NeedleXRControllerEventArgs, NeedleXREventArgs } from "../engine/engine_xr.js";
13
- import { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
12
+ import type { INeedleXRSessionEventReceiver, NeedleXRControllerEventArgs, NeedleXREventArgs } from "../engine/engine_xr.js";
13
+ import { type IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
14
14
 
15
15
 
16
16
  // export interface ISerializationCallbackReceiver {
src/engine/debug/debug_console.ts CHANGED
@@ -19,9 +19,9 @@
19
19
 
20
20
  if (!suppressConsole && (showConsole || isLocalNetwork())) {
21
21
  if (isLocalNetwork()) {
22
- const currentUrl = new URL(window.location.href);
23
- currentUrl.searchParams.set("console", "1");
24
- console.log("🌵 Tip: You can add the \"?console\" query parameter to the url to show the debug console (on mobile it will automatically open in the bottom right corner when your get errors during development)", "\nOpen this page console: " + currentUrl.toString());
22
+ const consoleUrl = new URL(window.location.href);
23
+ consoleUrl.searchParams.set("console", "1");
24
+ console.log("🌵 Tip: You can add the \"?console\" query parameter to the url to show the debug console (on mobile it will automatically open in the bottom right corner when your get errors during development. In VR a spatial console will appear.)", "\nOpen this page to get the console: " + consoleUrl.toString());
25
25
  }
26
26
  const isMobile = isMobileDevice() || (isQuest() && isDevEnvironment());
27
27
  if (isMobile) {
src/engine/debug/debug.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  export { showDebugConsole }
7
7
  export { LogType, setAllowOverlayMessages };
8
+ export { enableSpatialConsole } from "./debug_spatial_console.js";
8
9
 
9
10
  const noDevLogs = getParam("nodevlogs");
10
11
 
src/engine-components/DragControls.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AxesHelper, Box3, BufferGeometry, Camera, Color, Event, Line, LineBasicMaterial, Matrix3, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, PlaneHelper, Quaternion, Ray, Raycaster, SphereGeometry, Vector3 } from "three";
1
+ import { AxesHelper, Box3, BufferGeometry, Camera, Color, Line, LineBasicMaterial, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, Quaternion, Ray, Raycaster, SphereGeometry, Vector3 } from "three";
2
2
 
3
3
  import { Gizmos } from "../engine/engine_gizmos.js";
4
4
  import { InstancingUtil } from "../engine/engine_instancing.js";
@@ -7,7 +7,7 @@
7
7
  import { serializable } from "../engine/engine_serialization_decorator.js";
8
8
  import { Context } from "../engine/engine_setup.js";
9
9
  import { getWorldPosition, setWorldPosition } from "../engine/engine_three_utils.js";
10
- import { IGameObject } from "../engine/engine_types.js";
10
+ import { type IGameObject } from "../engine/engine_types.js";
11
11
  import { getParam } from "../engine/engine_utils.js";
12
12
  import { NeedleXRSession } from "../engine/engine_xr.js";
13
13
  import { Avatar_POI } from "./avatar/Avatar_Brain_LookAt.js";
@@ -32,6 +32,8 @@
32
32
  DynamicViewAngle = 3,
33
33
  /** The drag plane is adjusted dynamically while dragging. */
34
34
  SnapToSurfaces = 4,
35
+ /** Don't allow dragging the object */
36
+ None = 5,
35
37
  }
36
38
 
37
39
  export class DragControls extends Behaviour implements IPointerEventHandler {
@@ -121,6 +123,12 @@
121
123
  onPointerEnter(evt: PointerEventData) {
122
124
  if (!this.allowEdit(this.gameObject)) return;
123
125
  if (evt.mode !== "screen") return;
126
+
127
+ // get the drag mode and check if we need to abort early here
128
+ const isSpatialInput = evt.event.mode === "tracked-pointer";
129
+ const dragMode = isSpatialInput ? this.xrDragMode : this.dragMode;
130
+ if (dragMode === DragMode.None) return;
131
+
124
132
  const dc = GameObject.getComponentInParent(evt.object, DragControls);
125
133
  if (!dc || dc !== this) return;
126
134
  DragControls.lastHovered = evt.object;
@@ -137,6 +145,12 @@
137
145
  onPointerDown(args: PointerEventData) {
138
146
  if (!this.allowEdit(this.gameObject)) return;
139
147
  if (args.used) return;
148
+
149
+ // get the drag mode and check if we need to abort early here
150
+ const isSpatialInput = args.mode === "tracked-pointer";
151
+ const dragMode = isSpatialInput ? this.xrDragMode : this.dragMode;
152
+ if (dragMode === DragMode.None) return;
153
+
140
154
  DragControls.lastHovered = args.object;
141
155
 
142
156
  if (args.button === 0) {
@@ -743,6 +757,8 @@
743
757
  else
744
758
  this._dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP);
745
759
  break;
760
+ case DragMode.None:
761
+ break;
746
762
  }
747
763
 
748
764
  // calculate bounding box and snapping points. We want to either snap the "back" point or the "bottom" point.
@@ -864,6 +880,8 @@
864
880
  const keepRotation = isSpatialInput ? this.settings.xrKeepRotation : this.settings.keepRotation;
865
881
  const dragMode = isSpatialInput ? this.settings.xrDragMode : this.settings.dragMode;
866
882
 
883
+ if (dragMode === DragMode.None) return;
884
+
867
885
  const lerpStrength = 10;
868
886
  // - keeping rotation constant during dragging
869
887
  if (keepRotation) this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion;
src/engine-components/Duplicatable.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { serializable } from "../engine/engine_serialization_decorator.js";
6
6
  import { Behaviour, GameObject } from "./Component.js";
7
7
  import { DragControls } from "./DragControls.js";
8
- import { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
8
+ import { type IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
9
9
  import { ObjectRaycaster } from "./ui/Raycaster.js";
10
10
 
11
11
  export class Duplicatable extends Behaviour implements IPointerEventHandler {
src/engine/engine_addressables.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Group, Object3D, Texture, TextureLoader } from "three";
2
2
 
3
3
  import { deepClone, getParam, resolveUrl } from "../engine/engine_utils.js";
4
- import { destroy, IInstantiateOptions, instantiate, InstantiateOptions, isDestroyed } from "./engine_gameobject.js";
4
+ import { destroy, type IInstantiateOptions, instantiate, InstantiateOptions, isDestroyed } from "./engine_gameobject.js";
5
5
  import { getLoader } from "./engine_gltf.js";
6
6
  import { processNewScripts } from "./engine_mainloop_utils.js";
7
7
  import { registerPrefabProvider, syncInstantiate } from "./engine_networking_instantiate.js";
src/engine/engine_context.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  import { RendererData as SceneLighting } from './engine_scenelighting.js';
28
28
  import { logHierarchy } from './engine_three_utils.js';
29
29
  import { Time } from './engine_time.js';
30
- import { type CoroutineData, type GLTF, type ICamera, type IComponent, type IContext, type ILight, INeedleXRSession, type LoadedGLTF } from "./engine_types.js";
30
+ import type { CoroutineData, GLTF, ICamera, IComponent, IContext, ILight, LoadedGLTF } from "./engine_types.js";
31
31
  import * as utils from "./engine_utils.js";
32
32
  import { delay, getParam } from './engine_utils.js';
33
33
  import type { INeedleXRSessionEventReceiver, NeedleXRSession } from './engine_xr.js';
src/engine/engine_create_objects.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { BoxGeometry, ColorRepresentation,DoubleSide, Material, Mesh, MeshBasicMaterial, MeshStandardMaterial, PlaneGeometry, SphereGeometry } from "three"
1
+ import { BoxGeometry, Material, Mesh, MeshStandardMaterial, PlaneGeometry, SphereGeometry } from "three"
2
2
 
3
- import { Vec3 } from "./engine_types.js";
3
+ import type { Vec3 } from "./engine_types.js";
4
4
 
5
5
  export enum PrimitiveType {
6
6
  Quad = 0,
src/engine/engine_input.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { showBalloonMessage, showBalloonWarning } from './debug/debug.js';
4
4
  import { Context } from './engine_setup.js';
5
5
  import type { ButtonName, IGameObject, IInput, MouseButtonName, Vec2 } from './engine_types.js';
6
- import { EnumToPrimitiveUnion, getParam, isMozillaXR } from './engine_utils.js';
6
+ import { type EnumToPrimitiveUnion, getParam, isMozillaXR } from './engine_utils.js';
7
7
 
8
8
  const debug = getParam("debuginput");
9
9
 
@@ -226,7 +226,10 @@
226
226
  }
227
227
  }
228
228
  private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
229
- let propagationStopped = false;
229
+ /** True when the next event queue should not be invoked */
230
+ let preventNextEventQueue = false;
231
+
232
+ // Handle keyboard event
230
233
  if (evt instanceof NEKeyboardEvent) {
231
234
  const listeners = this._eventListeners[evt.type];
232
235
  if (listeners) {
@@ -236,17 +239,24 @@
236
239
  }
237
240
  }
238
241
  }
239
- else if (evt instanceof NEPointerEvent) {
242
+
243
+ // Hnadle pointer event
244
+ if (evt instanceof NEPointerEvent) {
240
245
  const listeners = this._eventListeners[evt.type];
241
246
  if (listeners) {
242
247
  for (const queue of listeners) {
243
- if (propagationStopped) break;
248
+ if (preventNextEventQueue) break;
244
249
  for (const l of queue.listeners) {
245
250
  if (evt.immediatePropagationStopped) {
246
- propagationStopped = true;
251
+ preventNextEventQueue = true;
247
252
  if (debug) console.log("immediatePropagationStopped", evt.type);
248
253
  break;
249
254
  }
255
+ else if (evt.propagationStopped) {
256
+ preventNextEventQueue = true;
257
+ if (debug) console.log("propagationStopped", evt.type);
258
+ // we do not break here but continue invoking the listeners in the queue
259
+ }
250
260
  (l as PointerEventListener)(evt);
251
261
  }
252
262
  }
src/engine/engine_lifecycle_api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FrameEvent } from "./engine_context.js";
2
2
  import { ContextEvent } from "./engine_context_registry.js";
3
- import { LifecycleMethod, registerFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
3
+ import { type LifecycleMethod, registerFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
4
4
 
5
5
 
6
6
  /**
src/engine/engine_math.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Vector } from "three";
2
2
 
3
+ import type { Vec3 } from "./engine_types.js";
4
+
3
5
  class MathHelper {
4
6
 
5
7
  random(min?: number, max?: number): number {
@@ -142,6 +144,12 @@
142
144
  dx: LowPassFilter;
143
145
  lasttime: number | null;
144
146
 
147
+ /** Create a new OneEuroFilter
148
+ * @param freq - An estimate of the frequency in Hz of the signal (> 0), if timestamps are not available.
149
+ * @param minCutOff - Min cutoff frequency in Hz (> 0). Lower values allow to remove more jitter.
150
+ * @param beta - Parameter to reduce latency (> 0). Higher values make the filter react faster to changes.
151
+ * @param dCutOff - Used to filter the derivates. 1 Hz by default. Change this parameter if you know what you are doing.
152
+ */
145
153
  constructor(freq: number, minCutOff = 1.0, beta = 0.0, dCutOff = 1.0) {
146
154
  if (freq <= 0 || minCutOff <= 0 || dCutOff <= 0) {
147
155
  throw new Error();
@@ -161,6 +169,7 @@
161
169
  return 1.0 / (1.0 + tau / te);
162
170
  }
163
171
 
172
+ /** Filter your value: call with your value and the current timestamp (e.g. from this.context.time.time) */
164
173
  filter(x: number, time: number | null = null) {
165
174
  if (this.lasttime && time) {
166
175
  this.freq = 1.0 / (time - this.lasttime);
@@ -172,4 +181,28 @@
172
181
  const cutOff = this.minCutOff + this.beta * Math.abs(edx);
173
182
  return this.x.filter(x, this.alpha(cutOff));
174
183
  }
184
+ }
185
+
186
+ export class OneEuroFilterXYZ {
187
+ readonly x: OneEuroFilter;
188
+ readonly y: OneEuroFilter;
189
+ readonly z: OneEuroFilter;
190
+
191
+ /** Create a new OneEuroFilter
192
+ * @param freq - An estimate of the frequency in Hz of the signal (> 0), if timestamps are not available.
193
+ * @param minCutOff - Min cutoff frequency in Hz (> 0). Lower values allow to remove more jitter.
194
+ * @param beta - Parameter to reduce latency (> 0). Higher values make the filter react faster to changes.
195
+ * @param dCutOff - Used to filter the derivates. 1 Hz by default. Change this parameter if you know what you are doing.
196
+ */
197
+ constructor(freq: number, minCutOff = 1.0, beta = 0.0, dCutOff = 1.0) {
198
+ this.x = new OneEuroFilter(freq, minCutOff, beta, dCutOff);
199
+ this.y = new OneEuroFilter(freq, minCutOff, beta, dCutOff);
200
+ this.z = new OneEuroFilter(freq, minCutOff, beta, dCutOff);
201
+ }
202
+
203
+ filter(value: Vec3, target: Vec3, time: number | null = null) {
204
+ target.x = this.x.filter(value.x, time);
205
+ target.y = this.y.filter(value.y, time);
206
+ target.z = this.z.filter(value.z, time);
207
+ }
175
208
  }
src/engine/engine_networking_instantiate.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { v5 } from 'uuid';
7
7
 
8
8
  import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js";
9
- import { destroy, findByGuid, IInstantiateOptions, instantiate } from "./engine_gameobject.js";
9
+ import { destroy, findByGuid, type IInstantiateOptions, instantiate } from "./engine_gameobject.js";
10
10
  import { InstantiateOptions } from "./engine_gameobject.js";
11
11
  import type { INetworkConnection } from "./engine_networking_types.js";
12
12
  import type { IModel } from "./engine_networking_types.js";
src/engine/engine_serialization_builtin_serializer.ts CHANGED
@@ -383,15 +383,17 @@
383
383
  const tex = data as Texture;
384
384
  const rt = new RenderTexture(tex.image.width, tex.image.height, {
385
385
  colorSpace: THREE.LinearSRGBColorSpace,
386
- });
386
+ });
387
387
  rt.texture = tex;
388
-
389
388
  tex.isRenderTargetTexture = true;
390
389
  tex.flipY = true;
391
390
  tex.offset.y = 1;
392
391
  tex.repeat.y = -1;
393
392
  tex.needsUpdate = true;
394
393
 
394
+ // when we have a compressed texture using mipmaps causes error in threejs because the bindframebuffer call will then try to set an array of framebuffers https://linear.app/needle/issue/NE-4294
395
+ tex.mipmaps = [];
396
+
395
397
  if (tex instanceof CompressedTexture) {
396
398
  //@ts-ignore
397
399
  tex["isCompressedTexture"] = false;
src/engine/engine_serialization_core.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { addLog,LogType } from "./debug/debug_overlay.js";
6
6
  import { isLocalNetwork } from "./engine_networking_utils.js";
7
7
  import { Context } from "./engine_setup.js";
8
- import { Constructor, type ConstructorConcrete, type SourceIdentifier } from "./engine_types.js";
8
+ import type { Constructor, ConstructorConcrete, SourceIdentifier } from "./engine_types.js";
9
9
  import { $BuiltInTypeFlag } from "./engine_typestore.js";
10
10
  import { getParam } from "./engine_utils.js";
11
11
  import { isPersistentAsset } from "./extensions/NEEDLE_persistent_assets.js";
src/engine/engine_types.ts CHANGED
@@ -47,6 +47,7 @@
47
47
 
48
48
  export interface ITime {
49
49
  get time(): number;
50
+ get deltaTime(): number;
50
51
  }
51
52
 
52
53
  export interface IInput {
src/engine-components/ui/EventSystem.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { Intersection, Object3D } from "three";
1
+ import { type Intersection, Object3D } from "three";
2
2
 
3
3
  import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
4
- import { Input, InputEventNames, InputEvents, NEPointerEvent, PointerType } from "../../engine/engine_input.js";
4
+ import { type InputEventNames, InputEvents, NEPointerEvent, PointerType } from "../../engine/engine_input.js";
5
5
  import { Mathf } from "../../engine/engine_math.js";
6
- import { RaycastOptions, RaycastTestObjectReturnType } from "../../engine/engine_physics.js";
6
+ import { RaycastOptions, type RaycastTestObjectReturnType } from "../../engine/engine_physics.js";
7
7
  import { Context } from "../../engine/engine_setup.js";
8
- import { IComponent } from "../../engine/engine_types.js";
8
+ import type { IComponent } from "../../engine/engine_types.js";
9
9
  import { getParam } from "../../engine/engine_utils.js";
10
10
  import { Behaviour, GameObject } from "../Component.js";
11
11
  import { $shadowDomOwner } from "./BaseUIComponent.js";
12
12
  import type { ICanvasGroup } from "./Interfaces.js";
13
- import { hasPointerEventComponent, type IPointerEventHandler, IPointerUpHandler, PointerEventData } from "./PointerEvents.js";
13
+ import { hasPointerEventComponent, type IPointerEventHandler, type IPointerUpHandler, PointerEventData } from "./PointerEvents.js";
14
14
  import { ObjectRaycaster, Raycaster } from "./Raycaster.js";
15
15
  import { UIRaycastUtils } from "./RaycastUtils.js";
16
16
  import { isUIObject } from "./Utils.js";
src/engine/extensions/extensions.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { GLTFExporter, GLTFExporterPlugin, GLTFWriter } from "three/examples/jsm/exporters/GLTFExporter.js";
2
- import { GLTFLoader, GLTFParser } from "three/examples/jsm/loaders/GLTFLoader.js";
1
+ import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
2
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
 
4
4
  import { isDevEnvironment } from "../debug/index.js";
5
5
  import { isResourceTrackingEnabled } from "../engine_assetdatabase.js";
src/engine/xr/internal.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Matrix4, Object3D, Quaternion, Scene, Vector3 } from 'three';
2
2
 
3
3
  import { CreateWireCube, Gizmos } from '../engine_gizmos.js';
4
- import { IGameObject } from '../engine_types.js';
4
+ import type { IGameObject } from '../engine_types.js';
5
5
  import { getParam } from '../engine_utils.js';
6
- import { IXRRig } from './XRRig.js';
6
+ import type { IXRRig } from './XRRig.js';
7
7
 
8
8
  export const flipForwardMatrix = new Matrix4().makeRotationY(Math.PI);
9
9
  export const flipForwardQuaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
src/engine-components/Light.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { setWorldPositionXYZ } from "../engine/engine_three_utils.js";
7
7
  import type { ILight } from "../engine/engine_types.js";
8
8
  import { getParam, isMobileDevice } from "../engine/engine_utils.js";
9
- import { NeedleXREventArgs } from "../engine/xr/index.js";
9
+ import { type NeedleXREventArgs } from "../engine/xr/index.js";
10
10
  import { Behaviour, GameObject } from "./Component.js";
11
11
  import { WebARSessionRoot } from "./webxr/WebARSessionRoot.js";
12
12
 
src/engine/xr/NeedleXRController.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { RGBAColor } from "../../engine-components/js-extensions/RGBAColor.js";
5
5
  import { Context } from "../engine_context.js";
6
6
  import { Gizmos } from "../engine_gizmos.js";
7
- import { InputEventNames, InputEvents, NEPointerEvent, NEPointerEventInit, PointerType } from "../engine_input.js";
7
+ import { type InputEventNames, InputEvents, NEPointerEvent, type NEPointerEventInit } from "../engine_input.js";
8
8
  import { getTempQuaternion, getTempVector, getWorldQuaternion } from "../engine_three_utils.js";
9
9
  import type { ButtonName, IGameObject, Vec3, XRControllerButtonName, XRGestureName } from "../engine_types.js";
10
10
  import { getParam } from "../engine_utils.js";
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { Camera, Object3D, PerspectiveCamera, Quaternion, Vector3 } from "three";
2
2
 
3
- import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
3
+ import { enableSpatialConsole, isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
4
4
  import { Context, FrameEvent } from "../engine_context.js";
5
5
  import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
6
6
  import { isDestroyed } from "../engine_gameobject.js";
7
7
  import { Gizmos } from "../engine_gizmos.js";
8
8
  import { registerFrameEventCallback, unregisterFrameEventCallback } from "../engine_lifecycle_functions_internal.js";
9
9
  import { getTempQuaternion, getTempVector, getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../engine_three_utils.js";
10
- import { ICamera, IComponent, INeedleXRSession } from "../engine_types.js";
10
+ import type { ICamera, IComponent, INeedleXRSession } from "../engine_types.js";
11
11
  import { delay, getParam, isDesktop, isQuest } from "../engine_utils.js";
12
12
  import { flipForwardMatrix, flipForwardQuaternion, ImplictXRRig } from "./internal.js";
13
13
  import { NeedleXRController } from "./NeedleXRController.js";
@@ -714,6 +714,8 @@
714
714
  this.mode = mode;
715
715
  this.context = context;
716
716
 
717
+ if(debug || getParam("console")) enableSpatialConsole(true);
718
+
717
719
  this.context.xr = this;
718
720
  this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet)
719
721
 
@@ -874,6 +876,8 @@
874
876
  this.context.requestSizeUpdate();
875
877
 
876
878
  this._defaultRig.gameObject.removeFromParent();
879
+
880
+ enableSpatialConsole(false);
877
881
  };
878
882
 
879
883
  /** Disconnects the controller, invokes events and notifies previou controller (if any) */
src/engine-components/utils/OpenURL.ts CHANGED
@@ -1,11 +1,10 @@
1
1
 
2
2
  import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
3
3
  import { serializable } from "../../engine/engine_serialization.js";
4
- import { isiOS,isSafari } from "../../engine/engine_utils.js";
4
+ import { isiOS, isSafari } from "../../engine/engine_utils.js";
5
5
  import { Behaviour } from "../Component.js";
6
6
  import { type IPointerClickHandler, PointerEventData } from "../ui/index.js";
7
7
  import { ObjectRaycaster, Raycaster } from "../ui/Raycaster.js";
8
- import { tryGetUIComponent } from "../ui/Utils.js";
9
8
 
10
9
  export enum OpenURLMode {
11
10
  NewTab = 0,
@@ -32,29 +31,34 @@
32
31
 
33
32
  this._validateUrl();
34
33
 
35
- if (isDevEnvironment()) showBalloonMessage("Open URL: " + this.url)
34
+ let url = this.url;
35
+ if (!url.startsWith("mailto:") && url.includes("@")) {
36
+ url = "mailto:" + url;
37
+ }
36
38
 
39
+ if (isDevEnvironment()) showBalloonMessage("Open URL: " + url)
40
+
37
41
  switch (this.mode) {
38
42
  case OpenURLMode.NewTab:
39
43
  if (isSafari()) {
40
- globalThis.open(this.url, "_blank");
44
+ globalThis.open(url, "_blank");
41
45
  }
42
46
  else
43
- globalThis.open(this.url, "_blank");
47
+ globalThis.open(url, "_blank");
44
48
  break;
45
49
  case OpenURLMode.SameTab:
46
50
  // TODO: test if "same tab" now also works on iOS
47
51
  if (isSafari() && isiOS()) {
48
- globalThis.open(this.url, "_top");
52
+ globalThis.open(url, "_top");
49
53
  }
50
54
  else
51
- globalThis.open(this.url, "_self");
55
+ globalThis.open(url, "_self");
52
56
  break;
53
57
  case OpenURLMode.NewWindow:
54
58
  if (isSafari()) {
55
- globalThis.open(this.url, "_top");
59
+ globalThis.open(url, "_top");
56
60
  }
57
- else globalThis.open(this.url, "_new");
61
+ else globalThis.open(url, "_new");
58
62
  break;
59
63
 
60
64
  }
src/engine-components-experimental/networking/PlayerSync.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { syncField } from "../../engine/engine_networking_auto.js"
6
6
  import { syncDestroy } from "../../engine/engine_networking_instantiate.js";
7
7
  import { serializable } from "../../engine/engine_serialization_decorator.js";
8
- import { IGameObject } from "../../engine/engine_types.js";
8
+ import type { IGameObject } from "../../engine/engine_types.js";
9
9
  import { delay, getParam } from "../../engine/engine_utils.js";
10
10
  import { Behaviour, Component, GameObject } from "../../engine-components/Component.js";
11
11
  import { EventList } from "../../engine-components/EventList.js";
src/engine-components/ui/PointerEvents.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Face, Object3D, Vector3 } from "three";
1
+ import { type Face, Object3D, Vector3 } from "three";
2
2
 
3
- import { Input, InputEventNames, NEPointerEvent } from "../../engine/engine_input.js";
4
- import { GamepadButtonName, MouseButtonName } from "../../engine/engine_types.js";
3
+ import { Input, type InputEventNames, NEPointerEvent } from "../../engine/engine_input.js";
4
+ import type { GamepadButtonName, MouseButtonName } from "../../engine/engine_types.js";
5
5
  import { GameObject } from "../Component.js";
6
6
 
7
7
  export interface IInputEventArgs {
src/engine-components/ui/Raycaster.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SkinnedMesh } from "three";
2
2
 
3
- import { IRaycastOptions, RaycastOptions } from "../../engine/engine_physics.js";
3
+ import { type IRaycastOptions, RaycastOptions } from "../../engine/engine_physics.js";
4
4
  import { serializable } from "../../engine/engine_serialization.js";
5
5
  import { NeedleXRSession } from "../../engine/engine_xr.js";
6
6
  import { Behaviour } from "../Component.js";
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  BufferGeometry,
8
8
  Color,
9
9
  DoubleSide,
10
+ LinearFilter,
10
11
  Material,
11
12
  MathUtils,
12
13
  Matrix4,
@@ -18,19 +19,23 @@
18
19
  OrthographicCamera,
19
20
  PerspectiveCamera,
20
21
  PlaneGeometry,
22
+ RGBAFormat,
21
23
  Scene,
22
24
  ShaderMaterial,
23
25
  SkinnedMesh,
24
26
  SRGBColorSpace,
25
27
  Texture,
26
28
  Uniform,
29
+ UnsignedByteType,
27
30
  Vector4,
28
- WebGLRenderer} from 'three';
31
+ WebGLRenderer,
32
+ WebGLRenderTarget} from 'three';
29
33
  import * as fflate from 'three/examples/jsm/libs/fflate.module.js';
30
34
 
31
35
  import type { OffscreenCanvasExt } from '../../../engine/engine_shims.js';
32
- import { GameObject } from '../../Component.js';
33
- import { Renderer } from '../../Renderer.js';
36
+ import { Progress } from '../../../engine/engine_time_utils.js';
37
+ import type { IUSDExporterExtension } from './Extension.js';
38
+ import type { AnimationExtension } from './extensions/Animation.js';
34
39
 
35
40
  function makeNameSafe( str ) {
36
41
  // Remove characters that are not allowed in USD ASCII identifiers
@@ -336,7 +341,7 @@
336
341
  }
337
342
 
338
343
 
339
- buildHeader( endTimeCode ) {
344
+ buildHeader( { startTimeCode, endTimeCode } ) {
340
345
 
341
346
  return `#usda 1.0
342
347
  (
@@ -346,7 +351,7 @@
346
351
  defaultPrim = "${makeNameSafe( this.name )}"
347
352
  metersPerUnit = 1
348
353
  upAxis = "Y"
349
- startTimeCode = 0
354
+ startTimeCode = ${startTimeCode}
350
355
  endTimeCode = ${endTimeCode}
351
356
  timeCodesPerSecond = 60
352
357
  framesPerSecond = 60
@@ -452,7 +457,7 @@
452
457
  class USDZExporterContext {
453
458
  root: any;
454
459
  exporter: any;
455
- extensions: any;
460
+ extensions: Array<IUSDExporterExtension> = [];
456
461
  quickLookCompatible: boolean;
457
462
  materials: Map<string, Material>;
458
463
  textures: TextureMap;
@@ -502,7 +507,8 @@
502
507
  class USDZExporter {
503
508
  debug: boolean;
504
509
  sceneAnchoringOptions: {} = {};
505
- extensions: any;
510
+ extensions: Array<IUSDExporterExtension> = [];
511
+ keepObject?: (object: Object3D) => boolean;
506
512
 
507
513
  constructor() {
508
514
 
@@ -510,20 +516,6 @@
510
516
 
511
517
  }
512
518
 
513
- getEndTimeCode( animations ) {
514
- let endTimeCode = 0;
515
-
516
- for( const animation of animations ) {
517
- const currentEndTimeCode = animation.duration * 60;
518
-
519
- if ( endTimeCode < currentEndTimeCode ) {
520
- endTimeCode = currentEndTimeCode;
521
- }
522
- }
523
-
524
- return endTimeCode;
525
- }
526
-
527
519
  async parse( scene, options: USDZExporterOptions = new USDZExporterOptions() ) {
528
520
 
529
521
  options = Object.assign( new USDZExporterOptions(), options );
@@ -541,8 +533,11 @@
541
533
  const materials = context.materials;
542
534
  const textures = context.textures;
543
535
 
536
+ Progress.report('export-usdz', "Invoking onBeforeBuildDocument");
544
537
  await invokeAll( context, 'onBeforeBuildDocument' );
538
+ Progress.report('export-usdz', "Done onBeforeBuildDocument");
545
539
 
540
+ Progress.report('export-usdz', "Reparent bones to common ancestor");
546
541
  // HACK let's find all skeletons and reparent them to their skelroot / armature / uppermost bone parent
547
542
  const reparentings: Array<any> = [];
548
543
  scene.traverseVisible(object => {
@@ -559,20 +554,26 @@
559
554
 
560
555
  for ( const reparenting of reparentings ) {
561
556
  const { object, originalParent, newParent } = reparenting;
562
- if (this.debug) console.log("REPARENTING", object, "from", originalParent, "to", newParent);
557
+ // if (this.debug) console.log("REPARENTING", object, "from", originalParent, "to", newParent);
563
558
  newParent.add( object );
564
559
  }
565
560
 
566
- traverseVisible( scene, context.document, context );
561
+ Progress.report('export-usdz', "Traversing hierarchy");
562
+ traverseVisible( scene, context.document, context, this.keepObject);
567
563
 
564
+ Progress.report('export-usdz', "Invoking onAfterBuildDocument");
568
565
  await invokeAll( context, 'onAfterBuildDocument' );
569
566
 
570
- parseDocument( context, () => {
567
+ Progress.report('export-usdz', { message: "Parsing document", autoStep: 10 });
568
+ await parseDocument( context, () => {
571
569
  // injected after stageRoot.
572
570
  // TODO property use context/writer instead of string concat
573
- return buildMaterials( materials, textures, options.quickLookCompatible );
571
+ Progress.report('export-usdz', "Building materials");
572
+ const result = buildMaterials( materials, textures, options.quickLookCompatible );
573
+ return result;
574
574
  } );
575
575
 
576
+ Progress.report("export-usdz", "Invoking onAfterSerialize");
576
577
  await invokeAll( context, 'onAfterSerialize' );
577
578
 
578
579
  // repair the parenting again
@@ -584,40 +585,57 @@
584
585
  // Moved into parseDocument callback for proper defaultPrim encapsulation
585
586
  // context.output += buildMaterials( materials, textures, options.quickLookCompatible );
586
587
 
587
- const endTimeCode = this.getEndTimeCode( context.animations );
588
+ const animationExtension: AnimationExtension | undefined = context.extensions.find( ext => ext.extensionName === 'animation' ) as AnimationExtension;
589
+ const startTimeCode = animationExtension?.getStartTimeCode() ?? 0;
590
+ const endTimeCode = animationExtension?.getEndTimeCode() ?? 0;
588
591
 
589
- const header = context.document.buildHeader( endTimeCode );
592
+ const header = context.document.buildHeader( { startTimeCode, endTimeCode } );
590
593
  const final = header + '\n' + context.output;
591
594
 
592
595
  // full output file
593
- if ( this.debug )
594
- console.log( final );
596
+ // if ( this.debug ) console.log( final );
595
597
 
596
598
  files[ modelFileName ] = fflate.strToU8( final );
597
599
  context.output = '';
598
600
 
599
- const decompressionRenderer = new WebGLRenderer( { antialias: false, alpha: true } );
601
+ Progress.report("export-usdz", { message: "Exporting textures", autoStep: 10 });
602
+ Progress.start("export-usdz-textures", "export-usdz");
603
+ const decompressionRenderer = new WebGLRenderer( {
604
+ antialias: false,
605
+ alpha: true,
606
+ premultipliedAlpha: false,
607
+ preserveDrawingBuffer: true
608
+ } );
600
609
 
601
- for ( const id in textures ) {
610
+ const textureCount = Object.keys(textures).length;
611
+ Progress.report("export-usdz-textures", { totalSteps: textureCount * 3, currentStep: 0 });
612
+ const convertTexture = async (id: string) => {
602
613
 
603
614
  const textureData = textures[ id ];
604
- let texture = textureData.texture;
615
+ const texture = textureData.texture;
605
616
 
606
617
  const isRGBA = formatsWithAlphaChannel.includes( texture.format );
607
-
608
- //@ts-ignore
609
- if ( texture.isCompressedTexture || texture.isRenderTargetTexture ) {
610
-
611
- texture = decompressGpuTexture( texture, options.maxTextureSize, decompressionRenderer );
612
-
618
+
619
+ // Change: we need to always read back the texture now, otherwise the unpremultiplied workflow doesn't work.
620
+ let img: ImageReadbackResult = {
621
+ imageData: texture.image
622
+ };
623
+
624
+ Progress.report("export-usdz-textures", { message: "read back texture", autoStep: true });
625
+ const anyColorScale = textureData.scale !== undefined && textureData.scale.x !== 1 && textureData.scale.y !== 1 && textureData.scale.z !== 1 && textureData.scale.w !== 1;
626
+ // @ts-ignore
627
+ if ( texture.isCompressedTexture || texture.isRenderTargetTexture || anyColorScale ) {
628
+ img = await decompressGpuTexture( texture, options.maxTextureSize, decompressionRenderer, textureData.scale );
613
629
  }
614
630
 
615
- const canvas = await imageToCanvas( texture.image, textureData.scale, false, options.maxTextureSize ).catch( err => {
631
+ Progress.report("export-usdz-textures", { message: "convert texture to canvas", autoStep: true });
632
+ const canvas = await imageToCanvasUnpremultiplied( img.imageBitmap || img.imageData, options.maxTextureSize ).catch( err => {
616
633
  console.error("Error converting texture to canvas", texture, err);
617
634
  });
618
635
 
619
636
  if ( canvas ) {
620
637
 
638
+ Progress.report("export-usdz-textures", { message: "convert canvas to blob", autoStep: true });
621
639
  const blob = await canvas.convertToBlob( {type: isRGBA ? 'image/png' : 'image/jpeg', quality: 0.95 } );
622
640
  files[ `textures/${id}.${isRGBA ? 'png' : 'jpg'}` ] = new Uint8Array( await blob.arrayBuffer() );
623
641
 
@@ -626,7 +644,11 @@
626
644
  console.warn( 'Can`t export texture: ', texture );
627
645
 
628
646
  }
647
+ };
629
648
 
649
+ for ( const id in textures ) {
650
+
651
+ await convertTexture( id );
630
652
  }
631
653
 
632
654
  decompressionRenderer.dispose();
@@ -634,6 +656,8 @@
634
656
  // 64 byte alignment
635
657
  // https://github.com/101arrowz/fflate/issues/39#issuecomment-777263109
636
658
 
659
+ Progress.end("export-usdz-textures");
660
+
637
661
  let offset = 0;
638
662
 
639
663
  for ( const filename in files ) {
@@ -658,13 +682,14 @@
658
682
 
659
683
  }
660
684
 
685
+ Progress.report("export-usdz", "zip archive");
661
686
  return fflate.zipSync( files, { level: 0 } );
662
687
 
663
688
  }
664
689
 
665
690
  }
666
691
 
667
- function traverseVisible( object: Object3D, parentModel: USDObject, context: USDZExporterContext ) {
692
+ function traverseVisible( object: Object3D, parentModel: USDObject, context: USDZExporterContext, keepObject?: (object: Object3D) => boolean ) {
668
693
 
669
694
  if ( ! object.visible ) return;
670
695
 
@@ -677,10 +702,7 @@
677
702
  material = object.material;
678
703
  }
679
704
 
680
- // TODO what should be do with disabled renderers?
681
- // Here we just assume they're off, and don't export them
682
- const renderer = GameObject.getComponent( object, Renderer )
683
- if (renderer && !renderer.enabled) {
705
+ if (keepObject && !keepObject(object)) {
684
706
  geometry = undefined;
685
707
  material = undefined;
686
708
  }
@@ -741,7 +763,7 @@
741
763
 
742
764
  for ( const ch of object.children ) {
743
765
 
744
- traverseVisible( ch, parentModel, context );
766
+ traverseVisible( ch, parentModel, context, keepObject );
745
767
 
746
768
  }
747
769
 
@@ -749,12 +771,23 @@
749
771
 
750
772
  async function parseDocument( context: USDZExporterContext, afterStageRoot: () => string ) {
751
773
 
774
+ Progress.start("export-usdz-resources", "export-usdz");
775
+ const resources: Array<() => void> = [];
752
776
  for ( const child of context.document.children ) {
753
-
754
- addResources( child, context );
755
-
777
+ addResources( child, context, resources );
756
778
  }
757
-
779
+ // addResources now only collects promises for better progress reporting.
780
+ // We are resolving them here and reporting progress on that:
781
+ const total = resources.length;
782
+ for (let i = 0; i < total; i++) {
783
+ Progress.report("export-usdz-resources", { totalSteps: total, currentStep: i });
784
+ await new Promise<void>((resolve, _reject) => {
785
+ resources[i]();
786
+ resolve();
787
+ });
788
+ }
789
+ Progress.end("export-usdz-resources");
790
+
758
791
  const writer = new USDWriter();
759
792
 
760
793
  writer.beginBlock( `def Xform "${context.document.name}"` );
@@ -780,12 +813,22 @@
780
813
  writer.appendLine( `rel preliminary:imageAnchoring:referenceImage = </${context.document.name}/Scenes/Scene/AnchoringReferenceImage>` );
781
814
  writer.appendLine();
782
815
 
816
+ const count = (object: USDObject | null) => {
817
+ if (!object) return 0;
818
+ let total = 1;
819
+ for ( const child of object.children ) total += count( child );
820
+ return total;
821
+ }
822
+ const totalXformCount = count(context.document);
823
+ Progress.start("export-usdz-xforms", "export-usdz");
824
+ Progress.report("export-usdz-xforms", { totalSteps: totalXformCount, currentStep: 1 });
825
+
783
826
  for ( const child of context.document.children ) {
784
-
785
827
  buildXform( child, writer, context );
786
-
787
828
  }
829
+ Progress.end("export-usdz-xforms");
788
830
 
831
+ Progress.report("export-usdz", "invoke onAfterHierarchy");
789
832
  invokeAll( context, 'onAfterHierarchy', writer );
790
833
 
791
834
  writer.closeBlock();
@@ -793,15 +836,15 @@
793
836
  writer.appendLine(afterStageRoot());
794
837
  writer.closeBlock();
795
838
 
839
+ Progress.report("export-usdz", "write to string")
796
840
  context.output += writer.toString();
797
841
 
798
842
  }
799
843
 
800
- function addResources( object: USDObject | null, context: USDZExporterContext ) {
844
+ function addResources( object: USDObject | null, context: USDZExporterContext, resources: Array<() => void>) {
801
845
 
802
- if ( object == null ) {
803
- return;
804
- }
846
+ if ( !object ) return;
847
+
805
848
  const geometry = object.geometry;
806
849
  const material = object.material;
807
850
 
@@ -813,9 +856,13 @@
813
856
 
814
857
  if ( ! ( geometryFileName in context.files ) ) {
815
858
 
816
- const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones );
817
- context.files[ geometryFileName ] = buildUSDFileAsString( meshObject, context );
859
+ const action = () => {
860
+ const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones );
861
+ context.files[ geometryFileName ] = buildUSDFileAsString( meshObject, context);
862
+ };
818
863
 
864
+ resources.push(action);
865
+
819
866
  }
820
867
 
821
868
  } else {
@@ -826,7 +873,7 @@
826
873
 
827
874
  }
828
875
 
829
- if( material ) {
876
+ if ( material ) {
830
877
 
831
878
  if ( ! ( material.uuid in context.materials ) ) {
832
879
 
@@ -837,7 +884,7 @@
837
884
 
838
885
  for ( const ch of object.children ) {
839
886
 
840
- addResources( ch, context );
887
+ addResources( ch, context, resources );
841
888
 
842
889
  }
843
890
 
@@ -866,24 +913,40 @@
866
913
 
867
914
  }
868
915
  let _renderer: WebGLRenderer | null = null;
916
+ let renderTarget: WebGLRenderTarget | null = null;
869
917
  let fullscreenQuadGeometry: PlaneGeometry | null;
870
918
  let fullscreenQuadMaterial: ShaderMaterial | null;
871
919
  let fullscreenQuad: Mesh | null;
872
920
 
873
- function decompressGpuTexture( texture, maxTextureSize = Infinity, renderer: WebGLRenderer | null = null ) {
921
+ declare type ImageReadbackResult = {
922
+ imageData: ImageData;
923
+ imageBitmap?: ImageBitmap;
924
+ }
874
925
 
926
+ /** Reads back a texture from the GPU (can be compressed, a render texture, or aynthing), apply colorScale to it, and return CPU data for further usage.
927
+ * Note that there are WebGL / WebGPU rules preventing some use of data between WebGL contexts.
928
+ */
929
+ async function decompressGpuTexture( texture, maxTextureSize = Infinity, renderer: WebGLRenderer | null = null, colorScale: Vector4 | undefined = undefined): Promise<ImageReadbackResult> {
930
+
875
931
  if ( ! fullscreenQuadGeometry ) fullscreenQuadGeometry = new PlaneGeometry( 2, 2, 1, 1 );
876
932
  if ( ! fullscreenQuadMaterial ) fullscreenQuadMaterial = new ShaderMaterial( {
877
- uniforms: { blitTexture: new Uniform( texture ) },
933
+ uniforms: {
934
+ blitTexture: new Uniform( texture ),
935
+ flipY: new Uniform( false ),
936
+ scale: new Uniform( new Vector4( 1, 1, 1, 1 ) ),
937
+ },
878
938
  vertexShader: `
879
939
  varying vec2 vUv;
940
+ uniform bool flipY;
880
941
  void main(){
881
942
  vUv = uv;
882
- vUv.y = 1. - vUv.y;
943
+ if (flipY)
944
+ vUv.y = 1. - vUv.y;
883
945
  gl_Position = vec4(position.xy * 1.0,0.,.999999);
884
946
  }`,
885
947
  fragmentShader: `
886
- uniform sampler2D blitTexture;
948
+ uniform sampler2D blitTexture;
949
+ uniform vec4 scale;
887
950
  varying vec2 vUv;
888
951
 
889
952
  void main(){
@@ -894,11 +957,18 @@
894
957
  #else
895
958
  gl_FragColor = texture2D( blitTexture, vUv);
896
959
  #endif
897
- gl_FragColor.rgb *= gl_FragColor.a;
960
+
961
+ gl_FragColor.rgba *= scale.rgba;
898
962
  }`
899
963
  } );
900
964
 
901
- fullscreenQuadMaterial.uniforms.blitTexture.value = texture;
965
+ // update uniforms
966
+ const uniforms = fullscreenQuadMaterial.uniforms;
967
+ uniforms.blitTexture.value = texture;
968
+ uniforms.flipY.value = false;
969
+ uniforms.scale.value = new Vector4( 1, 1, 1, 1 );
970
+ if ( colorScale !== undefined ) uniforms.scale.value.copy( colorScale );
971
+
902
972
  fullscreenQuadMaterial.defines.IS_SRGB = texture.colorSpace == SRGBColorSpace;
903
973
  fullscreenQuadMaterial.needsUpdate = true;
904
974
 
@@ -915,22 +985,31 @@
915
985
 
916
986
  if ( ! renderer ) {
917
987
 
918
- renderer = _renderer = new WebGLRenderer( { antialias: false, alpha: true } );
988
+ renderer = _renderer = new WebGLRenderer( { antialias: false, alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true } );
919
989
 
920
990
  }
921
991
 
922
- renderer.setSize( Math.min( texture.image.width, maxTextureSize ), Math.min( texture.image.height, maxTextureSize ) );
992
+ const width = Math.min( texture.image.width, maxTextureSize );
993
+ const height = Math.min( texture.image.height, maxTextureSize );
994
+
995
+ // dispose render target if the size is wrong
996
+ if ( renderTarget && ( renderTarget.width !== width || renderTarget.height !== height ) ) {
997
+
998
+ renderTarget.dispose();
999
+ renderTarget = null;
1000
+
1001
+ }
1002
+
1003
+ if ( ! renderTarget ) {
1004
+
1005
+ renderTarget = new WebGLRenderTarget( width, height, { format: RGBAFormat, type: UnsignedByteType, minFilter: LinearFilter, magFilter: LinearFilter } );
1006
+ }
1007
+
1008
+ renderer.setRenderTarget( renderTarget );
1009
+ renderer.setSize( width, height );
923
1010
  renderer.clear();
924
1011
  renderer.render( _scene, _camera );
925
1012
 
926
- const readableTexture = new Texture( renderer.domElement );
927
-
928
- readableTexture.minFilter = texture.minFilter;
929
- readableTexture.magFilter = texture.magFilter;
930
- readableTexture.wrapS = texture.wrapS;
931
- readableTexture.wrapT = texture.wrapT;
932
- readableTexture.name = texture.name;
933
-
934
1013
  if ( _renderer ) {
935
1014
 
936
1015
  _renderer.dispose();
@@ -938,11 +1017,18 @@
938
1017
 
939
1018
  }
940
1019
 
941
- return readableTexture;
1020
+ const buffer = new Uint8ClampedArray( renderTarget.width * renderTarget.height * 4 );
1021
+ renderer.readRenderTargetPixels( renderTarget, 0, 0, renderTarget.width, renderTarget.height, buffer );
1022
+ const imageData = new ImageData( buffer, renderTarget.width, renderTarget.height, undefined );
1023
+ const bmp = await createImageBitmap( imageData, { premultiplyAlpha: "none" } );
1024
+ return {
1025
+ imageData,
1026
+ imageBitmap: bmp
1027
+ };
942
1028
 
943
1029
  }
944
1030
 
945
-
1031
+ /** Checks if the given image is of a type with readable data and width/height */
946
1032
  function isImageBitmap( image ) {
947
1033
 
948
1034
  return ( typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement ) ||
@@ -952,8 +1038,32 @@
952
1038
 
953
1039
  }
954
1040
 
955
- async function imageToCanvas( image, color: Vector4 | undefined = undefined, flipY = false, maxTextureSize = 4096 ) {
1041
+ /** This method uses a 'bitmaprenderer' context and doesn't do any pixel manipulation.
1042
+ * This way, we can keep the alpha channel as it was, but we're losing the ability to do pixel manipulations or resize operations. */
1043
+ async function imageToCanvasUnpremultiplied( image: ImageBitmapSource & { width: number, height: number }, maxTextureSize = 4096) {
956
1044
 
1045
+ const scale = maxTextureSize / Math.max( image.width, image.height );
1046
+ const width = image.width * Math.min( 1, scale );
1047
+ const height = image.height * Math.min( 1, scale );
1048
+
1049
+ const canvas = new OffscreenCanvas( width, height );
1050
+ const settings: ImageBitmapOptions = { premultiplyAlpha: "none" };
1051
+ if (image.width !== width) settings.resizeWidth = width;
1052
+ if (image.height !== height) settings.resizeHeight = height;
1053
+
1054
+ const imageBitmap = await createImageBitmap(image, settings);
1055
+ const ctx = canvas.getContext("bitmaprenderer") as ImageBitmapRenderingContext | null;
1056
+ if (ctx) {
1057
+ ctx.transferFromImageBitmap(imageBitmap);
1058
+ }
1059
+ return canvas as OffscreenCanvasExt;
1060
+ }
1061
+
1062
+ /** This method uses a '2d' canvas context for pixel manipulation, and can apply a color scale or Y flip to the given image.
1063
+ * Unfortunately, canvas always uses premultiplied data, and thus images with low alpha values (or multiplying by a=0) will result in black pixels.
1064
+ */
1065
+ async function imageToCanvas( image: HTMLImageElement | HTMLCanvasElement | OffscreenCanvas | ImageBitmap, color: Vector4 | undefined = undefined, flipY = false, maxTextureSize = 4096 ) {
1066
+
957
1067
  if ( isImageBitmap( image ) ) {
958
1068
 
959
1069
  // max. canvas size on Safari is still 4096x4096
@@ -961,7 +1071,7 @@
961
1071
 
962
1072
  const canvas = new OffscreenCanvas( image.width * Math.min( 1, scale ), image.height * Math.min( 1, scale ) );
963
1073
 
964
- const context = canvas.getContext( '2d' ) as OffscreenCanvasRenderingContext2D;
1074
+ const context = canvas.getContext( '2d', { alpha: true, premultipliedAlpha: false } ) as OffscreenCanvasRenderingContext2D;
965
1075
  if (!context) throw new Error('Could not get canvas 2D context');
966
1076
 
967
1077
  if ( flipY === true ) {
@@ -1044,9 +1154,15 @@
1044
1154
  }
1045
1155
 
1046
1156
  function getGeometryName(geometry: BufferGeometry, fallbackName: string) {
1047
- return makeNameSafe(geometry.name || fallbackName || 'Geometry_') + geometry.id;
1157
+ // TODO using object names here breaks instancing...
1158
+ // Likely need to remove fallbackName again
1159
+ return makeNameSafe(geometry.name || fallbackName || 'Geometry') + "_" + geometry.id;
1048
1160
  }
1049
1161
 
1162
+ function getMaterialName(material: Material) {
1163
+ return makeNameSafe(material.name || 'Material') + "_" + material.id;
1164
+ }
1165
+
1050
1166
  function getPathToSkeleton(bone: Object3D, assumedRoot: Object3D) {
1051
1167
  let path = getBoneName(bone);
1052
1168
  let current = bone.parent;
@@ -1064,6 +1180,8 @@
1064
1180
  if ( model == null)
1065
1181
  return;
1066
1182
 
1183
+ Progress.report("export-usdz-xforms", { message: "buildXform " + model.displayName || model.name, autoStep: true });
1184
+
1067
1185
  const matrix = model.matrix;
1068
1186
  const geometry = model.geometry;
1069
1187
  const material = model.material;
@@ -1109,7 +1227,7 @@
1109
1227
  writer.beginBlock();
1110
1228
 
1111
1229
  if ( geometry && material ) {
1112
- const materialName = makeNameSafe(material.name) + `_${material.id}`;
1230
+ const materialName = getMaterialName(material);
1113
1231
  writer.appendLine( `rel material:binding = </StageRoot/Materials/${materialName}>` );
1114
1232
 
1115
1233
  // Turns out QuickLook / RealityKit doesn't support the doubleSided attribute, so we
@@ -1469,7 +1587,7 @@
1469
1587
  const pad = ' ';
1470
1588
  const inputs: Array<string> = [];
1471
1589
  const samplers: Array<string> = [];
1472
- const materialName = makeNameSafe(material.name) + `_${material.id}`;
1590
+ const materialName = getMaterialName(material);
1473
1591
 
1474
1592
  function texName(tex: Texture) {
1475
1593
  return makeNameSafe(tex.name) + '_' + tex.id;
@@ -1837,8 +1955,10 @@
1837
1955
  decompressGpuTexture,
1838
1956
  findStructuralNodesInBoneHierarchy,
1839
1957
  getBoneName,
1958
+ getMaterialName as getMaterialNameForUSD,
1840
1959
  getPathToSkeleton,
1841
1960
  imageToCanvas,
1961
+ imageToCanvasUnpremultiplied,
1842
1962
  makeNameSafe as makeNameSafeForUSD,
1843
1963
  USDDocument,
1844
1964
  fn as usdNumberFormatting,
src/engine-components/export/usdz/utils/timeutils.ts DELETED
@@ -1,20 +0,0 @@
1
-
2
-
3
-
4
- export function getFormattedDate() {
5
- var date = new Date();
6
-
7
- const month = date.getMonth() + 1;
8
- const day = date.getDate();
9
- const hour = date.getHours();
10
- const min = date.getMinutes();
11
- const sec = date.getSeconds();
12
-
13
- const s_month = (month < 10 ? "0" : "") + month;
14
- const s_day = (day < 10 ? "0" : "") + day;
15
- const s_hour = (hour < 10 ? "0" : "") + hour;
16
- const s_min = (min < 10 ? "0" : "") + min;
17
- const s_sec = (sec < 10 ? "0" : "") + sec;
18
-
19
- return date.getFullYear() + s_month + s_day + "-" + s_hour + s_min + s_sec;
20
- }
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -1,15 +1,15 @@
1
- import { Matrix4,Mesh, Object3D } from "three";
1
+ import { Matrix4, Mesh, Object3D } from "three";
2
2
 
3
3
  import { showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
4
4
  import { hasProLicense } from "../../../engine/engine_license.js";
5
5
  import { serializable } from "../../../engine/engine_serialization.js";
6
- import { Context } from "../../../engine/engine_setup.js";
7
- import { delay, getParam, isiOS, isMobileDevice, isSafari } from "../../../engine/engine_utils.js";
6
+ import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
7
+ import { getParam, isiOS, isMobileDevice, isSafari } from "../../../engine/engine_utils.js";
8
8
  import { Behaviour, GameObject } from "../../Component.js";
9
9
  import { Renderer } from "../../Renderer.js"
10
10
  import { WebARSessionRoot } from "../../webxr/WebARSessionRoot.js";
11
11
  import { NeedleWebXRHtmlElement } from "../../webxr/WebXRButtons.js";
12
- import { XRFlag, XRState, XRStateFlag } from "../../webxr/XRFlag.js";
12
+ import { XRState, XRStateFlag } from "../../webxr/XRFlag.js";
13
13
  import type { IUSDExporterExtension } from "./Extension.js";
14
14
  import { AnimationExtension } from "./extensions/Animation.js"
15
15
  import { AudioExtension } from "./extensions/behavior/AudioExtension.js";
@@ -17,9 +17,8 @@
17
17
  import { TextExtension } from "./extensions/USDZText.js";
18
18
  import { USDZUIExtension } from "./extensions/USDZUI.js";
19
19
  import { USDZExporter as ThreeUSDZExporter } from "./ThreeUSDZExporter.js";
20
- import { registerAnimatorsImplictly } from "./utils/animationutils.js";
20
+ import { registerAnimatorsImplictly, registerAudioSourcesImplictly } from "./utils/animationutils.js";
21
21
  import { ensureQuicklookLinkIsCreated } from "./utils/quicklook.js";
22
- import { getFormattedDate } from "./utils/timeutils.js";
23
22
 
24
23
  const debug = getParam("debugusdz");
25
24
 
@@ -41,10 +40,21 @@
41
40
  @serializable(Object3D)
42
41
  objectToExport?: Object3D;
43
42
 
43
+ /** Collect all Animations/Animators automatically on export and emit them as playing at the start.
44
+ * Animator state chains and loops will automatically be collected and exported in order as well.
45
+ * If this setting is off, Animators need to be registered by components – for example from PlayAnimationOnClick.
46
+ */
44
47
  @serializable()
45
48
  autoExportAnimations: boolean = false;
46
49
 
50
+ /** Collect all AudioSources automatically on export and emit them as playing at the start.
51
+ * They will loop according to their settings.
52
+ * If this setting is off, Audio Sources need to be registered by components – for example from PlayAudioOnClick.
53
+ */
47
54
  @serializable()
55
+ autoExportAudioSources: boolean = true;
56
+
57
+ @serializable()
48
58
  exportFileName?: string;
49
59
 
50
60
  @serializable(URL)
@@ -64,6 +74,10 @@
64
74
  @serializable()
65
75
  planeAnchoringAlignment: "horizontal" | "vertical" | "any" = "horizontal";
66
76
 
77
+ /** Enabling this option will export QuickLook-specific preliminary behaviours along with the USDZ files.
78
+ * These extensions are only supported on QuickLook on iOS/visionOS/MacOS.
79
+ * Keep this option off for general USDZ usage.
80
+ */
67
81
  @serializable()
68
82
  interactive: boolean = true;
69
83
 
@@ -194,21 +208,35 @@
194
208
  if (!objectToExport)
195
209
  return null;
196
210
 
211
+ Progress.start("export-usdz", { onProgress: (progress) => {
212
+ this.dispatchEvent(new CustomEvent("export-progress", { detail: { progress } }));
213
+ }});
214
+ Progress.report("export-usdz", { message: "Starting export", totalSteps: 40, currentStep: 0 });
215
+ Progress.report("export-usdz", { message: "Load progressive textures", autoStep: 5 });
216
+ Progress.start("export-usdz-textures", "export-usdz");
197
217
  // trigger progressive textures to be loaded:
198
218
  const renderers = GameObject.getComponentsInChildren(objectToExport, Renderer);
199
219
  const progressiveLoading = new Array<Promise<any>>();
220
+ let loadedTextures = 0;
200
221
  for (const rend of renderers) {
201
222
  for (const mat of rend.sharedMaterials) {
202
223
  if (mat) {
203
224
  const task = rend.loadProgressiveTextures(mat);
204
225
  if (task instanceof Promise)
205
- progressiveLoading.push(task);
226
+ progressiveLoading.push(new Promise<void>((resolve, reject) => {
227
+ task.then(() => {
228
+ loadedTextures++;
229
+ Progress.report("export-usdz-textures", { message: "Loaded progressive texture", currentStep: loadedTextures, totalSteps: progressiveLoading.length });
230
+ resolve();
231
+ }).catch((err) => reject(err));
232
+ }));
206
233
  }
207
234
  }
208
235
  }
209
236
  if (debug) showBalloonMessage("Load textures: " + progressiveLoading.length);
210
237
  await Promise.all(progressiveLoading);
211
238
  if (debug) showBalloonMessage("Load textures: done");
239
+ Progress.end("export-usdz-textures");
212
240
 
213
241
  // apply XRFlags
214
242
  const currentXRState = XRState.Global.Mask;
@@ -225,17 +253,31 @@
225
253
  extensions.push(animExt);
226
254
 
227
255
  const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport };
256
+ Progress.report("export-usdz", "Invoking before-export" );
228
257
  this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs }))
229
258
 
230
259
  // Implicit registration and actions for Animators and Animation components
231
260
  // Currently, Animators properly build PlayAnimation actions, but Animation components don't.
232
- const implicitAnimationBehaviors = new Array<Object3D>();
261
+
262
+ Progress.report("export-usdz", "auto export animations and audio sources");
263
+ const implicitBehaviors = new Array<Object3D>();
233
264
  if (this.autoExportAnimations) {
234
- implicitAnimationBehaviors.push(...registerAnimatorsImplictly(objectToExport, animExt));
265
+ implicitBehaviors.push(...registerAnimatorsImplictly(objectToExport, animExt));
235
266
  }
267
+ const audioExt = this.extensions.find(ext => ext.extensionName === "Audio");
268
+ if (audioExt && this.autoExportAudioSources)
269
+ implicitBehaviors.push(...registerAudioSourcesImplictly(objectToExport, audioExt as AudioExtension));
236
270
 
237
271
  //@ts-ignore
238
272
  exporter.debug = debug;
273
+ exporter.keepObject = (object) => {
274
+ // TODO We need to take more care with disabled renderers. This currently breaks when any renderer is disabled
275
+ // and then enabled at runtime by e.g. SetActiveOnClick, requiring extra work to enable them before export,
276
+ // cache their state, and then reset their state after export. See
277
+ const renderer = GameObject.getComponent( object, Renderer )
278
+ if (renderer && !renderer.enabled) return false;
279
+ return true;
280
+ }
239
281
 
240
282
  // sanitize anchoring types
241
283
  if (this.anchoringType !== "plane" && this.anchoringType !== "none" && this.anchoringType !== "image" && this.anchoringType !== "face")
@@ -243,6 +285,7 @@
243
285
  if (this.planeAnchoringAlignment !== "horizontal" && this.planeAnchoringAlignment !== "vertical" && this.planeAnchoringAlignment !== "any")
244
286
  this.planeAnchoringAlignment = "horizontal";
245
287
 
288
+ Progress.report("export-usdz", "Invoking exporter.parse" );
246
289
  //@ts-ignore
247
290
  const arraybuffer = await exporter.parse(this.objectToExport, {
248
291
  ar: {
@@ -260,10 +303,11 @@
260
303
  const blob = new Blob([arraybuffer], { type: 'application/octet-stream' });
261
304
 
262
305
  // cleanup – implicit animation behaviors need to be removed again
263
- for (const go of implicitAnimationBehaviors) {
306
+ for (const go of implicitBehaviors) {
264
307
  GameObject.destroy(go);
265
308
  }
266
309
 
310
+ Progress.report("export-usdz", "Invoking after-export" );
267
311
  this.dispatchEvent(new CustomEvent("after-export", { detail: eventArgs }))
268
312
 
269
313
  // restore XR flags
@@ -277,6 +321,7 @@
277
321
  // this.link.href = URL.createObjectURL(blob2);
278
322
  // this.link.click();
279
323
 
324
+ Progress.end("export-usdz");
280
325
  return blob;
281
326
  }
282
327
 
@@ -396,7 +441,7 @@
396
441
  // that#s the case when no objectToExport is explictly assigned and the whole scene is being exported
397
442
  if(!sessionRoot) sessionRoot = GameObject.getComponentInChildren(this.objectToExport, WebARSessionRoot);
398
443
 
399
- if(debug) console.log("applyWebARSessionRoot", sessionRoot);
444
+ if (debug) console.log("applyWebARSessionRoot", sessionRoot);
400
445
 
401
446
  if (!sessionRoot) {
402
447
  if(debug) console.warn("No WebARSessionRoot found in parent hierarchy", this.objectToExport);
@@ -406,7 +451,6 @@
406
451
  // either apply the scale to the object being exported or to the sessionRoot object itself
407
452
  const target = hasSessionRootInParentHierarchy ? this.objectToExport : sessionRoot.gameObject;
408
453
  const scale = 1 / sessionRoot!.arScale;
409
- if (debug) console.log("AR Session Root scale", scale, target);
410
454
  target.matrix.makeScale(scale, scale, scale);
411
455
  if (sessionRoot.invertForward) {
412
456
  target.matrix.multiply(USDZExporter.invertForwardMatrix);
src/engine-components/export/usdz/extensions/USDZText.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { TextAnchor } from "../../../ui/Text.js";
7
7
  import type { IUSDExporterExtension } from "../Extension.js";
8
8
  import type { IBehaviorElement } from "../extensions/behavior/BehavioursBuilder.js";
9
- import { USDDocument, USDObject, USDWriter, USDZExporterContext } from "../ThreeUSDZExporter.js";
9
+ import { getMaterialNameForUSD,USDDocument, USDObject, USDWriter, USDZExporterContext } from "../ThreeUSDZExporter.js";
10
10
 
11
11
 
12
12
  export enum TextWrapMode {
@@ -106,11 +106,10 @@
106
106
  writer.appendLine(`token verticalAlignment = "${this.verticalAlignment}"`);
107
107
 
108
108
  if (this.material !== undefined) {
109
- writer.appendLine(`rel material:binding = </StageRoot/Materials/Material_${this.material.id}>`)
109
+ writer.appendLine(`rel material:binding = </StageRoot/Materials/${getMaterialNameForUSD(this.material)}>`)
110
110
  }
111
111
 
112
112
  writer.closeBlock();
113
-
114
113
  }
115
114
 
116
115
  }
src/engine-components/webxr/WebARCameraBackground.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { serializable } from "../../engine/engine_serialization_decorator.js";
14
14
  import { getParam } from "../../engine/engine_utils.js";
15
- import { NeedleXREventArgs } from "../../engine/engine_xr.js";
15
+ import type { NeedleXREventArgs } from "../../engine/engine_xr.js";
16
16
  import { Behaviour } from "../Component.js";
17
17
  import { RGBAColor } from "../js-extensions/RGBAColor.js"
18
18
 
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  import { Context } from "../../engine/engine_context.js";
6
6
  import { ObjectUtils, PrimitiveType } from "../../engine/engine_create_objects.js";
7
7
  import { destroy, instantiate } from "../../engine/engine_gameobject.js";
8
- import { NEPointerEvent } from "../../engine/engine_input.js";
8
+ import { InputEventQueue, NEPointerEvent } from "../../engine/engine_input.js";
9
9
  import { serializable } from "../../engine/engine_serialization_decorator.js";
10
- import { IComponent, IGameObject } from "../../engine/engine_types.js";
10
+ import type { IComponent, IGameObject } from "../../engine/engine_types.js";
11
11
  import { getParam } from "../../engine/engine_utils.js";
12
- import { NeedleXRController, NeedleXREventArgs, NeedleXRHitTestResult, NeedleXRSession } from "../../engine/engine_xr.js";
12
+ import { NeedleXRController, type NeedleXREventArgs, type NeedleXRHitTestResult, NeedleXRSession } from "../../engine/engine_xr.js";
13
13
  import { Behaviour, GameObject } from "../Component.js";
14
14
 
15
15
  // https://github.com/takahirox/takahirox.github.io/blob/master/js.mmdeditor/examples/js/controls/DeviceOrientationControls.js
@@ -135,11 +135,11 @@
135
135
  }
136
136
  this._reticle.length = 0;
137
137
  this._isPlacing = true;
138
- this.context.input.addEventListener("pointerup", this.onPlaceScene);
138
+ this.context.input.addEventListener("pointerup", this.onPlaceScene, InputEventQueue.Early);
139
139
  }
140
140
  onLeaveXR() {
141
141
  // TODO: WebARSessionRoot doesnt work when we enter passthrough and leave XR without having placed the session!!!
142
- this.context.input.removeEventListener("pointerup", this.onPlaceScene)
142
+ this.context.input.removeEventListener("pointerup", this.onPlaceScene, InputEventQueue.Early);
143
143
  this.onRevertSceneChanges();
144
144
  // this._anchor?.delete();
145
145
  this._anchor = null;
@@ -305,6 +305,7 @@
305
305
 
306
306
  // if we place the scene we don't want this event to be propagated to any sub-objects (via the EventSystem) anymore and trigger e.g. a click on objects for the "place tap" event
307
307
  evt.stopImmediatePropagation();
308
+ evt.use();
308
309
 
309
310
  this._isPlacing = false;
310
311
  this.context.input.removeEventListener("pointerup", this.onPlaceScene);
@@ -455,20 +456,7 @@
455
456
  }
456
457
 
457
458
  private _enabled: boolean = false;
458
- enable() {
459
- if (this._enabled) return;
460
- this._enabled = true;
461
- window.addEventListener('touchstart', this.touchStart, { passive: false });
462
- window.addEventListener('touchmove', this.touchMove, { passive: false });
463
- window.addEventListener('touchend', this.touchEnd, { passive: false });
464
- }
465
- disable() {
466
- if (!this._enabled) return;
467
- this._enabled = false;
468
- window.removeEventListener('touchstart', this.touchStart);
469
- window.removeEventListener('touchmove', this.touchMove);
470
- window.removeEventListener('touchend', this.touchEnd);
471
- }
459
+
472
460
  reset() {
473
461
  this._scale = 1;
474
462
  this.offset.identity();
@@ -494,6 +482,51 @@
494
482
  // matrix.premultiply(this.offset).premultiply(this._rotationMatrix)
495
483
  }
496
484
 
485
+
486
+ private readonly currentlyUsedPointerIds = new Set<number>();
487
+ private readonly currentlyUnusedPointerIds = new Set<number>();
488
+ get isActive() {
489
+ return this.currentlyUsedPointerIds.size <= 0 && this.currentlyUnusedPointerIds.size > 0;
490
+ }
491
+
492
+ enable() {
493
+ if (this._enabled) return;
494
+ this._enabled = true;
495
+
496
+ this.context.input.addEventListener("pointerdown", this.onPointerDownEarly, InputEventQueue.Early);
497
+ this.context.input.addEventListener("pointerdown", this.onPointerDownLate, InputEventQueue.Late);
498
+ this.context.input.addEventListener("pointerup", this.onPointerUpEarly, InputEventQueue.Early);
499
+
500
+ // TODO: refactor the following events to use the input system
501
+ window.addEventListener('touchstart', this.touchStart, { passive: false });
502
+ window.addEventListener('touchmove', this.touchMove, { passive: false });
503
+ window.addEventListener('touchend', this.touchEnd, { passive: false });
504
+ }
505
+ disable() {
506
+ if (!this._enabled) return;
507
+ this._enabled = false;
508
+
509
+ this.context.input.removeEventListener("pointerdown", this.onPointerDownEarly, InputEventQueue.Early);
510
+ this.context.input.removeEventListener("pointerdown", this.onPointerDownLate, InputEventQueue.Late);
511
+ this.context.input.removeEventListener("pointerup", this.onPointerUpEarly, InputEventQueue.Early);
512
+
513
+ window.removeEventListener('touchstart', this.touchStart);
514
+ window.removeEventListener('touchmove', this.touchMove);
515
+ window.removeEventListener('touchend', this.touchEnd);
516
+ }
517
+
518
+ private onPointerDownEarly = (e: NEPointerEvent) => {
519
+ if (this.isActive) e.stopPropagation();
520
+ };
521
+ private onPointerDownLate = (e: NEPointerEvent) => {
522
+ if (e.used) this.currentlyUsedPointerIds.add(e.pointerId);
523
+ else if(this.currentlyUsedPointerIds.size <= 0) this.currentlyUnusedPointerIds.add(e.pointerId);
524
+ };
525
+ private onPointerUpEarly = (e: NEPointerEvent) => {
526
+ this.currentlyUsedPointerIds.delete(e.pointerId);
527
+ this.currentlyUnusedPointerIds.delete(e.pointerId);
528
+ };
529
+
497
530
  // private _needsUpdate: boolean = true;
498
531
  // private _rotationMatrix: Matrix4 = new Matrix4();
499
532
  // private updateMatrix() {
@@ -540,6 +573,7 @@
540
573
  }
541
574
  private touchMove = (evt: TouchEvent) => {
542
575
  if (evt.defaultPrevented) return;
576
+ if (!this.isActive) return;
543
577
 
544
578
  if (evt.touches.length === 1) {
545
579
  // if we had multiple touches before due to e.g. pinching / rotating
src/engine-components/webxr/WebXR.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { AssetReference } from "../../engine/engine_addressables.js";
5
5
  import { serializable } from "../../engine/engine_serialization.js";
6
6
  import { getParam, isDesktop, isiOS, isMobileDevice, isQuest, isSafari } from "../../engine/engine_utils.js";
7
- import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
7
+ import { type NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
8
8
  import { PlayerSync } from "../../engine-components-experimental/networking/PlayerSync.js";
9
9
  import { Behaviour, GameObject } from "../Component.js";
10
10
  import { USDZExporter } from "../export/usdz/USDZExporter.js";
src/engine-components/webxr/WebXRImageTracking.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { AssetReference } from "../../engine/engine_addressables.js";
5
5
  import { serializable } from "../../engine/engine_serialization.js";
6
6
  import { CircularBuffer, getParam } from "../../engine/engine_utils.js";
7
- import { NeedleXREventArgs, NeedleXRSession } from "../../engine/xr/index.js";
7
+ import { type NeedleXREventArgs, NeedleXRSession } from "../../engine/xr/index.js";
8
8
  import { imageToCanvas,USDWriter, USDZExporterContext } from "../../engine-components/export/usdz/ThreeUSDZExporter.js";
9
9
  import { USDZExporter } from "../../engine-components/export/usdz/USDZExporter.js";
10
10
  import { Behaviour, GameObject } from "../Component.js";
src/engine-components/webxr/WebXRPlaneTracking.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { serializable } from "../../engine/engine_serialization.js";
7
7
  import type { Vec3 } from "../../engine/engine_types.js";
8
8
  import { getParam } from "../../engine/engine_utils.js";
9
- import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
9
+ import type { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
10
10
  import { MeshCollider } from "../Collider.js";
11
11
  import { Behaviour, GameObject } from "../Component.js";
12
12
  // import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';
src/engine-components/webxr/WebXRRig.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { AxesHelper, Euler, Object3D, Quaternion, Vector3 } from "three";
1
+ import { AxesHelper, Object3D, Vector3 } from "three";
2
2
 
3
3
  import { serializable } from "../../engine/engine_serialization_decorator.js";
4
4
  import type { IGameObject } from "../../engine/engine_types.js";
5
5
  import { getParam } from "../../engine/engine_utils.js";
6
- import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
7
- import { IXRRig } from "../../engine/engine_xr.js";
6
+ import type { IXRRig } from "../../engine/engine_xr.js";
7
+ import { type NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
8
8
  import { Behaviour } from "../Component.js";
9
9
  import { BoxGizmo } from "../Gizmos.js";
10
10
 
src/engine-components/webxr/controllers/XRControllerFollow.ts CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { serializable } from "../../../engine/engine_serialization_decorator.js";
3
- import { NeedleXREventArgs } from "../../../engine/engine_xr.js";
3
+ import type { NeedleXREventArgs } from "../../../engine/engine_xr.js";
4
4
  import { Behaviour } from "../../Component.js";
5
5
 
6
6
 
src/engine-components/webxr/controllers/XRControllerModel.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  import { Gizmos } from "../../../engine/engine_gizmos.js";
10
10
  import { addDracoAndKTX2Loaders } from "../../../engine/engine_loaders.js";
11
11
  import { serializable } from "../../../engine/engine_serialization_decorator.js";
12
- import { IGameObject } from "../../../engine/engine_types.js";
12
+ import type { IGameObject } from "../../../engine/engine_types.js";
13
13
  import { getParam } from "../../../engine/engine_utils.js";
14
- import { NeedleXRController, NeedleXRControllerEventArgs, NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
14
+ import { NeedleXRController, type NeedleXRControllerEventArgs, type NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
15
15
  import { Behaviour, GameObject } from "../../Component.js"
16
16
 
17
17
  const debug = getParam("debugwebxr");
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -5,15 +5,15 @@
5
5
 
6
6
  import { Gizmos } from "../../../engine/engine_gizmos.js";
7
7
  import { Mathf } from "../../../engine/engine_math.js";
8
- import { RaycastTestObjectCallback } from "../../../engine/engine_physics.js";
8
+ import type { RaycastTestObjectCallback } from "../../../engine/engine_physics.js";
9
9
  import { serializable } from "../../../engine/engine_serialization.js"
10
10
  import { getTempVector, getWorldQuaternion, getWorldScale } from "../../../engine/engine_three_utils.js";
11
- import { IGameObject } from "../../../engine/engine_types.js";
11
+ import type { IGameObject } from "../../../engine/engine_types.js";
12
12
  import { getParam } from "../../../engine/engine_utils.js";
13
- import { NeedleXRController, NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
13
+ import { NeedleXRController, type NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
14
14
  import { Behaviour, GameObject } from "../../Component.js"
15
15
  import { TeleportTarget } from "../TeleportTarget.js";
16
- import { XRMovementBehaviour } from "../types.js";
16
+ import type { XRMovementBehaviour } from "../types.js";
17
17
 
18
18
  const debug = getParam("debugwebxr");
19
19
 
src/engine/xr/XRRig.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { IComponent } from "../engine_types.js";
1
+ import type { IComponent } from "../engine_types.js";
2
2
 
3
3
 
4
4
  export interface IXRRig extends Pick<IComponent, "gameObject"> {
src/engine/debug/debug_spatial_console.ts ADDED
@@ -0,0 +1,395 @@
1
+ import { Layers, Material, Matrix3, Matrix4, Mesh, Object3D, PerspectiveCamera, Quaternion, Texture, Vector2, Vector3, Vector4 } from "three";
2
+ import ThreeMeshUI from "three-mesh-ui";
3
+ import type { Options } from "three-mesh-ui/build/types/core/elements/MeshUIBaseElement";
4
+
5
+ import { ContextRegistry } from "../engine_context_registry.js";
6
+ import { OneEuroFilterXYZ } from "../engine_math.js";
7
+ import { lookAtObject } from "../engine_three_utils.js";
8
+ import type { IContext, IGameObject } from "../engine_types.js";
9
+
10
+
11
+ let _isActive = false;
12
+
13
+ /** Enable a spatial debug console that follows the camera */
14
+ export function enableSpatialConsole(active: boolean) {
15
+ if (active) {
16
+ if (_isActive) return;
17
+ _isActive = true;
18
+ onEnable();
19
+
20
+ } else {
21
+ if (!_isActive) return;
22
+ _isActive = false;
23
+ onDisable();
24
+ }
25
+ }
26
+
27
+
28
+ const originalConsoleMethods: { [key: string]: Function | undefined } = {
29
+ "log": undefined,
30
+ "warn": undefined,
31
+ "error": undefined,
32
+ };
33
+
34
+
35
+
36
+
37
+ class SpatialMessagesHandler {
38
+
39
+ private readonly familyName = "needle-xr";
40
+ private root: ThreeMeshUI.Block | null = null;
41
+
42
+ private context: IContext | null = null;
43
+ private readonly defaultFontSize = .06;
44
+
45
+ constructor() {
46
+ this.ensureFont();
47
+ }
48
+
49
+ onEnable() {
50
+ this.context = ContextRegistry.Current || ContextRegistry.All[0];
51
+ this.context.pre_render_callbacks.push(this.onBeforeRender)
52
+ }
53
+ onDisable() {
54
+ this.context?.pre_render_callbacks.splice(this.context?.pre_render_callbacks.indexOf(this.onBeforeRender), 1);
55
+ }
56
+
57
+ private readonly targetObject = new Object3D();
58
+ private readonly oneEuroFilter = new OneEuroFilterXYZ(90, .1);
59
+ private _lastElementRemoveTime = 0;
60
+
61
+ private onBeforeRender = () => {
62
+ const cam = this.context?.mainCamera as any as IGameObject;
63
+ if (this.context && cam instanceof PerspectiveCamera) {
64
+ const root = this.getRoot() as any as IGameObject;
65
+
66
+ // TODO: need to figure out why this happens when entering VR (in the simulator at least)
67
+ if (Number.isNaN(root.position.x))
68
+ root.position.set(0, 0, 0);
69
+
70
+ this.context.scene.add(this.targetObject);
71
+
72
+ const dist = 1.8;
73
+ const forward = cam.worldForward;
74
+ forward.y = 0;
75
+ forward.normalize().multiplyScalar((dist * 100) / cam.fov);
76
+ this.targetObject.position.copy(cam.worldPosition).sub(forward);
77
+ lookAtObject(this.targetObject, cam, false, true);
78
+
79
+ this.oneEuroFilter.filter(this.targetObject.position, root.position, this.context.time.time);
80
+ const step = this.context.time.deltaTime * 10;
81
+ root.quaternion.slerp(this.targetObject.quaternion, step);
82
+
83
+ this.targetObject.removeFromParent();
84
+ this.context.scene.add(this.getRoot() as any);
85
+
86
+ if (this.context.time.time - this._lastElementRemoveTime > .3) {
87
+ this._lastElementRemoveTime = this.context.time.time;
88
+ const now = Date.now();
89
+ for (let i = 0; i < root.children.length; i++) {
90
+ const el = root.children[i];
91
+ if (el instanceof ThreeMeshUI.Text && now - el["_activatedTime"] > 10000) {
92
+ el.removeFromParent();
93
+ this._textBuffer.push(el);
94
+ break;
95
+ }
96
+ }
97
+ }
98
+
99
+ }
100
+ }
101
+
102
+ addLog(type: "log" | "warn" | "error", message: string) {
103
+ const root = this.getRoot();
104
+
105
+ const text = this.getText();
106
+
107
+ let backgroundColor = 0xffffff;
108
+ let fontColor = 0x000000;
109
+ switch (type) {
110
+ case "log":
111
+ backgroundColor = 0xffffff;
112
+ fontColor = 0x000000;
113
+ break;
114
+ case "warn":
115
+ backgroundColor = 0xffee99;
116
+ fontColor = 0x442200;
117
+ break;
118
+ case "error":
119
+ backgroundColor = 0xffaaaa;
120
+ fontColor = 0xaa0000;
121
+ break;
122
+ }
123
+
124
+ text.set({
125
+ backgroundColor: backgroundColor,
126
+ color: fontColor,
127
+ });
128
+
129
+ if (message.length > 1000) message = message.substring(0, 1000) + "...";
130
+
131
+ const hourMinuteSecond = new Date().toLocaleTimeString().split(" ")[0];
132
+ text.textContent = "[" + hourMinuteSecond + "] " + message;
133
+ text.visible = true;
134
+ text["_activatedTime"] = Date.now();
135
+ root.add(text as any);
136
+ if (this.context) this.context.scene.add(root as any);
137
+
138
+ ThreeMeshUI.update();
139
+ }
140
+
141
+ private ensureFont() {
142
+ let fontFamily = ThreeMeshUI.FontLibrary.getFontFamily(this.familyName);
143
+
144
+ if (!fontFamily) {
145
+ fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(this.familyName);
146
+ const variant = fontFamily.addVariant("normal", "normal", "./include/needle/arial-msdf.json", "./include/needle/arial.png") as any as ThreeMeshUI.FontVariant;
147
+ variant?.addEventListener('ready', () => {
148
+ ThreeMeshUI.update();
149
+ });
150
+ }
151
+ }
152
+
153
+
154
+ private readonly textOptions: Options = {
155
+ fontSize: this.defaultFontSize,
156
+ fontFamily: this.familyName,
157
+ padding: .03,
158
+ margin: .005,
159
+ color: 0x000000,
160
+ backgroundColor: 0xffffff,
161
+ backgroundOpacity: .4,
162
+ borderRadius: .03,
163
+ };
164
+ private readonly _textBuffer: ThreeMeshUI.Text[] = [];
165
+ private getText() {
166
+ const root = this.getRoot();
167
+ if (this._textBuffer.length > 0) {
168
+ const text = this._textBuffer.pop() as any as ThreeMeshUI.Text;
169
+ text.visible = true;
170
+ setTimeout(() => this.disableDepthTestRecursive(text as any), 100);
171
+ return text;
172
+ }
173
+ if (root.children.length > 20) {
174
+ return root.children[0] as any as ThreeMeshUI.Text;
175
+ }
176
+ const newText = new ThreeMeshUI.Text(this.textOptions);
177
+ setTimeout(() => this.disableDepthTestRecursive(newText as any), 500);
178
+ setTimeout(() => this.disableDepthTestRecursive(newText as any), 1500);
179
+ return newText;
180
+ }
181
+ private disableDepthTestRecursive(obj: Object3D) {
182
+ obj.traverseVisible((t: Object3D) => {
183
+ t.renderOrder = 1000;
184
+ t.layers.set(2);
185
+ const mat = (t as Mesh).material as Material;
186
+ if (mat) {
187
+ mat.depthWrite = false;
188
+ mat.depthTest = false;
189
+ }
190
+ ThreeMeshUI.update();
191
+ });
192
+ }
193
+
194
+ private getRoot() {
195
+ if (this.root) {
196
+ return this.root;
197
+ }
198
+
199
+ const fontSize = this.defaultFontSize;
200
+ const defaultOptions: Options = {
201
+ boxSizing: 'border-box',
202
+ fontFamily: this.familyName,
203
+ width: "2.6",
204
+ fontSize: fontSize,
205
+ color: 0x000000,
206
+ lineHeight: 1,
207
+ backgroundColor: 0xffffff,
208
+ backgroundOpacity: 0,
209
+ // borderColor: 0xffffff,
210
+ // borderOpacity: .5,
211
+ // borderWidth: 0.01,
212
+ // padding: 0.01,
213
+ whiteSpace: 'pre-wrap',
214
+ flexDirection: 'column-reverse',
215
+ };
216
+ this.root = new ThreeMeshUI.Block(defaultOptions);
217
+
218
+ return this.root;
219
+ }
220
+ }
221
+
222
+
223
+
224
+ let messagesHandler: SpatialMessagesHandler | null = null;
225
+
226
+ function onEnable() {
227
+ // create messages handler
228
+ if (!messagesHandler) messagesHandler = new SpatialMessagesHandler();
229
+ messagesHandler.onEnable();
230
+
231
+ // save original console methods
232
+ for (const key in originalConsoleMethods) {
233
+ originalConsoleMethods[key] = console[key];
234
+ let isLogging = false;
235
+ console[key] = function () {
236
+ // call original console method
237
+ originalConsoleMethods[key]?.apply(console, arguments);
238
+ // prevent circular calls
239
+ if (isLogging) return;
240
+ try {
241
+ isLogging = true;
242
+ onLog(key as "log" | "warn" | "error", ...arguments);
243
+ } finally {
244
+ isLogging = false;
245
+ }
246
+ };
247
+ }
248
+
249
+ console.log("Enabling Spatial Console");
250
+ }
251
+ function onDisable() {
252
+ messagesHandler?.onDisable();
253
+ for (const key in originalConsoleMethods) {
254
+ console[key] = originalConsoleMethods[key];
255
+ }
256
+ }
257
+
258
+
259
+
260
+ const seen = new Map<string, any>();
261
+
262
+ function onLog(key: "log" | "warn" | "error", ...args: any[]) {
263
+ try {
264
+ seen.clear();
265
+ switch (key) {
266
+ case "log":
267
+ messagesHandler?.addLog("log", getLogString());
268
+ break;
269
+ case "warn":
270
+ messagesHandler?.addLog("warn", getLogString());
271
+ break;
272
+ case "error":
273
+ messagesHandler?.addLog("error", getLogString());
274
+ break;
275
+ }
276
+ }
277
+ catch (err) {
278
+ console.error("Error in spatial console", err);
279
+ }
280
+ finally {
281
+ seen.clear();
282
+ }
283
+
284
+
285
+ function getLogString() {
286
+ let str = "";
287
+ for (let i = 0; i < args.length; i++) {
288
+ const el = args[i];
289
+ str += serialize(el);
290
+ if (i < args.length - 1) str += ", ";
291
+ }
292
+ return str;
293
+ }
294
+ function serialize(value: any, level: number = 0): string {
295
+
296
+ if (typeof value === "string") {
297
+ return "\"" + value + "\"";
298
+ }
299
+ else if (typeof value === "number") {
300
+ const hasDecimal = value % 1 !== 0;
301
+ if (hasDecimal) {
302
+ const str = value.toFixed(5);
303
+ const dotIndex = str.indexOf(".");
304
+ let i = str.length - 1;
305
+ while (i > dotIndex && str[i] === "0") i--;
306
+ return str.substring(0, i + 1);
307
+ }
308
+ return value.toString();
309
+ }
310
+ else if (Array.isArray(value)) {
311
+ let res = "[";
312
+ for (let i = 0; i < value.length; i++) {
313
+ const val = value[i];
314
+ res += serialize(val, level + 1);
315
+ if (i < value.length - 1) res += ", ";
316
+ }
317
+ res += "]";
318
+ return res;
319
+ }
320
+ else if (value === null) {
321
+ return "null";
322
+ }
323
+ else if (value === undefined) {
324
+ return "undefined";
325
+ }
326
+ else if (typeof value === "function") {
327
+ return value.name + "()";
328
+ }
329
+
330
+
331
+ // if (value instanceof Object3D) {
332
+ // const name = value.name?.length > 0 ? value.name : ("object@" + (value["guid"] ?? value.uuid));;
333
+ // return name;
334
+ // }
335
+ if (value instanceof Vector2) return `(${serialize(value.x)}, ${serialize(value.y)})`;
336
+ if (value instanceof Vector3) return `(${serialize(value.x)}, ${serialize(value.y)}, ${serialize(value.z)})`;
337
+ if (value instanceof Vector4) return `(${serialize(value.x)}, ${serialize(value.y)}, ${serialize(value.z)}, ${serialize(value.w)})`;
338
+ if (value instanceof Quaternion) return `(${serialize(value.x)}, ${serialize(value.y)}, ${serialize(value.z)}, ${serialize(value.w)})`;
339
+ if (value instanceof Material) return value.name;
340
+ if (value instanceof Texture) return value.name;
341
+ if (value instanceof Matrix3) return `[${value.elements.join(", ")}]`;
342
+ if (value instanceof Matrix4) return `[${value.elements.join(", ")}]`;
343
+ if (value instanceof Layers) return value.mask.toString();
344
+
345
+ if (typeof value === "object") {
346
+ if (seen.has(value)) return "*";
347
+ let res = "{\n";
348
+ res += pad(level);
349
+ const keys = Object.keys(value);
350
+ let line = "";
351
+ for (let i = 0; i < keys.length; i++) {
352
+ const key = keys[i];
353
+ const val = value[key];
354
+ if (seen.has(val)) {
355
+ line += ""
356
+ continue;
357
+ }
358
+ seen.set(val, true);
359
+ line += key + ":" + serialize(val, level + 1);
360
+ if (i < keys.length - 1) line += ", ";
361
+ if (line.length >= 60) {
362
+ line += "\n";
363
+ line += pad(level);
364
+ res += line;
365
+ line = "";
366
+ }
367
+ }
368
+ res += line;
369
+ res += "\n}";
370
+ return res;
371
+ // return JSON.stringify(value, (_key, value) => {
372
+ // if (seen.has(value)) return seen.get(value);
373
+ // seen.set(value, "-");
374
+ // const res = serialize(value);
375
+ // seen.set(value, res);
376
+ // return _key;
377
+ // }, 1);
378
+ }
379
+
380
+
381
+ return value;
382
+ }
383
+
384
+ function pad(spaces: number) {
385
+ let res = "";
386
+ for (let i = 0; i < spaces; i++) {
387
+ res += " ";
388
+ }
389
+ return res;
390
+ }
391
+ }
392
+
393
+ // // this is just a hack - the spatial console should be enabled from the user or the NeedleXRSession
394
+ // if (getParam("debugwebxr") || getParam("console"))
395
+ // setTimeout(() => enableSpatialConsole(true), 1000);
src/engine/engine_time_utils.ts ADDED
</
@@ -0,0 +1,238 @@
1
+ import { getParam } from "./engine_utils.js";
2
+
3
+ const showProgressLogs = getParam("debugprogress");
4
+
5
+ /** Gets the date formatted as 20240220-161993. When no Date is passed in, the current local date is used. */
6
+ export function getFormattedDate(date?: Date) {
7
+ date = date || new Date();
8
+
9
+ const month = date.getMonth() + 1;
10
+ const day = date.getDate();
11
+ const hour = date.getHours();
12
+ const min = date.getMinutes();
13
+ const sec = date.getSeconds();
14
+
15
+ const s_month = (month < 10 ? "0" : "") + month;
16
+ const s_day = (day < 10 ? "0" : "") + day;
17
+ const s_hour = (hour < 10 ? "0" : "") + hour;
18
+ const s_min = (min < 10 ? "0" : "") + min;
19
+ const s_sec = (sec < 10 ? "0" : "") + sec;
20
+
21
+ return date.getFullYear() + s_month + s_day + "-" + s_hour + s_min + s_sec;
22
+ }
23
+
24
+ declare type ProgressOptions = {
25
+ message?: string,
26
+ progress?: number,
27
+ autoStep?: boolean | number;
28
+ currentStep?: number,
29
+ totalSteps?: number
30
+ };
31
+
32
+ declare type ProgressStartOptions = {
33
+ /** This progress scope will be nested below parentScope */
34
+ parentScope?: string,
35
+ /** Callback with progress in 0..1 range. */
36
+ onProgress?: (progress: number) => void,
37
+ /** Log timings using console.time() and console.timeLog(). */
38
+ logTimings?: boolean,
39
+ };
40
+
41
+ /** Progress reporting utility.
42
+ * See `Progress.start` for usage examples.
43
+ */
44
+ export class Progress {
45
+
46
+ /** Start a new progress reporting scope. Make sure to close it with Progress.end.
47
+ * @param scope The scope to start progress reporting for.
48
+ * @param options Parent scope, onProgress callback and logging. If only a string is provided, it's used as parentScope.
49
+ * @example
50
+ * // Manual usage:
51
+ * Progress.start("export-usdz", undefined, (progress) => console.log("Progress: " + progress));
52
+ * Progress.report("export-usdz", { message: "Exporting object 1", currentStep: 1, totalSteps: 3 });
53
+ * Progress.report("export-usdz", { message: "Exporting object 2", currentStep: 2, totalSteps: 3 });
54
+ * Progress.report("export-usdz", { message: "Exporting object 3", currentStep: 3, totalSteps: 3 });
55
+ *
56
+ * // Auto step usage:
57
+ * Progress.start("export-usdz", undefined, (progress) => console.log("Progress: " + progress));
58
+ * Progress.report("export-usdz", { message: "Exporting objects", autoStep: true, totalSteps: 3 });
59
+ * Progress.report("export-usdz", "Exporting object 1");
60
+ * Progress.report("export-usdz", "Exporting object 2");
61
+ * Progress.report("export-usdz", "Exporting object 3");
62
+ * Progress.end("export-usdz");
63
+ *
64
+ * // Auto step with weights:
65
+ * Progress.start("export-usdz", undefined, (progress) => console.log("Progress: " + progress));
66
+ * Progress.report("export-usdz", { message: "Exporting objects", autoStep: true, totalSteps: 10 });
67
+ * Progress.report("export-usdz", { message: "Exporting object 1", autoStep: 8 }); // will advance to 80% progress
68
+ * Progress.report("export-usdz", "Exporting object 2"); // 90%
69
+ * Progress.report("export-usdz", "Exporting object 3"); // 100%
70
+ *
71
+ * // Child scopes:
72
+ * Progress.start("export-usdz", undefined, (progress) => console.log("Progress: " + progress));
73
+ * Progress.report("export-usdz", { message: "Overall export", autoStep: true, totalSteps: 2 });
74
+ * Progress.start("export-usdz-objects", "export-usdz");
75
+ * Progress.report("export-usdz-objects", { message: "Exporting objects", autoStep: true, totalSteps: 3 });
76
+ * Progress.report("export-usdz-objects", "Exporting object 1");
77
+ * Progress.report("export-usdz-objects", "Exporting object 2");
78
+ * Progress.report("export-usdz-objects", "Exporting object 3");
79
+ * Progress.end("export-usdz-objects");
80
+ * Progress.report("export-usdz", "Exporting materials");
81
+ * Progress.end("export-usdz");
82
+ *
83
+ * // Enable console logging:
84
+ * Progress.start("export-usdz", { logTimings: true });
85
+ */
86
+ static start(scope: string, options?: ProgressStartOptions | string ) {
87
+ if (typeof options === "string") options = { parentScope: options };
88
+ const p = new ProgressEntry(scope, options);
89
+ progressCache.set(scope, p);
90
+ }
91
+
92
+ /** Report progress for a formerly started scope.
93
+ * @param scope The scope to report progress for.
94
+ * @param options Options for the progress report. If a string is passed, it will be used as the message.
95
+ * @example
96
+ * // auto step and show a message
97
+ * Progress.report("export-usdz", "Exporting object 1");
98
+ * // same as above
99
+ * Progress.report("export-usdz", { message: "Exporting object 1", autoStep: true });
100
+ * // show the current step and total steps and implicitly calculate progress as 10%
101
+ * Progress.report("export-usdz", { currentStep: 1, totalSteps: 10 });
102
+ * // enable auto step mode, following calls that have autoStep true will increase currentStep automatically.
103
+ * Progress.report("export-usdz", { totalSteps: 20, autoStep: true });
104
+ * // show the progress as 50%
105
+ * Progress.report("export-usdz", { progress: 0.5 });
106
+ * // give this step a weight of 20, which changes how progress is calculated. Useful for steps that take longer and/or have child scopes.
107
+ * Progress.report("export-usdz", { message. "Long process", autoStep: 20 });
108
+ * // show the current step and total steps and implicitly calculate progress as 10%
109
+ * Progress.report("export-usdz", { currentStep: 1, totalSteps: 10 });
110
+ */
111
+ static report(scope: string, options?: ProgressOptions | string) {
112
+ const p = progressCache.get(scope);
113
+ if (!p) {
114
+ console.warn("Reporting progress for non-existing scope", scope);
115
+ return;
116
+ }
117
+ if (typeof options === "string") options = { message: options, autoStep: true };
118
+ p.report(options);
119
+ }
120
+
121
+ /** End a formerly started scope. This will also report the progress as 100%.
122
+ * @remarks Will warn if any child scope is still running (progress < 1).
123
+ */
124
+ static end(scope: string) {
125
+ const p = progressCache.get(scope);
126
+ if (!p) return;
127
+ p.end();
128
+ progressCache.delete(scope);
129
+ }
130
+ }
131
+
132
+ const progressCache: Map<string, ProgressEntry> = new Map<string, ProgressEntry>();
133
+
134
+ /** Internal class that handles Progress instances and their parent/child relationship. */
135
+ class ProgressEntry {
136
+ private scopeLabel: string;
137
+ private parentScope?: ProgressEntry;
138
+ private childScopes: Array<ProgressEntry> = [];
139
+ private parentDepth = 0;
140
+ private lastStep? = 0;
141
+ private lastAutoStepWeight = 1;
142
+ private lastTotalSteps? = 0;
143
+ private onProgress?: (progress: number) => void;
144
+ private showLogs: boolean = false;
145
+
146
+ selfProgress: number = 0;
147
+ totalProgress: number = 0;
148
+ selfReports: number = 0;
149
+ totalReports: number = 0;
150
+
151
+ constructor(scope: string, options?: ProgressStartOptions) {
152
+ this.parentScope = options?.parentScope ? progressCache.get(options.parentScope) : undefined;
153
+ if (this.parentScope) {
154
+ this.parentScope.childScopes.push(this);
155
+ this.parentDepth = this.parentScope.parentDepth + 1;
156
+ }
157
+ this.scopeLabel = " ".repeat(this.parentDepth * 2) + scope;
158
+ this.showLogs = options?.logTimings ?? showProgressLogs !== undefined;
159
+ if (this.showLogs) console.time(this.scopeLabel);
160
+ this.onProgress = options?.onProgress;
161
+ }
162
+
163
+ report(options?: ProgressOptions, indirect: boolean = false) {
164
+ if (options) {
165
+ if (options.totalSteps !== undefined)
166
+ this.lastTotalSteps = options.totalSteps;
167
+ if (options.currentStep !== undefined)
168
+ this.lastStep = options.currentStep;
169
+ if (options.autoStep !== undefined) {
170
+ if (options.currentStep === undefined) {
171
+ if (this.lastStep === undefined) this.lastStep = 0;
172
+ const stepIncrease = typeof options.autoStep === "number" ? options.autoStep : 1;
173
+ this.lastStep += this.lastAutoStepWeight;
174
+ this.lastAutoStepWeight = stepIncrease;
175
+ options.currentStep = this.lastStep;
176
+ }
177
+ options.totalSteps = this.lastTotalSteps;
178
+ }
179
+ if (options.progress !== undefined)
180
+ this.selfProgress = options.progress;
181
+ else if (options.currentStep !== undefined && options.totalSteps !== undefined) {
182
+ this.selfProgress = options.currentStep / options.totalSteps;
183
+ }
184
+ }
185
+