@needle-tools/engine 4.12.1 → 4.12.3-next.a27c12e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/{needle-engine.bundle-BPNuWGjI.min.js → needle-engine.bundle-BHCgy9h1.min.js} +138 -139
  3. package/dist/{needle-engine.bundle-BV9DUnnS.umd.cjs → needle-engine.bundle-CLfNhYon.umd.cjs} +127 -128
  4. package/dist/{needle-engine.bundle-C4PyE3dQ.js → needle-engine.bundle-URbCchz5.js} +3531 -3470
  5. package/dist/needle-engine.d.ts +15 -5
  6. package/dist/needle-engine.js +2 -2
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/asap/needle-asap.js +1 -1
  10. package/lib/asap/needle-asap.js.map +1 -1
  11. package/lib/engine/debug/debug_overlay.js +6 -2
  12. package/lib/engine/debug/debug_overlay.js.map +1 -1
  13. package/lib/engine/engine_components.d.ts +2 -1
  14. package/lib/engine/engine_components.js +4 -3
  15. package/lib/engine/engine_components.js.map +1 -1
  16. package/lib/engine/engine_input.d.ts +4 -1
  17. package/lib/engine/engine_input.js +24 -20
  18. package/lib/engine/engine_input.js.map +1 -1
  19. package/lib/engine/js-extensions/Object3D.d.ts +6 -2
  20. package/lib/engine/js-extensions/Object3D.js +6 -4
  21. package/lib/engine/js-extensions/Object3D.js.map +1 -1
  22. package/lib/engine/webcomponents/buttons.d.ts +3 -1
  23. package/lib/engine/webcomponents/buttons.js +2 -2
  24. package/lib/engine/webcomponents/buttons.js.map +1 -1
  25. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +13 -11
  26. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  27. package/lib/engine/webcomponents/needle-button.js +21 -16
  28. package/lib/engine/webcomponents/needle-button.js.map +1 -1
  29. package/lib/engine/webcomponents/needle-engine.ar-overlay.js +7 -5
  30. package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
  31. package/lib/engine/webcomponents/needle-engine.js +1 -1
  32. package/lib/engine/webcomponents/needle-engine.loading.js +1 -1
  33. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  34. package/lib/engine/xr/NeedleXRSession.js +20 -0
  35. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  36. package/lib/engine/xr/TempXRContext.d.ts +7 -2
  37. package/lib/engine/xr/TempXRContext.js +143 -44
  38. package/lib/engine/xr/TempXRContext.js.map +1 -1
  39. package/lib/engine-components/SpectatorCamera.js +3 -2
  40. package/lib/engine-components/SpectatorCamera.js.map +1 -1
  41. package/lib/engine-components/ui/InputField.js +5 -2
  42. package/lib/engine-components/ui/InputField.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/asap/needle-asap.ts +1 -1
  45. package/src/engine/debug/debug_overlay.ts +6 -2
  46. package/src/engine/engine_components.ts +5 -4
  47. package/src/engine/engine_input.ts +26 -19
  48. package/src/engine/js-extensions/Object3D.ts +14 -7
  49. package/src/engine/webcomponents/buttons.ts +2 -2
  50. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +15 -13
  51. package/src/engine/webcomponents/needle-button.ts +23 -19
  52. package/src/engine/webcomponents/needle-engine.ar-overlay.ts +10 -9
  53. package/src/engine/webcomponents/needle-engine.loading.ts +1 -1
  54. package/src/engine/webcomponents/needle-engine.ts +1 -1
  55. package/src/engine/xr/NeedleXRSession.ts +22 -0
  56. package/src/engine/xr/TempXRContext.ts +155 -47
  57. package/src/engine-components/SpectatorCamera.ts +2 -1
  58. package/src/engine-components/ui/InputField.ts +11 -6
@@ -22,6 +22,8 @@ import { markHierarchyDirty } from "../engine_mainloop_utils.js";
22
22
  import { applyPrototypeExtensions, registerPrototypeExtensions } from "./ExtensionUtils.js";
23
23
 
24
24
 
25
+ // #region Type Declarations
26
+
25
27
  // NOTE: keep in sync with method declarations below
26
28
  declare module 'three' {
27
29
  export interface Object3D {
@@ -95,9 +97,11 @@ declare module 'three' {
95
97
  getComponents<T extends IComponent>(type: Constructor<T>, arr?: []): T[];
96
98
  /**
97
99
  * Get a Needle Engine component from the {@link Object3D} or its children. This will search on the current Object and all its children.
100
+ * @param type The type of the component to search for.
101
+ * @param includeInactive If true, also inactive components are considered. Default is false.
98
102
  * @returns The component instance or null if not found.
99
103
  */
100
- getComponentInChildren<T extends IComponent>(type: Constructor<T>): T | null;
104
+ getComponentInChildren<T extends IComponent>(type: Constructor<T>, includeInactive?: boolean): T | null;
101
105
  /**
102
106
  * Get all components of a specific type from the {@link Object3D} or its children. This will search on the current Object and all its children.
103
107
  * @param arr Optional array to fill with the found components.
@@ -106,9 +110,11 @@ declare module 'three' {
106
110
  getComponentsInChildren<T extends IComponent>(type: Constructor<T>, arr?: []): T[];
107
111
  /**
108
112
  * Get a Needle Engine component from the {@link Object3D} or its parents. This will search on the current Object and all its parents.
113
+ * @param type The type of the component to search for.
114
+ * @param includeInactive If true, also inactive components are considered. Default is false.
109
115
  * @returns The component instance or null if not found.
110
116
  */
111
- getComponentInParent<T extends IComponent>(type: Constructor<T>): T | null;
117
+ getComponentInParent<T extends IComponent>(type: Constructor<T>, includeInactive?: boolean): T | null;
112
118
  /**
113
119
  * Get all Needle Engine components of a specific type from the {@link Object3D} or its parents. This will search on the current Object and all its parents.
114
120
  * @param arr Optional array to fill with the found components.
@@ -200,6 +206,7 @@ if (NEEDLE_ENGINE_FEATURE_FLAGS.experimentalSmartHierarchyUpdate) {
200
206
  }
201
207
  }
202
208
 
209
+ // #region Prototype Method Implementations
203
210
 
204
211
  Object3D.prototype["SetActive"] = function (active: boolean) {
205
212
  this.visible = active;
@@ -213,7 +220,6 @@ Object3D.prototype["destroy"] = function () {
213
220
  destroy(this);
214
221
  }
215
222
 
216
-
217
223
  Object3D.prototype["addComponent"] = function <T extends IComponent>(comp: T | ConstructorConcrete<T>, init?: ComponentInit<T>) {
218
224
  return addComponent(this, comp, init);
219
225
  }
@@ -238,22 +244,23 @@ Object3D.prototype["getComponents"] = function <T extends IComponent>(type: Cons
238
244
  return getComponents(this, type, arr);
239
245
  }
240
246
 
241
- Object3D.prototype["getComponentInChildren"] = function <T extends IComponent>(type: Constructor<T>) {
242
- return getComponentInChildren(this, type);
247
+ Object3D.prototype["getComponentInChildren"] = function <T extends IComponent>(type: Constructor<T>, includeInactive: boolean = false) {
248
+ return getComponentInChildren(this, type, includeInactive);
243
249
  }
244
250
 
245
251
  Object3D.prototype["getComponentsInChildren"] = function <T extends IComponent>(type: Constructor<T>, arr?: []) {
246
252
  return getComponentsInChildren(this, type, arr);
247
253
  }
248
254
 
249
- Object3D.prototype["getComponentInParent"] = function <T extends IComponent>(type: Constructor<T>) {
250
- return getComponentInParent(this, type);
255
+ Object3D.prototype["getComponentInParent"] = function <T extends IComponent>(type: Constructor<T>, includeInactive: boolean = false) {
256
+ return getComponentInParent(this, type, includeInactive);
251
257
  }
252
258
 
253
259
  Object3D.prototype["getComponentsInParent"] = function <T extends IComponent>(type: Constructor<T>, arr?: []) {
254
260
  return getComponentsInParent(this, type, arr);
255
261
  }
256
262
 
263
+ // #region Prototype Property Implementations
257
264
 
258
265
  // this is a fix to allow gameObject active animation be applied to a three object
259
266
  if (!Object.getOwnPropertyDescriptor(Object3D.prototype, "activeSelf")) {
@@ -176,7 +176,7 @@ export class ButtonsFactory {
176
176
  * The QR code will be generated with the current URL when the button is clicked
177
177
  * @returns the QR code button element
178
178
  */
179
- createQRCode(): HTMLButtonElement {
179
+ createQRCode(opts?: { anchorElement?: HTMLElement }): HTMLButtonElement {
180
180
 
181
181
  if (this._qrButton) return this._qrButton;
182
182
 
@@ -223,7 +223,7 @@ export class ButtonsFactory {
223
223
  // TODO: we would need to search for the right engine element to insert this into if there are more
224
224
  // Insert the QR code overlay inside the needle-engine element
225
225
  const engine_element = document.body.querySelector("needle-engine");
226
- const parent = engine_element || document.body;
226
+ const parent = opts?.anchorElement?.parentElement || engine_element || document.body;
227
227
  parent.appendChild(qrCodeContainer);
228
228
  const containerRect = qrCodeElement.getBoundingClientRect();
229
229
  const buttonRect = qrCodeButton.getBoundingClientRect();
@@ -8,7 +8,7 @@ import type { Context } from "../../engine_setup.js";
8
8
  import { lookAtObject } from "../../engine_three_utils.js";
9
9
  import { IComponent, IContext, IGameObject } from "../../engine_types.js";
10
10
  import { TypeStore } from "../../engine_typestore.js";
11
- import { DeviceUtilities,getParam } from "../../engine_utils.js";
11
+ import { DeviceUtilities, getParam } from "../../engine_utils.js";
12
12
  import { getIconTexture, isIconElement } from "../icons.js";
13
13
 
14
14
  const debug = getParam("debugspatialmenu");
@@ -303,17 +303,19 @@ export class NeedleSpatialMenu {
303
303
  // this._context.scene.add(logoObject);
304
304
  const textureLoader = new TextureLoader();
305
305
  textureLoader.load("./include/needle/poweredbyneedle.webp", (texture) => {
306
- onClick.allowModifyUI = false;
307
- firstLabel.removeFromParent();
308
- secondLabel.removeFromParent();
309
- const aspect = texture.image.width / texture.image.height;
310
- this._poweredByNeedleElement?.set({
311
- backgroundImage: texture,
312
- backgroundOpacity: 1,
313
- width: .1 * aspect,
314
- height: .1
315
- });
316
- this.markDirty();
306
+ if (texture) {
307
+ onClick.allowModifyUI = false;
308
+ firstLabel.removeFromParent();
309
+ secondLabel.removeFromParent();
310
+ const aspect = texture.image.width / texture.image.height;
311
+ this._poweredByNeedleElement?.set({
312
+ backgroundImage: texture,
313
+ backgroundOpacity: 1,
314
+ width: .1 * aspect,
315
+ height: .1
316
+ });
317
+ this.markDirty();
318
+ }
317
319
  });
318
320
 
319
321
  }
@@ -342,7 +344,7 @@ export class NeedleSpatialMenu {
342
344
  if (!fontFamily) {
343
345
  fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(this.familyName);
344
346
  const normal = fontFamily.addVariant("normal", "normal", "./include/needle/arial-msdf.json", "./include/needle/arial.png") as any as ThreeMeshUI.FontVariant;
345
- /** @ts-ignore */
347
+ /** @ts-ignore */
346
348
  normal?.addEventListener('ready', () => {
347
349
  this.markDirty();
348
350
  });
@@ -1,4 +1,5 @@
1
1
  import { isDevEnvironment } from "../debug/index.js";
2
+ import { ButtonsFactory } from "./buttons.js";
2
3
  import { iconFontUrl, loadFont } from "./fonts.js";
3
4
  import { WebXRButtonFactory } from "./WebXRButtons.js";
4
5
 
@@ -41,7 +42,7 @@ const isDev = isDevEnvironment();
41
42
  */
42
43
  export class NeedleButtonElement extends HTMLElement {
43
44
 
44
- static observedAttributes = ["ar", "vr", "quicklook"];
45
+ static observedAttributes = ["ar", "vr", "quicklook", "qrcode"];
45
46
 
46
47
  constructor() {
47
48
  super();
@@ -62,13 +63,13 @@ export class NeedleButtonElement extends HTMLElement {
62
63
  #button: HTMLButtonElement | undefined;
63
64
  /** If AR or VR is requested we create and use the webxr button factory to create a button with default behaviour */
64
65
  #webxrfactory: WebXRButtonFactory | undefined;
66
+ #buttonfactory: ButtonsFactory | undefined;
65
67
 
66
68
  #observer: MutationObserver | undefined;
67
69
 
68
70
  #update() {
69
71
  this.#button?.remove();
70
72
 
71
-
72
73
  if (this.getAttribute("ar") != null) {
73
74
  this.#webxrfactory ??= new WebXRButtonFactory()
74
75
  this.#button = this.#webxrfactory.createARButton();
@@ -81,11 +82,15 @@ export class NeedleButtonElement extends HTMLElement {
81
82
  this.#webxrfactory ??= new WebXRButtonFactory()
82
83
  this.#button = this.#webxrfactory.createQuicklookButton();
83
84
  }
85
+ else if (this.getAttribute("qrcode") != null) {
86
+ this.#buttonfactory ??= new ButtonsFactory();
87
+ this.#button = this.#buttonfactory.createQRCode({ anchorElement: this });
88
+ }
84
89
  else {
85
90
  if (isDev) {
86
91
  console.warn("No button type specified for <needle-button>. Use either ar, vr or quicklook attribute.")
87
92
  }
88
- else {
93
+ else {
89
94
  console.debug("No button type specified for <needle-button>. Use either ar, vr or quicklook attribute.")
90
95
  }
91
96
  return;
@@ -96,30 +101,29 @@ export class NeedleButtonElement extends HTMLElement {
96
101
  this.#styles ??= document.createElement("style");
97
102
  this.#styles.innerHTML = `
98
103
  button {
99
- all: initial;
100
- cursor: inherit;
101
- color: inherit;
102
- font-family: inherit;
103
- gap: inherit;
104
- white-space: nowrap;
104
+ all: unset;
105
105
  }
106
106
  `;
107
107
  const hasUnstyledAttribute = this.getAttribute("unstyled") != undefined;
108
108
  if (!hasUnstyledAttribute) {
109
109
  this.#styles.innerHTML += `
110
110
  :host {
111
- display: inline-block;
112
- background: rgba(255, 255, 255, .8);
113
- backdrop-filter: blur(10px);
111
+ display: inline-flex;
112
+ align-items: center;
113
+ justify-content: center;
114
114
  width: fit-content;
115
- transition: background .2s;
116
115
 
117
- cursor: pointer;
118
116
  padding: 0.4rem .5rem;
119
- border-radius: 0.8rem;
120
- color: black;
117
+ border-radius: 100vw;
118
+
121
119
  background: rgba(245, 245, 245, .8);
120
+ backdrop-filter: blur(10px);
121
+
122
+ cursor: pointer;
123
+ color: black;
122
124
  outline: rgba(0,0,0,.05) 1px solid;
125
+
126
+ transition: all .2s;
123
127
  }
124
128
  :host(:hover) {
125
129
  background: rgba(255, 255, 255, 1);
@@ -152,8 +156,8 @@ export class NeedleButtonElement extends HTMLElement {
152
156
  this.#observer?.disconnect();
153
157
  this.#observer ??= new MutationObserver(() => this.#updateVisibility());
154
158
  this.#observer.observe(this.#button, { attributes: true });
155
- if(isDev) {
156
- console.log("Needle Button updated")
159
+ if (isDev) {
160
+ console.log("Needle Button updated", this);
157
161
  }
158
162
  }
159
163
 
@@ -170,7 +174,7 @@ export class NeedleButtonElement extends HTMLElement {
170
174
 
171
175
  #onclick = (_ev: MouseEvent) => {
172
176
  if (isDev) {
173
- console.log("Needle Button clicked")
177
+ console.log("Needle Button clicked", { defaultPrevented: _ev.defaultPrevented, hasButton: !!this.#button });
174
178
  }
175
179
  if (_ev.defaultPrevented) return;
176
180
 
@@ -1,5 +1,5 @@
1
1
  import { Context } from "../engine_setup.js";
2
- import { DeviceUtilities,getParam } from "../engine_utils.js";
2
+ import { DeviceUtilities, getParam } from "../engine_utils.js";
3
3
 
4
4
  const debug = getParam("debugoverlay");
5
5
  export const arContainerClassName = "ar";
@@ -28,7 +28,7 @@ export class AROverlayHandler {
28
28
  this.currentSession = session;
29
29
  this.arContainer = overlayContainer;
30
30
 
31
- if (DeviceUtilities.isMozillaXR()) {
31
+ if (DeviceUtilities.isMozillaXR() || DeviceUtilities.isNeedleAppClip()) {
32
32
  const arElements = context.domElement!.children;
33
33
  for (let i = 0; i < arElements?.length; i++) {
34
34
  const el = arElements[i];
@@ -38,10 +38,9 @@ export class AROverlayHandler {
38
38
  this.arContainer?.appendChild(el);
39
39
  }
40
40
 
41
- if(overlayContainer) {
41
+ if (overlayContainer) {
42
42
  this.originalDomOverlayParent = overlayContainer.parentNode;
43
- if (this.originalDomOverlayParent)
44
- {
43
+ if (this.originalDomOverlayParent) {
45
44
  console.log("Reparent DOM Overlay to body", overlayContainer, overlayContainer.style.display);
46
45
  // mozilla webxr does hide elements on session start
47
46
  // this is only necessary if we generated the overlay element
@@ -119,18 +118,20 @@ export class AROverlayHandler {
119
118
 
120
119
  private ensureQuitARButton(element: HTMLElement) {
121
120
 
122
- // No quit button in app clips, we provide one via the native UI
123
- if (DeviceUtilities.isNeedleAppClip()) {
124
- return;
125
- }
126
121
 
127
122
  const quitARSlot = document.createElement("slot");
123
+ quitARSlot.style.display = "contents";
128
124
  quitARSlot.setAttribute("name", "quit-ar");
129
125
  this.appendElement(quitARSlot, element);
130
126
  this._createdAROnlyElements.push(quitARSlot);
131
127
  // for mozilla XR reparenting we have to make sure the close button is clickable so we set it on the element directly
132
128
  // it's in general perhaps more safe to set it on the element to ensure it's clickable
133
129
  quitARSlot.style.pointerEvents = "auto";
130
+ // No default quit button in the top right corner in app clips
131
+ // we provide one via the native UI
132
+ if (DeviceUtilities.isNeedleAppClip()) {
133
+ quitARSlot.style.display = "none";
134
+ }
134
135
 
135
136
  // We want to search the document if there's a quit-ar button
136
137
  // In which case we don't want to populate the default button (slot) with any content
@@ -288,7 +288,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
288
288
  logo.src = needleLogoOnlySVG;
289
289
  let isUsingCustomLogo = false;
290
290
  if (hasLicense && this._element) {
291
- const customLogo = this._element.getAttribute("loading-logo-src");
291
+ const customLogo = this._element.getAttribute("logo-src");
292
292
  if (customLogo) {
293
293
  isUsingCustomLogo = true;
294
294
  logo.src = customLogo;
@@ -254,7 +254,7 @@ export class NeedleEngineWebComponent extends HTMLElement implements INeedleEngi
254
254
  <canvas></canvas>
255
255
  </div>
256
256
  <div class="content">
257
- <slot class="overlay-content"></slot>
257
+ <slot class="overlay-content" style="display: contents;"></slot>
258
258
  </div>
259
259
  `;
260
260
  // #endregion
@@ -1111,6 +1111,28 @@ export class NeedleXRSession implements INeedleXRSession {
1111
1111
  else if (debug) {
1112
1112
  console.warn("controllerAutoUpdate is not available in three.js - cannot disable it");
1113
1113
  }
1114
+
1115
+
1116
+ // In the appclip we want to make sure the canvas resolution is correctly applied with the DPR
1117
+ // this change has been added to Needle Go AppClip in daa9c186e49fe9d6f14d200e307eb0664e35ad72
1118
+ // But for an immediate fix for Needle Engine projects we also add it here.
1119
+ // We can most likely remove the folllowing block in march 2026
1120
+ if (DeviceUtilities.isNeedleAppClip()) {
1121
+ window.requestAnimationFrame(() => {
1122
+ const canvas = this.context.renderer.domElement;
1123
+ const dpr = window.devicePixelRatio || 1;
1124
+ const currentWidth = canvas.width;
1125
+ const currentHeight = canvas.height;
1126
+ // Check if DPR is already applied (avoid double-scaling)
1127
+ const expectedWidth = Math.floor(window.innerWidth * dpr);
1128
+ const expectedHeight = Math.floor(window.innerHeight * dpr);
1129
+ if (Math.abs(currentWidth - expectedWidth) > 2 || Math.abs(currentHeight - expectedHeight) > 2) {
1130
+ canvas.width = expectedWidth;
1131
+ canvas.height = expectedHeight;
1132
+ console.debug("Applied DPR scaling for Needle AppClip XR session", dpr, canvas.width, canvas.height);
1133
+ }
1134
+ });
1135
+ }
1114
1136
  }
1115
1137
 
1116
1138
  /** called when renderer.setSession is fulfilled */
@@ -1,8 +1,11 @@
1
- import { AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelper, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Scene, WebGLRenderer } from "three";
1
+ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelper, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PlaneGeometry, PointLight, Scene, TextureLoader, Vector3, WebGLRenderer } from "three";
2
2
 
3
+ import { needleLogoOnlySVG } from "../assets/index.js";
4
+ import { isDevEnvironment } from "../debug/index.js";
3
5
  import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
6
+ import { hasCommercialLicense } from "../engine_license.js";
4
7
  import { Mathf } from "../engine_math.js";
5
- import { delay } from "../engine_utils.js";
8
+ import { delay, DeviceUtilities } from "../engine_utils.js";
6
9
 
7
10
  export declare type SessionInfo = { session: XRSession, mode: XRSessionMode, init: XRSessionInit };
8
11
 
@@ -77,6 +80,9 @@ export class TemporaryXRContext {
77
80
  get isAR() {
78
81
  return this._mode === "immersive-ar";
79
82
  }
83
+ get isVR() {
84
+ return this._mode === "immersive-vr";
85
+ }
80
86
 
81
87
  private readonly _renderer: WebGLRenderer;
82
88
  private readonly _camera: Camera;
@@ -88,19 +94,31 @@ export class TemporaryXRContext {
88
94
  this._session = session;
89
95
  this._session.addEventListener("end", this.onEnd);
90
96
 
91
- this._renderer = new WebGLRenderer({ alpha: true, antialias: true });
97
+ this._renderer = new WebGLRenderer({ alpha: true, antialias: true });
92
98
  this._renderer.outputColorSpace = 'srgb';
99
+ // Set pixel ratio and size
100
+ this._renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
101
+ this._renderer.setSize(window.innerWidth, window.innerHeight, true);
102
+ if (DeviceUtilities.isNeedleAppClip()) {
103
+ window.requestAnimationFrame(() => {
104
+ const dpr = Math.min(2, window.devicePixelRatio);
105
+ const expectedWidth = Math.floor(window.innerWidth * dpr);
106
+ const expectedHeight = Math.floor(window.innerHeight * dpr);
107
+ this._renderer.domElement.width = expectedWidth;
108
+ this._renderer.domElement.height = expectedHeight;
109
+ });
110
+ }
111
+
93
112
  this._renderer.setAnimationLoop(this.onFrame);
94
113
  this._renderer.xr.setSession(session);
95
114
  this._renderer.xr.enabled = true;
96
- // Set pixel ratio and size
97
- this._renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
98
- this._renderer.setSize(window.innerWidth, window.innerHeight);
115
+
99
116
  this._camera = new PerspectiveCamera();
100
117
  this._scene = new Scene();
101
118
  this._scene.fog = new Fog(0x444444, 10, 250);
102
119
  this._scene.add(this._camera);
103
120
  this.setupScene();
121
+
104
122
  }
105
123
 
106
124
  end() {
@@ -133,6 +151,7 @@ export class TemporaryXRContext {
133
151
  }
134
152
 
135
153
  private _lastTime = 0;
154
+ private _frames = 0;
136
155
  private onFrame = (time: DOMHighResTimeStamp, _frame: XRFrame) => {
137
156
  const dt = time - this._lastTime;
138
157
  this.update(time, dt);
@@ -140,6 +159,39 @@ export class TemporaryXRContext {
140
159
  this._scene.add(this._camera);
141
160
  }
142
161
  this._renderer.render(this._scene, this._camera);
162
+ this._lastTime = time;
163
+ this._frames++;
164
+ }
165
+
166
+
167
+ private readonly _roomFlyObjects: Mesh[] = [];
168
+ private _logoObject: Mesh | null = null;
169
+ private get _logoDistance() {
170
+ return this.isAR ? 0.3 : 5;
171
+ }
172
+ private get _logoScale() {
173
+ return this.isAR ? 0.04 : 1;
174
+ }
175
+
176
+ private update(time: number, _deltaTime: number) {
177
+
178
+ const speed = time * .0004;
179
+ for (let i = 0; i < this._roomFlyObjects.length; i++) {
180
+ const obj = this._roomFlyObjects[i];
181
+ obj.position.y += Math.sin(speed + i * .5) * 0.005;
182
+ obj.rotateY(.002);
183
+ }
184
+
185
+ const logo = this._logoObject;
186
+ const xrCamera = this._renderer.xr.getCamera() as ArrayCamera;
187
+ if (logo) {
188
+ const cameraForward = new Vector3();
189
+ xrCamera.getWorldDirection(cameraForward);
190
+ const targetPosition = xrCamera.position.clone().addScaledVector(cameraForward, this._logoDistance);
191
+ const speed = this.isAR ? 0.005 : 0.00001; // in VR it's nicer to have the logo basically static
192
+ logo.position.lerp(targetPosition, this._frames <= 2 ? 1 : _deltaTime * speed);
193
+ logo.lookAt(this._camera.position);
194
+ }
143
195
  }
144
196
 
145
197
  /** can be used to prepare the user or fade to black */
@@ -159,10 +211,78 @@ export class TemporaryXRContext {
159
211
  }
160
212
 
161
213
 
162
- private _objects: Mesh[] = [];
163
214
  private setupScene() {
164
215
  this._scene.background = new Color(0x000000);
165
- this._scene.add(new GridHelper(5, 10, 0x111111, 0x111111));
216
+
217
+ let logoSrc = needleLogoOnlySVG;
218
+ if (hasCommercialLicense()) {
219
+ const htmlComponent = document.querySelector("needle-engine");
220
+ if (htmlComponent) {
221
+ const licenseLogo = htmlComponent.getAttribute("logo-src");
222
+ if (licenseLogo?.length) {
223
+ logoSrc = licenseLogo;
224
+ if (isDevEnvironment()) console.debug("[XR] Using custom loading logo from license:", logoSrc);
225
+ }
226
+ }
227
+ }
228
+ const logo = this._logoObject = new Mesh(new PlaneGeometry(1, 1, 1, 1), new MeshBasicMaterial({ transparent: true, side: 2 }));
229
+ logo.scale.multiplyScalar(this._logoScale * window.devicePixelRatio);
230
+ logo.renderOrder = 1000;
231
+ logo.material.opacity = 0;
232
+ this._scene.add(logo);
233
+ const canvas = document.createElement("canvas");
234
+ const ctx = canvas.getContext("2d");
235
+ const img = new Image();
236
+ const drawLogo = (loadingFailed: boolean) => {
237
+ if (!ctx) return;
238
+ logo.material.opacity = 1;
239
+ const size = 1024;
240
+ canvas.width = size;
241
+ canvas.height = size;
242
+ ctx.imageSmoothingQuality = "high";
243
+
244
+ // ctx.fillStyle = "#33333399";
245
+ // ctx.fillRect(0, 0, canvas.width, canvas.height,);
246
+
247
+ const padding = size * .19;
248
+ const aspect = loadingFailed ? 1 : img.width / img.height;
249
+ if (!loadingFailed) {
250
+ const maxHeight = canvas.height - padding * 1.5;
251
+ const imgWidth = maxHeight * aspect;
252
+ const imgX = (canvas.width - imgWidth) / 2;
253
+ ctx.drawImage(img, imgX, 0, imgWidth, maxHeight);
254
+ }
255
+ const fontSize = size * .12;
256
+ const text = "Loading...";
257
+ ctx.shadowBlur = 0;
258
+ ctx.fillStyle = this.isAR ? "white" : "rgba(255,255,255,0.4)";
259
+ ctx.font = `${fontSize}px Arial`;
260
+ ctx.shadowBlur = size * .02;
261
+ ctx.shadowColor = "rgba(0,0,0,0.5)";
262
+ ctx.shadowOffsetX = 0;
263
+ ctx.shadowOffsetY = 0;
264
+ const metrics = ctx.measureText(text);
265
+ ctx.fillText(text, canvas.width / 2 - metrics.width / 2, canvas.height - padding / 4);
266
+ ctx.font = `${fontSize}px Arial`;
267
+ ctx.fillText(text, canvas.width / 2 - metrics.width / 2, canvas.height - padding / 4);
268
+
269
+ const texture = new TextureLoader().load(canvas.toDataURL());
270
+ texture.generateMipmaps = true;
271
+ texture.colorSpace = 'srgb';
272
+ texture.anisotropy = 4;
273
+ const canvasAspect = canvas.width / canvas.height;
274
+ logo.scale.x = this._logoScale * canvasAspect * window.devicePixelRatio;
275
+ logo.scale.y = this._logoScale * window.devicePixelRatio;
276
+ logo.material.map = texture;
277
+ logo.material.needsUpdate = true;
278
+ }
279
+ img.onload = () => drawLogo(false);
280
+ img.onerror = e => {
281
+ console.error("Failed to load temporary XR logo:", logoSrc, e);
282
+ img.src = needleLogoOnlySVG;
283
+ };
284
+ img.crossOrigin = "anonymous";
285
+ img.src = logoSrc;
166
286
 
167
287
  const light = new DirectionalLight(0xffffff, 1);
168
288
  light.position.set(0, 20, 0);
@@ -180,48 +300,36 @@ export class TemporaryXRContext {
180
300
  light3.distance = 200;
181
301
  this._scene.add(light3);
182
302
 
183
- const range = 50;
184
- for (let i = 0; i < 100; i++) {
185
- const material = new MeshStandardMaterial({
186
- color: 0x222222,
187
- metalness: 1,
188
- roughness: .8,
189
- });
190
- // if we're in passthrough
191
- if (this.isAR) {
192
- material.emissive = new Color(Math.random(), Math.random(), Math.random());
193
- material.emissiveIntensity = Math.random();
194
- }
195
- const type = Mathf.random(0, 1) > .5 ? PrimitiveType.Sphere : PrimitiveType.Cube;
196
- const obj = ObjectUtils.createPrimitive(type, {
197
- material
198
- });
199
- obj.position.x = Mathf.random(-range, range);
200
- obj.position.y = Mathf.random(-2, range);
201
- obj.position.z = Mathf.random(-range, range);
202
- // random rotation
203
- obj.rotation.x = Mathf.random(0, Math.PI * 2);
204
- obj.rotation.y = Mathf.random(0, Math.PI * 2);
205
- obj.rotation.z = Mathf.random(0, Math.PI * 2);
206
- obj.scale.multiplyScalar(.5 + Math.random() * 10);
207
-
208
- const dist = obj.position.distanceTo(this._camera.position) - obj.scale.x;
209
- if (dist < 1) {
210
- obj.position.multiplyScalar(1 + 1 / dist);
211
- }
303
+ // if we're in passthrough
304
+ if (this.isAR === false) {
305
+ const range = 50;
306
+ for (let i = 0; i < 100; i++) {
307
+ const material = new MeshStandardMaterial({
308
+ color: 0x222222,
309
+ metalness: 1,
310
+ roughness: .8,
311
+ });
212
312
 
213
- this._objects.push(obj);
214
- this._scene.add(obj);
215
- }
216
- }
313
+ const type = PrimitiveType.Sphere; //Mathf.random(0, 1) > .5 ? PrimitiveType.Sphere : PrimitiveType.Cube;
314
+ const obj = ObjectUtils.createPrimitive(type, { material });
315
+ obj.position.x = Mathf.random(-range, range);
316
+ obj.position.y = Mathf.random(-2, range);
317
+ obj.position.z = Mathf.random(-range, range);
318
+ // random rotation
319
+ obj.rotation.x = Mathf.random(0, Math.PI * 2);
320
+ obj.rotation.y = Mathf.random(0, Math.PI * 2);
321
+ obj.rotation.z = Mathf.random(0, Math.PI * 2);
322
+ obj.scale.multiplyScalar(.5 + Math.random() * 10);
217
323
 
218
- private update(time: number, _deltaTime: number) {
324
+ const dist = obj.position.distanceTo(this._camera.position) - obj.scale.x;
325
+ if (dist < 10) {
326
+ obj.position.z += 5;
327
+ obj.position.multiplyScalar(1 + 1 / dist);
328
+ }
219
329
 
220
- const speed = time * .0004;
221
- for (let i = 0; i < this._objects.length; i++) {
222
- const obj = this._objects[i];
223
- obj.position.y += Math.sin(speed + i * .5) * 0.005;
224
- obj.rotateY(.002);
330
+ this._roomFlyObjects.push(obj);
331
+ this._scene.add(obj);
332
+ }
225
333
  }
226
334
  }
227
335
  }
@@ -229,11 +229,12 @@ export class SpectatorCamera extends Behaviour {
229
229
  onLeaveXR(_evt) {
230
230
  this.context.removeCamera(this.cam as ICamera);
231
231
  GameObject.setActive(this.gameObject, false);
232
- if (this.orbit) this.orbit.enabled = true;
233
232
  this._handler?.set(undefined);
234
233
  this._handler?.disable();
235
234
  if (this.isSpectatingSelf)
236
235
  this.stopSpectating();
236
+ // Importantly re-enable orbit controls on main camera at the end here. This is a workaround for https://linear.app/needle/issue/NE-6897
237
+ if (this.orbit) this.orbit.enabled = true;
237
238
  }
238
239
 
239
240
  /**