Needle Engine

Changes between version 3.32.28-beta and 3.33.0-beta
Files changed (44) hide show
  1. src/engine/api.ts +6 -5
  2. src/engine-components/webxr/Avatar.ts +1 -1
  3. src/engine-components/ui/Button.ts +1 -1
  4. src/engine-components/ui/Canvas.ts +1 -1
  5. src/engine/debug/debug_console.ts +3 -3
  6. src/engine/debug/debug_overlay.ts +48 -31
  7. src/engine/debug/debug_spatial_console.ts +37 -16
  8. src/engine/engine_context.ts +2 -1
  9. src/engine/engine_element_loading.ts +7 -2
  10. src/engine/engine_element.ts +3 -9
  11. src/engine/engine_license.ts +1 -3
  12. src/engine/engine_math.ts +17 -0
  13. src/engine/engine_types.ts +2 -39
  14. src/engine/engine_utils.ts +43 -5
  15. src/engine/engine_xr.ts +1 -2
  16. src/engine-components/ui/EventSystem.ts +9 -5
  17. src/engine-components/GroundProjection.ts +10 -3
  18. src/engine/assets/index.ts +44 -1
  19. src/engine/xr/index.ts +0 -5
  20. src/engine/webcomponents/license-banner.ts +0 -48
  21. src/engine-components/Light.ts +2 -2
  22. src/needle-engine.ts +1 -0
  23. src/engine/webcomponents/needle-menu.ts +0 -470
  24. src/engine-components/NeedleMenu.ts +21 -0
  25. src/engine/xr/NeedleXRSession.ts +85 -29
  26. src/engine-components/js-extensions/RGBAColor.ts +3 -3
  27. src/engine-components/ScreenCapture.ts +4 -3
  28. src/engine-components/SyncedRoom.ts +89 -5
  29. src/engine/xr/TempXRContext.ts +62 -28
  30. src/engine-components/webxr/WebARSessionRoot.ts +3 -2
  31. src/engine-components/webxr/WebXR.ts +27 -6
  32. src/engine-components/webxr/WebXRButtons.ts +13 -4
  33. src/engine-components/webxr/WebXRImageTracking.ts +3 -3
  34. src/engine-components/webxr/controllers/XRControllerMovement.ts +2 -2
  35. src/engine/webcomponents/api.ts +3 -0
  36. src/engine/xr/api.ts +5 -0
  37. src/engine/webcomponents/buttons.ts +107 -0
  38. src/engine/engine_audio.ts +18 -0
  39. src/engine/xr/events.ts +27 -0
  40. src/engine/webcomponents/fonts.ts +36 -0
  41. src/engine/webcomponents/icons.ts +54 -0
  42. src/engine/webcomponents/needle menu/needle-menu-spatial.ts +545 -0
  43. src/engine/webcomponents/needle menu/needle-menu.ts +620 -0
  44. src/include/needle/poweredbyneedle.webp +0 -0
src/engine/api.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  export * from "./engine_hot_reload.js";
21
21
  export * from "./engine_input.js";
22
22
  export { InstancingUtil } from "./engine_instancing.js";
23
- export { hasIndieLicense,hasProLicense } from "./engine_license.js";
23
+ export { hasCommercialLicense,hasIndieLicense, hasProLicense } from "./engine_license.js";
24
24
  export * from "./engine_lifecycle_api.js";
25
25
  export * from "./engine_math.js";
26
26
  export * from "./engine_networking.js";
@@ -45,11 +45,12 @@
45
45
  export * from "./engine_time.js";
46
46
  export * from "./engine_time_utils.js";
47
47
  export * from "./engine_types.js";
48
- export { registerType,TypeStore } from "./engine_typestore.js";
49
- export { prefix,validate } from "./engine_util_decorator.js";
48
+ export { registerType, TypeStore } from "./engine_typestore.js";
49
+ export { prefix, validate } from "./engine_util_decorator.js";
50
50
  export * from "./engine_utils.js";
51
51
  export * from "./engine_utils_screenshot.js";
52
52
  export * from "./engine_web_api.js";
53
- export * from "./engine_xr.js";
54
53
  export * from "./extensions/index.js";
55
- export * from "./js-extensions/index.js";
54
+ export * from "./js-extensions/index.js";
55
+ export * from "./webcomponents/api.js"
56
+ export * from "./xr/api.js"
src/engine-components/webxr/Avatar.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { serializable } from "../../engine/engine_serialization_decorator.js";
6
6
  import type { IGameObject } from "../../engine/engine_types.js";
7
7
  import { getParam,PromiseAllWithErrors } from "../../engine/engine_utils.js";
8
- import { type NeedleXREventArgs, NeedleXRSession, NeedleXRUtils } from "../../engine/xr/index.js";
8
+ import { type NeedleXREventArgs, NeedleXRSession, NeedleXRUtils } from "../../engine/xr/api.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/ui/Button.ts CHANGED
@@ -146,7 +146,7 @@
146
146
  }
147
147
  }
148
148
 
149
- @serializable()
149
+ @serializable(ButtonColors)
150
150
  colors?: ButtonColors;
151
151
  @serializable()
152
152
  transition?: Transition;
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 { type NeedleXREventArgs } from "../../engine/xr/index.js";
8
+ import { type NeedleXREventArgs } from "../../engine/xr/api.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/debug/debug_console.ts CHANGED
@@ -23,14 +23,14 @@
23
23
  consoleUrl.searchParams.set("console", "1");
24
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
- const isMobile = isMobileDevice() || (isQuest() && isDevEnvironment());
27
- if (isMobile) {
26
+ const enableConsole = isMobileDevice() || (isQuest() && isDevEnvironment());
27
+ if (enableConsole) {
28
28
  // we need to invoke this here - otherwise we will miss errors that happen after the console is loaded
29
29
  // and calling the method from the root needle-engine.ts import is evaluated later (if we import the method from the toplevel file and then invoke it)
30
30
  makeErrorsVisibleForDevelopment();
31
31
  beginWatchingLogs();
32
32
  createConsole(true);
33
- if (isMobile) {
33
+ if (enableConsole) {
34
34
  const engineElement = document.querySelector("needle-engine");
35
35
  // setTimeout(() => {
36
36
  // const el = getConsoleElement();
src/engine/debug/debug_overlay.ts CHANGED
@@ -18,14 +18,36 @@
18
18
  return _errorCount;
19
19
  }
20
20
 
21
+ const _errorListeners = new Array<(...args: any[]) => void>();
22
+ /** Register callback when a new error happens */
23
+ export function onError(cb: (...args: any[]) => void) { _errorListeners.push(cb); }
24
+ /** Unregister error callback */
25
+ export function offError(cb: (...args: any[]) => void) { _errorListeners.splice(_errorListeners.indexOf(cb), 1); }
26
+ let isInvokingErrorListeners = false;
27
+ function invokeErrorListeners(...args: any[]) {
28
+ if (isInvokingErrorListeners) return; // prevent infinite loop
29
+ isInvokingErrorListeners = true;
30
+ try {
31
+ for (let i = 0; i < _errorListeners.length; i++) {
32
+ _errorListeners[i](...args);
33
+ }
34
+ }
35
+ catch (e) {
36
+ console.error(e);
37
+ }
38
+ isInvokingErrorListeners = false;
39
+ }
40
+
21
41
  const originalConsoleError = console.error;
22
42
  const patchedConsoleError = function (...args: any[]) {
23
43
  originalConsoleError.apply(console, args);
24
44
  onParseError(args);
25
45
  addLog(LogType.Error, args, null, null);
26
- onReceivedError();
46
+ onReceivedError(...args);
27
47
  }
28
48
 
49
+
50
+
29
51
  /** Set false to prevent overlay messages from being shown */
30
52
  export function setAllowOverlayMessages(allow: boolean) {
31
53
  hide = !allow;
@@ -35,42 +57,37 @@
35
57
 
36
58
  export function makeErrorsVisibleForDevelopment() {
37
59
  if (hide) return;
38
- const isLocal = isLocalNetwork();
39
- if (debug) console.log("Is this a local network?", isLocal);
40
- if (isLocal)
41
- {
42
- if (debug)
43
- console.warn("Patch console", window.location.hostname);
44
- console.error = patchedConsoleError;
45
- window.addEventListener("error", (event) => {
46
- if (hide) return;
47
- if (!event) return;
48
- const message = event.error;
49
- if (message === undefined) {
50
- if (isLocalNetwork())
51
- console.warn("Received unknown error", event, event.target);
52
- return;
53
- }
54
- addLog(LogType.Error, message, event.filename, event.lineno);
55
- onReceivedError();
56
- }, true);
57
- window.addEventListener("unhandledrejection", (event) => {
58
- if (hide) return;
59
- if (!event) return;
60
- if (event.reason)
61
- addLog(LogType.Error, event.reason.message, event.reason.stack);
62
- else
63
- addLog(LogType.Error, "unhandled rejection");
64
- onReceivedError();
65
- });
66
- }
60
+ if (debug)
61
+ console.warn("Patch console", window.location.hostname);
62
+ console.error = patchedConsoleError;
63
+ window.addEventListener("error", (event) => {
64
+ if (!event) return;
65
+ const message = event.error;
66
+ if (message === undefined) {
67
+ if (isLocalNetwork())
68
+ console.warn("Received unknown error", event, event.target);
69
+ return;
70
+ }
71
+ addLog(LogType.Error, message, event.filename, event.lineno);
72
+ onReceivedError(event);
73
+ }, true);
74
+ window.addEventListener("unhandledrejection", (event) => {
75
+ if (hide) return;
76
+ if (!event) return;
77
+ if (event.reason)
78
+ addLog(LogType.Error, event.reason.message, event.reason.stack);
79
+ else
80
+ addLog(LogType.Error, "unhandled rejection");
81
+ onReceivedError(event);
82
+ });
67
83
  }
68
84
 
69
85
 
70
86
  let _errorCount = 0;
71
87
 
72
- function onReceivedError() {
88
+ function onReceivedError(...args: any[]) {
73
89
  _errorCount += 1;
90
+ invokeErrorListeners(...args);
74
91
  }
75
92
 
76
93
  function onParseError(args: Array<any>) {
src/engine/debug/debug_spatial_console.ts CHANGED
@@ -6,10 +6,22 @@
6
6
  import { OneEuroFilterXYZ } from "../engine_math.js";
7
7
  import { lookAtObject } from "../engine_three_utils.js";
8
8
  import type { IContext, IGameObject } from "../engine_types.js";
9
+ import { getParam } from "../engine_utils.js";
10
+ import { isDevEnvironment } from "./debug.js";
11
+ import { onError } from "./debug_overlay.js";
9
12
 
10
13
 
11
14
  let _isActive = false;
12
15
 
16
+ // enable the spatial console if we receive an error while in dev session and in XR
17
+ onError((...args: any[]) => {
18
+ if (isDevEnvironment() && ContextRegistry.Current?.isInXR) {
19
+ enableSpatialConsole(true);
20
+ onLog("error", ...args);
21
+ }
22
+ })
23
+
24
+
13
25
  /** Enable a spatial debug console that follows the camera */
14
26
  export function enableSpatialConsole(active: boolean) {
15
27
  if (active) {
@@ -52,6 +64,7 @@
52
64
  }
53
65
  onDisable() {
54
66
  this.context?.pre_render_callbacks.splice(this.context?.pre_render_callbacks.indexOf(this.onBeforeRender), 1);
67
+ this.root?.removeFromParent();
55
68
  }
56
69
 
57
70
  private readonly targetObject = new Object3D();
@@ -73,14 +86,16 @@
73
86
 
74
87
  this.context.scene.add(this.targetObject);
75
88
 
76
- const dist = 3.5;
89
+ const rigScale = this.context.xr?.rigScale ?? 1;
90
+
91
+ const dist = 3.5 * rigScale;
77
92
  const forward = cam.worldForward;
78
93
  forward.y = 0;
79
94
  forward.normalize().multiplyScalar(dist);
80
95
  this.userForwardViewPoint.copy(cam.worldPosition).sub(forward);
81
96
 
82
97
  const distFromForwardView = this.targetObject.position.distanceTo(this.userForwardViewPoint);
83
- if (distFromForwardView > 2) {
98
+ if (distFromForwardView > 2 * rigScale) {
84
99
  this.targetObject.position.copy(this.userForwardViewPoint);
85
100
  lookAtObject(this.targetObject, cam, true, true);
86
101
  this.targetObject.rotateY(Math.PI);
@@ -89,9 +104,10 @@
89
104
  this.oneEuroFilter.filter(this.targetObject.position, root.position, this.context.time.time);
90
105
  const step = this.context.time.deltaTime;
91
106
  root.quaternion.slerp(this.targetObject.quaternion, step * 5);
107
+ root.scale.setScalar(rigScale);
92
108
 
93
109
  this.targetObject.removeFromParent();
94
- this.context.scene.add(this.getRoot() as any);
110
+ this.context.scene.add(root);
95
111
 
96
112
  if (this.context.time.time - this._lastElementRemoveTime > .1) {
97
113
  this._lastElementRemoveTime = this.context.time.time;
@@ -128,7 +144,7 @@
128
144
  break;
129
145
  case "error":
130
146
  backgroundColor = 0xffaaaa;
131
- fontColor = 0xaa0000;
147
+ fontColor = 0x770000;
132
148
  break;
133
149
  }
134
150
 
@@ -171,6 +187,7 @@
171
187
  backgroundColor: 0xffffff,
172
188
  backgroundOpacity: .4,
173
189
  borderRadius: .03,
190
+ offset: .025,
174
191
  };
175
192
  private readonly _textBuffer: ThreeMeshUI.Text[] = [];
176
193
  private readonly _activeTexts: ThreeMeshUI.Text[] = [];
@@ -191,18 +208,24 @@
191
208
  setTimeout(() => this.disableDepthTestRecursive(newText as any), 1500);
192
209
  return newText;
193
210
  }
194
- private disableDepthTestRecursive(obj: Object3D) {
195
- obj.traverseVisible((t: Object3D) => {
196
- t.renderOrder = 1000;
197
- t.layers.set(2);
198
- t.position.z = .05;
199
- const mat = (t as Mesh).material as Material;
200
- if (mat) {
201
- mat.depthWrite = false;
202
- mat.depthTest = false;
211
+ private disableDepthTestRecursive(obj: Object3D, level: number = 0) {
212
+ for (let i = 0; i < obj.children.length; i++) {
213
+ const child = obj.children[i];
214
+ if (child instanceof Object3D) {
215
+ this.disableDepthTestRecursive(child, level + 1);
203
216
  }
217
+ }
218
+ obj.renderOrder = 10 * level;
219
+ obj.layers.set(2);
220
+ // obj.position.z = .01 * level;
221
+ const mat = (obj as Mesh).material as Material;
222
+ if (mat) {
223
+ mat.depthWrite = false;
224
+ mat.depthTest = false;
225
+ mat.transparent = true;
226
+ }
227
+ if (level === 0)
204
228
  ThreeMeshUI.update();
205
- });
206
229
  }
207
230
 
208
231
  private getRoot() {
@@ -259,8 +282,6 @@
259
282
  }
260
283
  };
261
284
  }
262
-
263
- console.log("Enabling Spatial Console");
264
285
  }
265
286
  function onDisable() {
266
287
  messagesHandler?.onDisable();
src/engine/engine_context.ts CHANGED
@@ -31,7 +31,7 @@
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';
34
- import { NeedleMenu, NeedleMenuElement } from './webcomponents/needle-menu.js';
34
+ import { NeedleMenu, NeedleMenuElement } from './webcomponents/needle menu/needle-menu.js';
35
35
 
36
36
 
37
37
  const debug = utils.getParam("debugcontext");
@@ -566,6 +566,7 @@
566
566
  this.scene = null!;
567
567
  this.renderer = null!;
568
568
  this.input.dispose();
569
+ this.menu.onDestroy();
569
570
  for (const cb of this._disposeCallbacks) {
570
571
  try {
571
572
  cb();
src/engine/engine_element_loading.ts CHANGED
@@ -340,7 +340,7 @@
340
340
  }
341
341
  }
342
342
 
343
- this.handleRuntimeLicense(details);
343
+ this.handleRuntimeLicense(this._loadingElement);
344
344
 
345
345
  return this._loadingElement;
346
346
  }
@@ -357,7 +357,12 @@
357
357
  nonCommercialContainer.style.paddingTop = ".6em";
358
358
  nonCommercialContainer.style.fontSize = ".8em";
359
359
  nonCommercialContainer.style.textTransform = "uppercase";
360
- nonCommercialContainer.innerText = "non commercial";
360
+ nonCommercialContainer.innerText = "NEEDLE ENGINE COMMERCIAL USE REQUIRES A LICENSE.\nCLICK HERE TO GET ONE.";
361
+ nonCommercialContainer.style.cursor = "pointer";
362
+ nonCommercialContainer.style.userSelect = "none";
363
+ nonCommercialContainer.style.textAlign = "center";
364
+ nonCommercialContainer.style.pointerEvents = "all";
365
+ nonCommercialContainer.addEventListener("click", () => window.open("https://needle.tools/pricing", "_self"));
361
366
  nonCommercialContainer.style.opacity = "0";
362
367
  loadingElement.appendChild(nonCommercialContainer);
363
368
 
src/engine/engine_element.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { Context, ContextCreateArgs, LoadingProgressArgs } from "./engine_setup.js";
11
11
  import { type INeedleEngineComponent, type LoadedGLTF } from "./engine_types.js";
12
12
  import { getParam } from "./engine_utils.js";
13
+ import { ensureFonts } from "./webcomponents/fonts.js";
13
14
 
14
15
  //
15
16
  // registering loader here too to make sure it's imported when using engine via vanilla js
@@ -94,16 +95,9 @@
94
95
  this._overlay_ar = new AROverlayHandler();
95
96
  // TODO: do we want to rename this event?
96
97
  this.addEventListener("ready", this.onReady);
97
-
98
- // Workaround for font loading not being supported in ShadowDOM:
99
- // Add font import to document header.
100
- // Note that this is slower than it could be, ideally the font would be prefetched,
101
- // but for that it needs to be in the actual document and not added by JS.
102
- const fontLink = document.createElement("link");
103
- fontLink.href = "https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,[email protected],100..1000&display=swap";
104
- fontLink.rel = "stylesheet";
105
- document.head.appendChild(fontLink);
106
98
 
99
+ ensureFonts();
100
+
107
101
  this.attachShadow({ mode: 'open' });
108
102
  const template = document.createElement('template');
109
103
  template.innerHTML = `<style>
src/engine/engine_license.ts CHANGED
@@ -1,9 +1,7 @@
1
- import { showBalloonError, showBalloonWarning } from "./debug/index.js";
2
1
  import { BUILD_TIME, GENERATOR, VERSION } from "./engine_constants.js";
3
2
  import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
4
3
  import type { IContext } from "./engine_types.js";
5
- import { getParam, isMobileDevice } from "./engine_utils.js";
6
- import { LicenseBanner } from "./webcomponents/license-banner.js";
4
+ import { getParam } from "./engine_utils.js";
7
5
 
8
6
  const debug = getParam("debuglicense");
9
7
 
src/engine/engine_math.ts CHANGED
@@ -133,6 +133,11 @@
133
133
  lastValue() {
134
134
  return this.y;
135
135
  }
136
+
137
+ reset(value: number) {
138
+ this.y = value;
139
+ this.s = value;
140
+ }
136
141
  }
137
142
 
138
143
  export class OneEuroFilter {
@@ -181,6 +186,13 @@
181
186
  const cutOff = this.minCutOff + this.beta * Math.abs(edx);
182
187
  return this.x.filter(x, this.alpha(cutOff));
183
188
  }
189
+
190
+ reset(x?: number) {
191
+ if (x != undefined) this.x.reset(x);
192
+ this.x.alpha = this.alpha(this.minCutOff);
193
+ this.dx.alpha = this.alpha(this.dCutOff);
194
+ this.lasttime = null;
195
+ }
184
196
  }
185
197
 
186
198
  export class OneEuroFilterXYZ {
@@ -205,4 +217,9 @@
205
217
  target.y = this.y.filter(value.y, time);
206
218
  target.z = this.z.filter(value.z, time);
207
219
  }
220
+ reset(value?: Vec3) {
221
+ this.x.reset(value?.x);
222
+ this.y.reset(value?.y);
223
+ this.z.reset(value?.z);
224
+ }
208
225
  }
src/engine/engine_types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { RGBAColor } from "../engine-components/js-extensions/RGBAColor.js";
6
6
  import { CollisionDetectionMode, type PhysicsMaterial, RigidbodyConstraints } from "./engine_physics.types.js";
7
+ import type { Context } from "./engine_setup.js";
7
8
  import { RenderTexture } from "./engine_texture.js";
8
9
  import { CircularBuffer } from "./engine_utils.js";
9
10
  import type { INeedleXRSessionEventReceiver, NeedleXRSession } from "./engine_xr.js";
@@ -59,46 +60,8 @@
59
60
  engine?: IPhysicsEngine;
60
61
  }
61
62
 
62
- export interface IContext {
63
- alias?: string | null;
64
- hash?: string;
63
+ export type IContext = Context;
65
64
 
66
- scene: Scene;
67
- renderer: WebGLRenderer;
68
- mainCamera: Camera | null;
69
- mainCameraComponent: ICamera | undefined;
70
- domElement: HTMLElement;
71
-
72
- time: ITime;
73
- input: IInput;
74
- physics: IPhysics;
75
-
76
- scripts: IComponent[];
77
- scripts_pausedChanged: IComponent[];
78
- scripts_earlyUpdate: IComponent[];
79
- scripts_update: IComponent[];
80
- scripts_lateUpdate: IComponent[];
81
- scripts_onBeforeRender: IComponent[];
82
- scripts_onAfterRender: IComponent[];
83
- scripts_WithCorroutines: IComponent[];
84
- scripts_immersive_vr: INeedleXRSessionEventReceiver[];
85
- scripts_immersive_ar: INeedleXRSessionEventReceiver[];
86
- coroutines: { [FrameEvent: number]: Array<CoroutineData> };
87
-
88
- post_setup_callbacks: Function[];
89
- pre_update_callbacks: Function[];
90
- pre_render_callbacks: Function[];
91
- post_render_callbacks: Function[];
92
-
93
- new_scripts: IComponent[];
94
- new_script_start: IComponent[];
95
- new_scripts_pre_setup_callbacks: Function[];
96
- new_scripts_post_setup_callbacks: Function[];
97
- new_scripts_xr: INeedleXRSessionEventReceiver[];
98
-
99
- stopAllCoroutinesFrom(script: IComponent);
100
- }
101
-
102
65
  export type INeedleXRSession = NeedleXRSession;
103
66
 
104
67
  export declare interface INeedleEngineComponent extends HTMLElement {
src/engine/engine_utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // use for typesafe interface method calls
2
- import { Quaternion, type Vector, Vector2, Vector3, Vector4 } from "three";
2
+ import { Object3D,Quaternion, type Vector, Vector2, Vector3, Vector4 } from "three";
3
3
 
4
4
  import { type Context } from "./engine_context.js";
5
5
  import { ContextRegistry } from "./engine_context_registry.js";
@@ -92,6 +92,7 @@
92
92
  document.location.search = urlParams.toString();
93
93
  }
94
94
 
95
+ /** Sets an URL parameter without reloading the website */
95
96
  export function setParamWithoutReload(paramName: string, paramValue: string | null, appendHistory = true): void {
96
97
  const urlParams = getUrlParams();
97
98
  if (urlParams.has(paramName)) {
@@ -104,6 +105,7 @@
104
105
  else setState(paramName, urlParams);
105
106
  }
106
107
 
108
+ /** Sets or adds an URL query parameter */
107
109
  export function setOrAddParamsToUrl(url: URLSearchParams, paramName: string, paramValue: string | number): void {
108
110
  if (url.has(paramName)) {
109
111
  url.set(paramName, paramValue.toString());
@@ -112,15 +114,18 @@
112
114
  url.append(paramName, paramValue.toString());
113
115
  }
114
116
 
117
+ /** Adds an entry to the browser history. Internally uses `window.history.pushState` */
115
118
  export function pushState(title: string, urlParams: URLSearchParams, state?: any) {
116
119
  window.history.pushState(state, title, "?" + urlParams.toString());
117
120
  }
118
121
 
122
+ /** Replaces the current entry in the browser history. Internally uses `window.history.replaceState` */
119
123
  export function setState(title: string, urlParams: URLSearchParams, state?: any) {
120
124
  window.history.replaceState(state, title, "?" + urlParams.toString());
121
125
  }
122
126
 
123
127
  // for room id
128
+ /** Generates a random id string of the given length */
124
129
  export function makeId(length): string {
125
130
  var result = '';
126
131
  var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -132,12 +137,16 @@
132
137
  return result;
133
138
  }
134
139
 
135
- export function randomNumber(min, max) {
140
+ /** Generates a random number
141
+ * @deprecated use Mathf.random(min, max)
142
+ */
143
+ export function randomNumber(min: number, max: number) {
136
144
  return Math.floor(Math.random() * (max - min + 1)) + min;
137
145
  }
138
146
 
139
147
  const adjectives = ["smol", "tiny", "giant", "interesting", "smart", "bright", "dull", "extreme", "beautiful", "pretty", "dark", "epic", "salty", "silly", "funny", "lame", "lazy", "loud", "lucky", "mad", "mean", "mighty", "mysterious", "nasty", "odd", "old", "powerful", "quiet", "rapid", "scary", "shiny", "shy", "silly", "smooth", "sour", "spicy", "stupid", "sweet", "tasty", "terrible", "ugly", "unusual", "vast", "wet", "wild", "witty", "wrong", "zany", "zealous", "zippy", "zombie", "zorro"];
140
148
  const nouns = ["cat", "dog", "mouse", "pig", "cow", "horse", "sheep", "chicken", "duck", "goat", "panda", "tiger", "lion", "elephant", "monkey", "bird", "fish", "snake", "frog", "turtle", "hamster", "penguin", "kangaroo", "whale", "dolphin", "crocodile", "snail", "ant", "bee", "beetle", "butterfly", "dragon", "eagle", "fish", "giraffe", "lizard", "panda", "penguin", "rabbit", "snake", "spider", "tiger", "zebra"]
149
+ /** Generates a random id string from a list of adjectives and nouns */
141
150
  export function makeIdFromRandomWords(): string {
142
151
  const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
143
152
  const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
@@ -152,6 +161,12 @@
152
161
 
153
162
 
154
163
  // TODO: taken from scene utils
164
+ /**
165
+ * @param globalObjectIdentifier The guid of the object to find
166
+ * @param obj The object to search in
167
+ * @param recursive If true the search will be recursive
168
+ * @param searchComponents If true the search will also search components
169
+ * @returns the first object that has the globalObjectIdentifier as a guid */
155
170
  export function tryFindObject(globalObjectIdentifier: string, obj, recursive: boolean = true, searchComponents: boolean = false) {
156
171
  if (obj === undefined || obj === null) return null;
157
172
 
@@ -167,7 +182,6 @@
167
182
  }
168
183
 
169
184
  if (recursive) {
170
-
171
185
  if (obj.scenes) {
172
186
  for (const i in obj.scenes) {
173
187
  const scene = obj.scenes[i];
@@ -175,7 +189,6 @@
175
189
  if (found) return found;
176
190
  }
177
191
  }
178
-
179
192
  if (obj.children) {
180
193
  for (const i in obj.children) {
181
194
  const child = obj.children[i];
@@ -188,6 +201,16 @@
188
201
 
189
202
  declare type deepClonePredicate = (owner: any, propertyName: string, current: any) => boolean;
190
203
 
204
+ /** Deep clones an object
205
+ * @param obj The object to clone
206
+ * @param predicate A function that can be used to skip certain properties from being cloned
207
+ * @returns The cloned object
208
+ * @example
209
+ * const clone = deepClone(obj, (owner, propertyName, current) => {
210
+ * if (propertyName === "dontCloneMe") return false;
211
+ * return true;
212
+ * });
213
+ * */
191
214
  export function deepClone(obj: any, predicate?: deepClonePredicate): any {
192
215
  if (obj !== null && obj !== undefined && typeof obj === "object") {
193
216
  let clone;
@@ -475,21 +498,25 @@
475
498
  return standalone && !isHololens && !isiOS();
476
499
  }
477
500
 
501
+ /** @returns `true` if it's a phone or tablet */
478
502
  export function isMobileDevice() {
479
503
  return (typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1);
480
504
  }
481
505
 
506
+ /** @returns `true` if we're currently using the mozilla XR browser */
482
507
  export function isMozillaXR() {
483
508
  return /WebXRViewer\//i.test(navigator.userAgent);
484
509
  }
485
510
 
486
511
  const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
512
+ /** @returns `true` for iOS devices like iPad, iPhone, iPod... */
487
513
  export function isiOS() {
488
514
  return iosDevices.includes(navigator.platform)
489
515
  // iPad on iOS 13 detection
490
516
  || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
491
517
  }
492
518
 
519
+ /** @returns `true` if we're currently on safari */
493
520
  export function isSafari() {
494
521
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
495
522
  }
@@ -498,6 +525,7 @@
498
525
  return navigator.userAgent.includes("OculusBrowser");
499
526
  }
500
527
 
528
+ /** @returns `true` if the user allowed to use the microphone */
501
529
  export async function microphonePermissionsGranted() {
502
530
  try {
503
531
  //@ts-ignore
@@ -605,6 +633,7 @@
605
633
  }
606
634
 
607
635
 
636
+ /** Used by `PromiseAllWithErrors` */
608
637
  export class PromiseErrorResult {
609
638
  readonly reason: string;
610
639
  constructor(reason: string) {
@@ -644,7 +673,16 @@
644
673
 
645
674
 
646
675
 
647
- /** using https://github.com/davidshimjs/qrcodejs */
676
+ /** Generates a QR code HTML image using https://github.com/davidshimjs/qrcodejs
677
+ * @param args.text The text to encode
678
+ * @param args.width The width of the QR code
679
+ * @param args.height The height of the QR code
680
+ * @param args.colorDark The color of the dark squares
681
+ * @param args.colorLight The color of the light squares
682
+ * @param args.correctLevel The error correction level to use
683
+ * @param args.domElement The dom element to append the QR code to. If not provided a new div will be created and returned
684
+ * @returns The dom element containing the QR code
685
+ */
648
686
  export async function generateQRCode(args: { domElement?: HTMLElement, text: string, width?: number, height?: number, colorDark?: string, colorLight?: string, correctLevel?: any }): Promise<HTMLElement> {
649
687
 
650
688
  // ensure that the QRCode library is loaded
src/engine/engine_xr.ts CHANGED
@@ -1,2 +1,1 @@
1
-
2
- export * from "./xr/index.js"
1
+ export * from "./xr/api.js"
src/engine-components/ui/EventSystem.ts CHANGED
@@ -428,11 +428,15 @@
428
428
  isShadow = true;
429
429
  }
430
430
  }
431
-
432
- if (!isShadow) {
433
- const obj = this.handleMeshUiObjectWithoutShadowDom(parent, pressedOrClicked);
434
- if (obj) return true;
435
- }
431
+
432
+ // adding this to have a way for allowing to receive events on TMUI elements without shadow hierarchy
433
+ // if(parent["needle:use_eventsystem"] == true){
434
+ // // if use_eventsystem is true, we want to handle the event
435
+ // }
436
+ // else if (!isShadow) {
437
+ // const obj = this.handleMeshUiObjectWithoutShadowDom(parent, pressedOrClicked);
438
+ // if (obj) return true;
439
+ // }
436
440
  }
437
441
 
438
442
  if (clicked && debug)
src/engine-components/GroundProjection.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  import { GroundedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundedSkybox.js';
3
3
 
4
4
  import { serializable } from "../engine/engine_serialization_decorator.js";
5
- import { getParam,Watch as Watch } from "../engine/engine_utils.js";
6
- import { Behaviour, GameObject } from "./Component.js";
5
+ import { getParam, Watch as Watch } from "../engine/engine_utils.js";
6
+ import { Behaviour } from "./Component.js";
7
7
 
8
8
  const debug = getParam("debuggroundprojection");
9
9
 
@@ -69,13 +69,20 @@
69
69
  this.env?.removeFromParent();
70
70
  }
71
71
 
72
+ onEnterXR(): void {
73
+ this.updateProjection();
74
+ }
75
+ onLeaveXR(): void {
76
+ this.updateProjection();
77
+ }
78
+
72
79
  private updateAndCreate() {
73
80
  this.updateProjection();
74
81
  this._watcher?.apply();
75
82
  }
76
83
 
77
84
  updateProjection() {
78
- if (!this.context.scene.environment) {
85
+ if (!this.context.scene.environment || this.context.xr?.isPassThrough) {
79
86
  this.env?.removeFromParent();
80
87
  return;
81
88
  }
src/engine/assets/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { Box3, DoubleSide, Group, Mesh, MeshBasicMaterial, ShapeGeometry, Vector3 } from "three";
2
+ import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader";
3
+
4
+
1
5
  const logoSvgString = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 160 187.74"><defs><linearGradient id="a" x1="89.64" y1="184.81" x2="90.48" y2="21.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62d399"/><stop offset=".51" stop-color="#acd842"/><stop offset=".9" stop-color="#d7db0a"/></linearGradient><linearGradient id="b" x1="69.68" y1="178.9" x2="68.08" y2="16.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ba398"/><stop offset=".5" stop-color="#4ca352"/><stop offset="1" stop-color="#76a30a"/></linearGradient><linearGradient id="c" x1="36.6" y1="152.17" x2="34.7" y2="84.19" gradientUnits="userSpaceOnUse"><stop offset=".19" stop-color="#36a382"/><stop offset=".54" stop-color="#49a459"/><stop offset="1" stop-color="#76a30b"/></linearGradient><linearGradient id="d" x1="15.82" y1="153.24" x2="18" y2="90.86" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#267880"/><stop offset=".51" stop-color="#457a5c"/><stop offset="1" stop-color="#717516"/></linearGradient><linearGradient id="e" x1="135.08" y1="135.43" x2="148.93" y2="63.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0d939"/><stop offset="1" stop-color="#eadb04"/></linearGradient><linearGradient id="f" x1="-4163.25" y1="2285.12" x2="-4160.81" y2="2215.34" gradientTransform="rotate(20 4088.49 13316.712)" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#74af52"/><stop offset=".48" stop-color="#99be32"/><stop offset="1" stop-color="#c0c40a"/></linearGradient><symbol id="g" viewBox="0 0 160 187.74"><path style="fill:url(#a)" d="M79.32 36.98v150.76L95 174.54l6.59-156.31-22.27 18.75z"/><path style="fill:url(#b)" d="M79.32 36.98 57.05 18.23l6.59 156.31 15.68 13.2V36.98z"/><path style="fill:url(#c)" d="m25.19 104.83 8.63 49.04 12.5-14.95-2.46-56.42-18.67 22.33z"/><path style="fill:url(#d)" d="M25.19 104.83 0 90.24l16.97 53.86 16.85 9.77-8.63-49.04z"/><path style="fill:#9c3" d="M43.86 82.5 18.69 67.98 0 90.24l25.18 14.59L43.86 82.5z"/><path style="fill:url(#e)" d="m134.82 78.69-9.97 56.5 15.58-9.04L160 64.1l-25.18 14.59z"/><path style="fill:url(#f)" d="m134.82 78.69-18.68-22.33-2.86 65 11.57 13.83 9.97-56.5z"/><path style="fill:#ffe113" d="m160 64.1-18.69-22.26-25.17 14.52 18.67 22.33L160 64.1z"/><path style="fill:#f3e600" d="M101.59 18.23 79.32 0 57.05 18.23l22.27 18.75 22.27-18.75z"/></symbol></defs><use width="160" height="187.74" xlink:href="#g"/></svg>`;
2
6
  const logoSvgBlob = new Blob([logoSvgString], { type: "image/svg+xml;charset=utf-8" });
3
7
  const logoSvgUrl = URL.createObjectURL(logoSvgBlob);
@@ -16,4 +20,43 @@
16
20
  const needleLogoBlob = new Blob([needleLogoSvgString], { type: "image/svg+xml;charset=utf-8" });
17
21
  const needleLogoUrl = URL.createObjectURL(needleLogoBlob);
18
22
  /** Logo + Needle Typo */
19
- export const needleLogoSVG: string = needleLogoUrl
23
+ export const needleLogoSVG: string = needleLogoUrl
24
+
25
+
26
+
27
+ /** experimental
28
+ * @returns {Group} needle logo as a group of meshes with the needle logo. Size is normalized to one unit
29
+ */
30
+ export function needleLogoAsSVGObject() {
31
+
32
+ const loader = new SVGLoader();
33
+ const res = loader.parse(needleLogoSvgString);
34
+ const paths = res.paths;
35
+
36
+ const group = new Group();
37
+ const bounds = new Box3();
38
+ for (let i = 0; i < paths.length; i++) {
39
+ const path = paths[i];
40
+ const material = new MeshBasicMaterial({
41
+ color: path.color,
42
+ side: DoubleSide,
43
+ depthWrite: false
44
+ });
45
+ const shapes = SVGLoader.createShapes(path);
46
+ for (let j = 0; j < shapes.length; j++) {
47
+ const shape = shapes[j];
48
+ const geometry = new ShapeGeometry(shape);
49
+ const mesh = new Mesh(geometry, material);
50
+ group.add(mesh);
51
+ mesh.geometry.computeBoundingBox();
52
+ bounds.union(mesh.geometry.boundingBox!);
53
+ }
54
+ }
55
+ const maxSize = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y);
56
+ const normalizedScale = 1 / maxSize;
57
+ group.scale.set(normalizedScale, normalizedScale, normalizedScale);
58
+ group.rotateZ(Math.PI);
59
+
60
+ return group;
61
+
62
+ }
src/engine/xr/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export * from "./NeedleXRController.js";
2
- export * from "./NeedleXRSession.js";
3
- export * from "./NeedleXRSync.js"
4
- export * from "./utils.js"
5
- export * from "./XRRig.js";
src/engine/webcomponents/license-banner.ts DELETED
@@ -1,48 +0,0 @@
1
- import { NeedleLogoElement } from "./logo-element.js";
2
-
3
- const elementName = "needle-license-banner";
4
-
5
- export class LicenseBanner extends HTMLElement {
6
-
7
- static create() {
8
- return document.createElement(elementName);
9
- }
10
-
11
- constructor() {
12
-
13
- super();
14
- this.attachShadow({ mode: 'open' });
15
- const template = document.createElement('template');
16
- template.innerHTML = `<style>
17
- :host {
18
- position: relative;
19
- width: fit-content;
20
- height: fit-content;
21
- min-width: 100px;
22
- min-height: 30px;
23
- background: white;
24
- border-radius: .3rem;
25
- display: flex;
26
- flex-direction: column;
27
- justify-content: center;
28
- align-items: start;
29
- padding: .2rem 1rem .25rem .5rem;
30
- box-shadow: 0 0 1rem 0 rgba(0, 0, 0, .1);
31
- }
32
- `;
33
- const content = template.content.cloneNode(true) as HTMLElement;
34
- content.title = "Made with Needle Engine";
35
-
36
- this.shadowRoot?.appendChild(content);
37
-
38
- const logo = NeedleLogoElement.create();
39
- this.shadowRoot?.appendChild(logo);
40
-
41
- this.addEventListener("click", () => {
42
- globalThis.open("https://needle.tools", "_blank");
43
- });
44
- }
45
-
46
- }
47
- if (!customElements.get(elementName))
48
- customElements.define(elementName, LicenseBanner);
src/engine-components/Light.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  import { FrameEvent } from "../engine/engine_setup.js";
6
6
  import { setWorldPositionXYZ } from "../engine/engine_three_utils.js";
7
7
  import type { ILight } from "../engine/engine_types.js";
8
- import { getParam, isMobileDevice } from "../engine/engine_utils.js";
9
- import { type NeedleXREventArgs } from "../engine/xr/index.js";
8
+ import { getParam } from "../engine/engine_utils.js";
9
+ import { type NeedleXREventArgs } from "../engine/xr/api.js";
10
10
  import { Behaviour, GameObject } from "./Component.js";
11
11
  import { WebARSessionRoot } from "./webxr/WebARSessionRoot.js";
12
12
 
src/needle-engine.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import "./engine/engine_element.js";
2
2
  import "./engine/engine_setup.js";
3
+ import "./engine/engine_audio.js";
3
4
  export * from "./engine/api.js";
4
5
  export * from "./engine-components/api.js";
5
6
  export * from "./engine-components-experimental/api.js";
src/engine/webcomponents/needle-menu.ts DELETED
@@ -1,470 +0,0 @@
1
- import { isDevEnvironment } from "../debug/index.js";
2
- import type { Context } from "../engine_context.js";
3
- import { hasProLicense, onLicenseCheckResultChanged } from "../engine_license.js";
4
- import { getParam } from "../engine_utils.js";
5
- import { NeedleLogoElement } from "./logo-element.js";
6
-
7
- const elementName = "needle-menu";
8
- const debug = getParam("debugmenu");
9
-
10
- declare type MenuButtonOption = {
11
- text: string,
12
- onClick: () => void,
13
- }
14
-
15
- export class NeedleMenu {
16
- private readonly _context: Context;
17
- private readonly _menu: NeedleMenuElement;
18
-
19
- constructor(context: Context) {
20
- this._menu = NeedleMenuElement.getOrCreate(context.domElement);
21
- this._context = context;
22
- }
23
-
24
- /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent
25
- * @param position "top" or "bottom"
26
- */
27
- setPosition(position: "top" | "bottom") {
28
- this._menu.setPosition(position);
29
- }
30
-
31
- /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a needle engine license */
32
- showNeedleLogo(visible: boolean) {
33
- this._menu.showNeedleLogo(visible);
34
- }
35
-
36
- appendChild(child: HTMLElement) {
37
- this._menu.appendChild(child);
38
- }
39
-
40
- }
41
-
42
- export class NeedleMenuElement extends HTMLElement {
43
-
44
- static create() {
45
- // https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement#is
46
- return document.createElement(elementName, { is: elementName });
47
- }
48
-
49
- static getOrCreate(domElement: HTMLElement) {
50
- let element = domElement.querySelector(elementName) as NeedleMenuElement | null;
51
- if (!element && domElement.shadowRoot) {
52
- element = domElement.shadowRoot.querySelector(elementName);
53
- }
54
- if (!element) {
55
- element = NeedleMenuElement.create() as NeedleMenuElement;
56
- element._domElement = domElement;
57
- if (domElement.shadowRoot)
58
- domElement.shadowRoot.appendChild(element);
59
- else
60
- domElement.appendChild(element);
61
- }
62
- return element as NeedleMenuElement;
63
- }
64
-
65
- private _domElement: HTMLElement | null = null;
66
-
67
- constructor() {
68
- super();
69
- const template = document.createElement('template');
70
- // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space
71
- template.innerHTML = `<style>
72
-
73
- #root {
74
- position: absolute;
75
- width: auto;
76
- max-width: 95%;
77
- left: 50%;
78
- transform: translateX(-50%);
79
- top: 20px;
80
- padding: 0.3rem;
81
- background: #ffffff5c;
82
- display: flex;
83
- flex-direction: row-reverse; /* if we overflow this should be right aligned so the logo is always visible */
84
- outline: rgb(0 0 0 / 5%) 1px solid;
85
- border: 1px solid rgba(255, 255, 255, .1);
86
- border-radius: 1.1999999999999993rem;
87
- overflow: clip;
88
- box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05);
89
- backdrop-filter: blur(16px);
90
- }
91
-
92
- /** using a div here because then we can change the class for placement **/
93
- #root.bottom {
94
- top: auto;
95
- bottom: 30px;
96
- }
97
-
98
- .wrapper {
99
- position: relative;
100
- display: flex;
101
- flex-direction: row;
102
- justify-content: center;
103
- align-items: stretch;
104
- gap: 0px;
105
- padding: 0 .3rem;
106
- }
107
-
108
- .wrapper > *, .options > * {
109
- position: relative;
110
- border: none;
111
- border-radius: 0;
112
- outline: 1px solid rgba(0,0,0,0);
113
- display: flex;
114
- justify-content: center;
115
- align-items: center;
116
-
117
- /** basic font settings for all entries **/
118
- font-size: 1rem;
119
- font-family: 'Roboto Flex', sans-serif;
120
- font-optical-sizing: auto;
121
- font-weight: normal;
122
- font-variation-settings: "wdth" 100;
123
- color: rgb(40,40,40);
124
- }
125
-
126
- .options > * {
127
- padding: .4rem .5rem;
128
- }
129
-
130
- :host .options > * {
131
- background: transparent;
132
- border: none;
133
- white-space: nowrap;
134
- transition: all 0.1s linear .02s;
135
- border-radius: 0.8rem;
136
- }
137
- :host .options > *:hover {
138
- cursor: pointer;
139
- color: black;
140
- background: rgba(245, 245, 245, .8);
141
- outline: rgba(0,0,0,.05) 1px solid;
142
- }
143
-
144
-
145
- /** XR button animation **/
146
- :host button.this-mode-is-requested {
147
- background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
148
- background-size: 200% auto;
149
- background-position: 0 100%;
150
- animation: AnimationName .7s ease infinite forwards;
151
- }
152
- :host button.other-mode-is-requested {
153
- opacity: .5;
154
- }
155
-
156
- @keyframes AnimationName {
157
- 0% { background-position: 0% 0 }
158
- 100% { background-position: -200% 0 }
159
- }
160
-
161
-
162
-
163
-
164
- .logo {
165
- cursor: pointer;
166
- padding-left: 0.6rem;
167
- }
168
- :host .logo.any-options {
169
- border-left: 1px solid rgba(40,40,40,.4);
170
- margin-left: 0.3rem;
171
- }
172
-
173
- .logo > span {
174
- white-space: nowrap;
175
- }
176
-
177
-
178
-
179
- /** COMPACT */
180
- .compact .wrapper, .compact .options {
181
- height: auto;
182
- max-height: initial;
183
- flex-direction: column-reverse;
184
- }
185
-
186
- .compact .options > * {
187
- font-size: 1.2rem;
188
- padding: .6rem .5rem;
189
- }
190
- .compact .top .options {
191
- height: auto;
192
- flex-direction: column-reverse;
193
- }
194
- .compact .bottom .wrapper {
195
- height: auto;
196
- flex-direction: column;
197
- }
198
- .compact .logo {
199
- padding-left: 0;
200
- margin-left: 0.3rem;
201
- }
202
- .compact.bottom .logo.any-options {
203
- border: none;
204
- border-bottom: 1px solid rgba(40,40,40,.4);
205
- padding-bottom: .4rem;
206
- margin-bottom: .5rem;
207
- }
208
- .compact.top .logo.any-options {
209
- border: none;
210
- border-top: 1px solid rgba(40,40,40,.4);
211
- padding-top: .4rem;
212
- margin-top: .5rem;
213
- }
214
- .compact .options > button {
215
- width: 100%;
216
- }
217
-
218
-
219
-
220
- /* dark mode */
221
- /*
222
- @media (prefers-color-scheme: dark) {
223
- :host {
224
- background: rgba(0,0,0, .6);
225
- }
226
- :host button {
227
- color: rgba(200,200,200);
228
- }
229
- :host button:hover {
230
- background: rgba(100,100,100, .8);
231
- }
232
- }
233
- */
234
-
235
-
236
-
237
- </style>
238
-
239
- <div id="root" class="bottom">
240
- <div class="wrapper">
241
- <div class="options"></div>
242
- <div class="logo">
243
- <span class="madewith">powered by</span>
244
- </div>
245
- </div>
246
- </div>
247
- `;
248
-
249
- // we dont need to expose the shadow root
250
- const shadow = this.attachShadow({ mode: 'closed' });
251
- const content = template.content.cloneNode(true) as DocumentFragment;
252
- shadow?.appendChild(content);
253
- this.root = shadow.querySelector("#root") as HTMLDivElement;
254
-
255
- this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement;
256
- this.options = this.root?.querySelector(".options") as HTMLDivElement;
257
- this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement;
258
-
259
- this.root?.appendChild(this.wrapper);
260
- this.wrapper.classList.add("wrapper");
261
-
262
- const logo = NeedleLogoElement.create();
263
- logo.style.minHeight = "1rem";
264
- this.logoContainer.append(logo);
265
- this.logoContainer.addEventListener("click", () => {
266
- globalThis.open("https://needle.tools", "_blank");
267
- });
268
-
269
- // if the user has a license then we CAN hide the needle logo
270
- onLicenseCheckResultChanged(res => {
271
- if (res == true && hasProLicense()) {
272
- this.logoContainer.style.display = this._userRequestedLogoVisible ? "" : "none";
273
- }
274
- });
275
-
276
-
277
- // watch changes
278
- const observer = new MutationObserver(mutations => {
279
- for (const mutation of mutations) {
280
- if (mutation.type === 'childList') {
281
- this.onOptionsChildrenChanged(mutation);
282
- }
283
- }
284
- this.onChangeDetected(mutations);
285
- });
286
- observer.observe(this.root, { childList: true, subtree: true, attributes: true });
287
-
288
-
289
-
290
- if (debug) {
291
- this.___insertDebugOptions();
292
- }
293
- }
294
-
295
- connectedCallback() {
296
- window.addEventListener("resize", this.handleSizeChange);
297
- this._domElement?.addEventListener("resize", this.handleSizeChange);
298
- this.handleMenuVisible();
299
- }
300
- disconnectedCallback() {
301
- window.removeEventListener("resize", this.handleSizeChange);
302
- this._domElement?.removeEventListener("resize", this.handleSizeChange);
303
- }
304
-
305
- private _userRequestedLogoVisible?: boolean = undefined;
306
- showNeedleLogo(visible: boolean) {
307
- this._userRequestedLogoVisible = visible;
308
- if (!hasProLicense()) {
309
- if (isDevEnvironment()) console.warn("Needle Menu: You need a PRO license to hide the Needle Engine logo.");
310
- return;
311
- }
312
- this.logoContainer.style.display = visible ? "" : "none";
313
- }
314
-
315
- setPosition(position: "top" | "bottom") {
316
- // ensure the position is of a known type:
317
- if (position !== "top" && position !== "bottom") {
318
- return console.error("NeedleMenu.setPosition: invalid position", position);
319
- }
320
- this.root.classList.remove("top", "bottom");
321
- this.root.classList.add(position);
322
- }
323
-
324
- // private _root: ShadowRoot | null = null;
325
- private readonly root: HTMLDivElement;
326
- /** wraps the whole content */
327
- private readonly wrapper: HTMLDivElement;
328
- /** contains the buttons and dynamic elements */
329
- private readonly options: HTMLDivElement;
330
- /** contains the needle-logo html element */
331
- private readonly logoContainer: HTMLDivElement;
332
-
333
- append(...nodes: (string | Node)[]): void {
334
- for (const node of nodes) {
335
- if (typeof node === "string") {
336
- const element = document.createTextNode(node);
337
- this.options.appendChild(element);
338
- } else {
339
- this.options.appendChild(node);
340
- }
341
- }
342
- }
343
- appendChild<T extends Node>(node: T): T {
344
- return this.options.appendChild(node);
345
- }
346
- prepend(...nodes: (string | Node)[]): void {
347
- for (const node of nodes) {
348
- if (typeof node === "string") {
349
- const element = document.createTextNode(node);
350
- this.options.prepend(element);
351
- } else {
352
- this.options.prepend(node);
353
- }
354
- }
355
- }
356
-
357
-
358
-
359
- /** Called when any change in the web component is detected (including in children and child attributes) */
360
- private onChangeDetected(_mut: MutationRecord[]) {
361
- // if (debug) console.log("NeedleMenu.onChangeDetected", _mut);
362
- this.handleMenuVisible();
363
- }
364
-
365
- private onOptionsChildrenChanged(_mut: MutationRecord) {
366
- let anyVisibleOptions = false;
367
- for (let i = 0; i < this.options.children.length; i++) {
368
- const child = this.options.children[i] as HTMLElement;
369
- if (child.style.display != "none") {
370
- anyVisibleOptions = true;
371
- break;
372
- }
373
- }
374
- this.logoContainer.classList.toggle("any-options", anyVisibleOptions);
375
- this.handleSizeChange();
376
- }
377
-
378
-
379
-
380
-
381
- /** checks if the menu has any content and should be rendered at all
382
- * if we dont have any content and logo then we hide the menu
383
- */
384
- private handleMenuVisible() {
385
- if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent);
386
- if (this.hasAnyContent) {
387
- this.root.style.display = "";
388
- } else {
389
- this.root.style.display = "none";
390
- }
391
- }
392
-
393
- /** @returns true if we have any content OR a logo */
394
- get hasAnyContent() {
395
- return this.options.children.length > 0 || this.logoContainer.style.display != "none";
396
- }
397
-
398
-
399
- private _lastAvailableWidthChange = 0;
400
- private _timeoutHandle: number = 0;
401
-
402
- private handleSizeChange = () => {
403
- if (!this._domElement) return;
404
-
405
- const width = this._domElement.clientWidth;
406
- if (width < 500) {
407
- this.root.classList.add("compact");
408
- return;
409
- }
410
-
411
- const padding = 50;
412
- const availableWidth = width - padding * 2;
413
-
414
- // if the available width has not changed significantly then we can skip the rest
415
- if (Math.abs(availableWidth - this._lastAvailableWidthChange) < 5) return;
416
- this._lastAvailableWidthChange = availableWidth;
417
-
418
- clearTimeout(this._timeoutHandle!);
419
-
420
- this._timeoutHandle = setTimeout(() => {
421
- const currentWidth = this.root.clientWidth;
422
- const spaceLeft = availableWidth - currentWidth;
423
- if (spaceLeft <= 0) {
424
- this.root.classList.add("compact")
425
- }
426
- else if (spaceLeft > 5) {
427
- this.root.classList.remove("compact")
428
- }
429
- }, 200) as unknown as number;
430
-
431
- }
432
-
433
-
434
-
435
- private ___insertDebugOptions() {
436
- window.addEventListener("keydown", (e) => {
437
- if (e.key === "p") {
438
- this.setPosition(this.root.classList.contains("top") ? "bottom" : "top");
439
- }
440
- });
441
- const removeOptionsButton = document.createElement("button");
442
- removeOptionsButton.textContent = "Hide Buttons";
443
- removeOptionsButton.onclick = () => {
444
- const optionsChildren = new Array(this.options.children.length);
445
- for (let i = 0; i < this.options.children.length; i++) {
446
- optionsChildren[i] = this.options.children[i];
447
- }
448
- for (const child of optionsChildren) {
449
- this.options.removeChild(child);
450
- }
451
- setTimeout(() => {
452
- for (const child of optionsChildren) {
453
- this.options.appendChild(child);
454
- }
455
-
456
- }, 1000)
457
- };
458
- this.appendChild(removeOptionsButton);
459
- const anotherButton = document.createElement("button");
460
- anotherButton.textContent = "Toggle Logo";
461
- anotherButton.addEventListener("click", () => {
462
- this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none";
463
- });
464
- this.appendChild(anotherButton);
465
- }
466
- }
467
-
468
-
469
- if (!customElements.get(elementName))
470
- customElements.define(elementName, NeedleMenuElement);
src/engine-components/NeedleMenu.ts CHANGED
@@ -11,9 +11,30 @@
11
11
  @serializable()
12
12
  showNeedleLogo: boolean = true;
13
13
 
14
+ /** When enabled the menu will also be visible in VR/AR when you look up */
15
+ @serializable()
16
+ showSpatialMenu: boolean = true;
17
+
18
+ /** When enabled a button to enter fullscreen will be added to the menu */
19
+ @serializable()
20
+ createFullscreenButton: boolean = true;
21
+ /** When enabled a button to mute the application will be added to the menu */
22
+ @serializable()
23
+ createMuteButton: boolean = true;
24
+
14
25
  onEnable() {
26
+ this.applyOptions();
27
+ }
28
+
29
+ /** applies the options to `this.context.menu` */
30
+ applyOptions() {
15
31
  this.context.menu.setPosition(this.position);
16
32
  this.context.menu.showNeedleLogo(this.showNeedleLogo);
33
+ if (this.createFullscreenButton)
34
+ this.context.menu.showFullscreenOption(true);
35
+ if (this.createMuteButton)
36
+ this.context.menu.showAudioPlaybackOption(true);
37
+ this.context.menu.showSpatialMenu(this.showSpatialMenu);
17
38
  }
18
39
 
19
40
  }
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -9,11 +9,12 @@
9
9
  import { getTempQuaternion, getTempVector, getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../engine_three_utils.js";
10
10
  import type { ICamera, IComponent, INeedleXRSession } from "../engine_types.js";
11
11
  import { delay, getParam, isDesktop, isQuest } from "../engine_utils.js";
12
+ import { invokeXRSessionStart } from "./events.js"
12
13
  import { flipForwardMatrix, flipForwardQuaternion, ImplictXRRig } from "./internal.js";
13
14
  import { NeedleXRController } from "./NeedleXRController.js";
14
15
  import { NeedleXRSync } from "./NeedleXRSync.js";
15
16
  import { SceneTransition } from "./SceneTransition.js";
16
- import { TemporaryXRContext } from "./TempXRContext.js";
17
+ import { SessionInfo, TemporaryXRContext } from "./TempXRContext.js";
17
18
  import type { IXRRig } from "./XRRig.js";
18
19
 
19
20
 
@@ -82,11 +83,24 @@
82
83
  return;
83
84
  }
84
85
 
85
- navigator.xr?.addEventListener('sessiongranted', () => {
86
+ navigator.xr?.addEventListener('sessiongranted', async () => {
86
87
  console.log("Received Session Granted...")
87
- const lastSessionMode = sessionStorage.getItem("needle_xr_session_mode");
88
- const lastSessionInit = sessionStorage.getItem("needle_xr_session_init");
89
- if (lastSessionMode && lastSessionInit) {
88
+ await delay(100);
89
+
90
+ const lastSessionMode = sessionStorage.getItem("needle_xr_session_mode") as XRSessionMode;
91
+ const lastSessionInit = sessionStorage.getItem("needle_xr_session_init") ?? null;
92
+ const init = lastSessionInit ? JSON.parse(lastSessionInit) : null;
93
+
94
+ let info: SessionInfo | null = null;
95
+ if (contextIsLoading()) {
96
+ await TemporaryXRContext.start(lastSessionMode || "immersive-vr", init || NeedleXRSession.getDefaultSessionInit("immersive-vr"));
97
+ await waitForContextLoadingFinished();
98
+ info = await TemporaryXRContext.handoff();
99
+ }
100
+ if (info) {
101
+ NeedleXRSession.setSession(info.mode, info.session, info.init, Context.Current);
102
+ }
103
+ else if (lastSessionMode && lastSessionInit) {
90
104
  console.log("Session Granted: Restore last session")
91
105
  const init = JSON.parse(lastSessionInit);
92
106
  NeedleXRSession.start(lastSessionMode as XRSessionMode, init).catch(e => console.warn(e));
@@ -102,12 +116,33 @@
102
116
  sessionStorage.setItem("needle_xr_session_mode", mode);
103
117
  sessionStorage.setItem("needle_xr_session_init", JSON.stringify(init));
104
118
  }
105
-
106
119
  function deleteSessionInfo() {
107
120
  sessionStorage.removeItem("needle_xr_session_mode");
108
121
  sessionStorage.removeItem("needle_xr_session_init");
109
122
  }
110
123
 
124
+ const contexts_loading: Set<Context> = new Set();
125
+ ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, async cb => {
126
+ contexts_loading.add(cb.context);
127
+ });
128
+ ContextRegistry.registerCallback(ContextEvent.ContextCreated, async cb => {
129
+ contexts_loading.delete(cb.context);
130
+ });
131
+
132
+ function contextIsLoading() { return contexts_loading.size > 0; }
133
+ function waitForContextLoadingFinished(): Promise<void> {
134
+ return new Promise(res => {
135
+ const startTime = Date.now();
136
+ const interval = setInterval(() => {
137
+ if (!contextIsLoading() || Date.now() - startTime > 60000) {
138
+ clearInterval(interval);
139
+ res();
140
+ }
141
+ }, 100);
142
+ });
143
+ }
144
+
145
+
111
146
  if (isDesktop() && isDevEnvironment()) {
112
147
  window.addEventListener("keydown", (evt) => {
113
148
  if (evt.key === "x" || evt.key === "Escape") {
@@ -118,23 +153,23 @@
118
153
  });
119
154
  }
120
155
 
121
- if (getParam("simulatewebxrloading")) {
122
- ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, async _cb => {
123
- await delay(3000);
124
- setTimeout(async () => {
125
- const info = await TemporaryXRContext.handoff();
126
- if (info) NeedleXRSession.setSession(info.mode, info.session, info.init, Context.Current);
127
- else
128
- NeedleXRSession.start("immersive-vr")
129
- }, 6000)
130
- });
131
- let triggered = false;
132
- window.addEventListener("click", () => {
133
- if (triggered) return;
134
- triggered = true;
135
- TemporaryXRContext.start("immersive-vr", NeedleXRSession.getDefaultSessionInit("immersive-vr"));
136
- });
137
- }
156
+ // if (getParam("simulatewebxrloading")) {
157
+ // ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, async _cb => {
158
+ // await delay(3000);
159
+ // setTimeout(async () => {
160
+ // const info = await TemporaryXRContext.handoff();
161
+ // if (info) NeedleXRSession.setSession(info.mode, info.session, info.init, Context.Current);
162
+ // else
163
+ // NeedleXRSession.start("immersive-vr")
164
+ // }, 6000)
165
+ // });
166
+ // let triggered = false;
167
+ // window.addEventListener("click", () => {
168
+ // if (triggered) return;
169
+ // triggered = true;
170
+ // TemporaryXRContext.start("immersive-vr", NeedleXRSession.getDefaultSessionInit("immersive-vr"));
171
+ // });
172
+ // }
138
173
 
139
174
  /**
140
175
  * This class manages an XRSession to provide helper methods and events. It provides easy access to the XRInputSources (controllers and hands)
@@ -301,6 +336,12 @@
301
336
  */
302
337
  static async start(mode: XRSessionMode, init?: XRSessionInit, context?: Context): Promise<NeedleXRSession | null> {
303
338
 
339
+ if (isDevEnvironment() && getParam("debugxrpreroom")) {
340
+ console.warn("Debug: Starting temporary XR session");
341
+ await TemporaryXRContext.start(mode, init || NeedleXRSession.getDefaultSessionInit(mode));
342
+ return null;
343
+ }
344
+
304
345
  if (this._currentSessionRequest) {
305
346
  console.warn("A XRSession is already being requested");
306
347
  if (debug || isDevEnvironment()) showBalloonWarning("A XRSession is already being requested");
@@ -748,8 +789,13 @@
748
789
  // register already connected input sources
749
790
  // this is for when the session is already running (via a temporary xr session)
750
791
  // and the controllers are already connected
751
- for (const sources of this.session.inputSources) {
752
- this.onInputSourceAdded(sources);
792
+ for (let i = 0; i < session.inputSources.length; i++) {
793
+ const inputSource = session.inputSources[i];
794
+ if (!inputSource.handedness) {
795
+ console.warn("Input source in xr session has no handedness - ignoring", i);
796
+ continue;
797
+ }
798
+ this.onInputSourceAdded(inputSource);
753
799
  }
754
800
 
755
801
  // handle controller and input source changes changes
@@ -768,7 +814,7 @@
768
814
  // we set the session on the webxr manager at the end because we want to receive inputsource events first
769
815
  // e.g. in case there's a bug in the threejs codebase
770
816
  this.context.xr = this;
771
- this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet)
817
+ this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet);
772
818
  }
773
819
 
774
820
  /** called when renderer.setSession is fulfilled */
@@ -798,14 +844,14 @@
798
844
  console.warn("Controller already exists for input source", index);
799
845
  return;
800
846
  }
847
+ console.log("Adding controller", index);
801
848
  const newController = new NeedleXRController(this, newInputSource, index);
802
849
  this._newControllers.push(newController);
803
850
  }
804
851
 
805
852
  /** Disconnects the controller, invokes events and notifies previou controller (if any) */
806
853
  private disconnectInputSource(inputSource: XRInputSource) {
807
- for (let i = this.controllers.length - 1; i >= 0; i--) {
808
- const oldController = this.controllers[i];
854
+ const handleController = (oldController: NeedleXRController, _array: Array<NeedleXRController>, i: number) => {
809
855
  if (oldController.inputSource === inputSource) {
810
856
  console.log("Disconnecting controller", oldController.index);
811
857
  this.controllers.splice(i, 1);
@@ -821,6 +867,14 @@
821
867
  oldController.onDisconnected();
822
868
  }
823
869
  }
870
+ for (let i = this.controllers.length - 1; i >= 0; i--) {
871
+ const oldController = this.controllers[i];
872
+ handleController(oldController, this.controllers, i);
873
+ }
874
+ for (let i = this._newControllers.length - 1; i >= 0; i--) {
875
+ const oldController = this._newControllers[i];
876
+ handleController(oldController, this._newControllers, i);
877
+ }
824
878
  }
825
879
 
826
880
  /** End the XR Session */
@@ -857,7 +911,7 @@
857
911
  this.context.xr = null;
858
912
  this.context.renderer.xr.enabled = false;
859
913
  // apply the clearflags at the beginning of the next frame
860
- this.context.pre_update_oneshot_callbacks.push(()=>{
914
+ this.context.pre_update_oneshot_callbacks.push(() => {
861
915
  this.context.mainCameraComponent?.applyClearFlags()
862
916
  });
863
917
 
@@ -949,6 +1003,8 @@
949
1003
  if (!this._didStart) {
950
1004
  this._didStart = true;
951
1005
 
1006
+ invokeXRSessionStart({ session: this });
1007
+
952
1008
  for (const listener of NeedleXRSession._xrStartListeners) {
953
1009
  listener(args);
954
1010
  }
src/engine-components/js-extensions/RGBAColor.ts CHANGED
@@ -32,20 +32,20 @@
32
32
 
33
33
  lerp(color: Color, alpha: number): this {
34
34
  const rgba = color as RGBAColor;
35
- if(rgba.alpha) this.alpha = Mathf.lerp(this.alpha, rgba.alpha, alpha);
35
+ if(rgba.alpha != undefined) this.alpha = Mathf.lerp(this.alpha, rgba.alpha, alpha);
36
36
  return super.lerp(color, alpha);
37
37
  }
38
38
 
39
39
  lerpColors(color1: Color, color2: Color, alpha: number): this {
40
40
  const rgba1 = color1 as RGBAColor;
41
41
  const rgba2 = color2 as RGBAColor;
42
- if(rgba1.alpha && rgba2.alpha) this.alpha = Mathf.lerp(rgba1.alpha, rgba2.alpha, alpha);
42
+ if(rgba1.alpha != undefined && rgba2.alpha != undefined) this.alpha = Mathf.lerp(rgba1.alpha, rgba2.alpha, alpha);
43
43
  return super.lerpColors(color1, color2, alpha);
44
44
  }
45
45
 
46
46
  multiply(color: Color): this {
47
47
  const rgba = color as RGBAColor;
48
- if(rgba.alpha) this.alpha = this.alpha * rgba.alpha;
48
+ if(rgba.alpha != undefined) this.alpha = this.alpha * rgba.alpha;
49
49
  return super.multiply(color);
50
50
  }
51
51
 
src/engine-components/ScreenCapture.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { showBalloonWarning } from "../engine/debug/index.js";
1
+ import { isDevEnvironment, showBalloonWarning } from "../engine/debug/index.js";
2
2
  import { RoomEvents } from "../engine/engine_networking.js";
3
- import { disposeStream, NetworkedStreamEvents,NetworkedStreams, PeerHandle, StreamEndedEvent, StreamReceivedEvent } from "../engine/engine_networking_streams.js";
3
+ import { disposeStream, NetworkedStreamEvents, NetworkedStreams, PeerHandle, StreamEndedEvent, StreamReceivedEvent } from "../engine/engine_networking_streams.js";
4
4
  import { serializable } from "../engine/engine_serialization.js";
5
5
  import { delay, getParam } from "../engine/engine_utils.js";
6
6
  import { AudioSource } from "./AudioSource.js";
@@ -181,7 +181,7 @@
181
181
  async share(opts?: ScreenCaptureOptions) {
182
182
  if (this._activeShareRequest) return this._activeShareRequest;
183
183
  this._activeShareRequest = this.internalShare(opts);
184
- return this._activeShareRequest.then(() =>{
184
+ return this._activeShareRequest.then(() => {
185
185
  return this._activeShareRequest = null;
186
186
  })
187
187
  }
@@ -189,6 +189,7 @@
189
189
  private async internalShare(opts?: ScreenCaptureOptions) {
190
190
  if (this.context.connection.isInRoom === false) {
191
191
  console.warn("Can not start screensharing: requires network connection");
192
+ if (isDevEnvironment()) showBalloonWarning("Can not start screensharing: requires network connection. Add a SyncedRoom component or join a room first.");
192
193
  return;
193
194
  }
194
195
 
src/engine-components/SyncedRoom.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import { isDevEnvironment, showBalloonWarning } from "../engine/debug/index.js";
2
+ import { Mathf } from "../engine/engine_math.js";
3
+ import { RoomEvents } from "../engine/engine_networking.js";
1
4
  import { serializable } from "../engine/engine_serialization_decorator.js";
2
5
  import * as utils from "../engine/engine_utils.js"
3
6
  import { getParam } from "../engine/engine_utils.js";
7
+ import { getIconElement } from "../engine/webcomponents/icons.js";
4
8
  import { Behaviour } from "./Component.js";
5
9
 
6
10
  const viewParamName = "view";
@@ -19,6 +23,9 @@
19
23
  @serializable()
20
24
  public autoRejoin: boolean = true;
21
25
 
26
+ @serializable()
27
+ public createJoinButton: boolean = true;
28
+
22
29
  private _roomPrefix?: string;
23
30
 
24
31
  public get RoomPrefix(): string | undefined {
@@ -44,19 +51,30 @@
44
51
  // If setup to join a random room
45
52
  if (this.joinRandomRoom || getParam(this.urlParameterName))
46
53
  this.tryJoinRoom();
54
+
55
+ if (this.createJoinButton) {
56
+ const button = this.createRoomButton();
57
+ this.context.menu.appendChild(button);
58
+ }
47
59
  }
48
60
 
49
61
  onDisable(): void {
62
+ this._roomButton?.remove();
50
63
  if (this.roomName && this.roomName.length > 0)
51
64
  this.context.connection.leaveRoom(this.roomName);
52
65
  }
53
66
 
67
+ onDestroy(): void {
68
+ this.destroyRoomButton();
69
+ }
70
+
54
71
  /** Will generate a random room name, set it as an URL parameter and attempt to join the room */
55
72
  tryJoinRandomRoom() {
56
73
  this.setRandomRoomUrlParameter();
57
74
  this.tryJoinRoom();
58
75
  }
59
76
 
77
+ private _lastJoinedRoom = "";
60
78
  /** Try to join the currently set roomName */
61
79
  tryJoinRoom(call: number = 0): boolean {
62
80
  if (call === undefined) call = 0;
@@ -94,15 +112,18 @@
94
112
 
95
113
  if (debug) console.log("Join " + this.roomName)
96
114
 
115
+ this._lastJoinedRoom = this.roomName;
97
116
  if (this._roomPrefix)
98
117
  this.roomName = this._roomPrefix + this.roomName;
99
118
 
119
+ this._userWantsToBeInARoom = true;
100
120
  this.context.connection.joinRoom(this.roomName);
101
121
  return true;
102
122
  }
103
123
 
104
124
  private _lastPingTime: number = 0;
105
125
  private _lastRoomTime: number = -1;
126
+ private _userWantsToBeInARoom = false;
106
127
 
107
128
  update(): void {
108
129
  if (this.context.connection.isConnected) {
@@ -120,11 +141,13 @@
120
141
  this._lastRoomTime = -1;
121
142
 
122
143
  if (this.autoRejoin) {
123
- console.log("Disconnected from networking backend - attempt reconnecting now")
124
- this.tryJoinRoom();
144
+ if (this._userWantsToBeInARoom) {
145
+ console.log("Disconnected from networking backend - attempt reconnecting now")
146
+ this.tryJoinRoom();
147
+ }
125
148
  }
126
- else
127
- console.warn("You are not connected to a room anymore (possibly because the tab was inactive for too long and the server kicked you)");
149
+ else if (isDevEnvironment())
150
+ console.warn("You are not connected to a room anymore (possibly because the tab was inactive for too long and the server kicked you?)");
128
151
  }
129
152
  }
130
153
 
@@ -148,7 +171,7 @@
148
171
 
149
172
  generateRoomName(): string {
150
173
  const words = utils.makeIdFromRandomWords();
151
- const roomName = words + "_" + utils.randomNumber(100, 999);
174
+ const roomName = words + "_" + Math.floor(Mathf.random(100, 999));
152
175
  return roomName;
153
176
  }
154
177
 
@@ -163,4 +186,65 @@
163
186
  }
164
187
  return null;
165
188
  }
189
+
190
+
191
+
192
+ private _roomButton?: HTMLButtonElement;
193
+ private _roomButtonIconJoin?: HTMLElement;
194
+ private _roomButtonIconLeave?: HTMLElement;
195
+ private createRoomButton() {
196
+ if (this._roomButton) {
197
+ return this._roomButton;
198
+ }
199
+ const button = document.createElement("button");
200
+ this._roomButton = button;
201
+ button.classList.add("create-room-button");
202
+ button.setAttribute("priority", "90");
203
+ button.onclick = () => {
204
+ if (this.context.connection.isInRoom) {
205
+ if (this.urlParameterName) {
206
+ utils.setParamWithoutReload(this.urlParameterName, null);
207
+ }
208
+ this.context.connection.leaveRoom();
209
+ this._userWantsToBeInARoom = false;
210
+ }
211
+ else {
212
+ if (this.urlParameterName) {
213
+ if (!getParam(this.urlParameterName)) {
214
+ if(this._lastJoinedRoom)
215
+ utils.setParamWithoutReload(this.urlParameterName, this._lastJoinedRoom);
216
+ else
217
+ this.setRandomRoomUrlParameter();
218
+ };
219
+ }
220
+ this.tryJoinRoom();
221
+ }
222
+ };
223
+ this._roomButtonIconJoin = getIconElement("group");
224
+ this._roomButtonIconLeave = getIconElement("group_off");
225
+ this.updateRoomButtonState();
226
+ this.context.connection.beginListen(RoomEvents.JoinedRoom, this.updateRoomButtonState);
227
+ this.context.connection.beginListen(RoomEvents.LeftRoom, this.updateRoomButtonState);
228
+ return button;
229
+ }
230
+ private updateRoomButtonState = () => {
231
+ if (!this._roomButton) return;
232
+
233
+ if (this.context.connection.isInRoom) {
234
+ this._roomButton.title = "Leave the networked room";
235
+ this._roomButton.textContent = "Leave Room";
236
+ this._roomButtonIconJoin?.remove();
237
+ this._roomButton.prepend(this._roomButtonIconLeave!);
238
+ }
239
+ else {
240
+ this._roomButton.title = "Create or join a networked room";
241
+ this._roomButton.textContent = "Join Room";
242
+ this._roomButtonIconLeave?.remove();
243
+ this._roomButton.prepend(this._roomButtonIconJoin!);
244
+ }
245
+ }
246
+ private destroyRoomButton() {
247
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.updateRoomButtonState);
248
+ this.context.connection.stopListen(RoomEvents.LeftRoom, this.updateRoomButtonState);
249
+ }
166
250
  }
src/engine/xr/TempXRContext.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { AxesHelper, Camera, Color, DirectionalLight, GridHelper, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Scene, WebGLRenderer } from "three";
1
+ import { AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelper, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Scene, WebGLRenderer } from "three";
2
2
 
3
3
  import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
4
4
  import { Mathf } from "../engine_math.js";
5
5
  import { delay } from "../engine_utils.js";
6
6
 
7
- declare type SessionInfo = { session: XRSession, mode: XRSessionMode, init: XRSessionInit };
7
+ export declare type SessionInfo = { session: XRSession, mode: XRSessionMode, init: XRSessionInit };
8
8
 
9
9
  /** Create with static `start`- used to start an XR session while waiting for session granted */
10
10
  export class TemporaryXRContext {
@@ -68,6 +68,10 @@
68
68
  private readonly _mode: XRSessionMode;
69
69
  private readonly _init: XRSessionInit;
70
70
 
71
+ get isAR() {
72
+ return this._mode === "immersive-ar";
73
+ }
74
+
71
75
  private readonly _renderer: WebGLRenderer;
72
76
  private readonly _camera: Camera;
73
77
  private readonly _scene: Scene;
@@ -84,6 +88,7 @@
84
88
  this._renderer.xr.enabled = true;
85
89
  this._camera = new PerspectiveCamera();
86
90
  this._scene = new Scene();
91
+ this._scene.fog = new Fog(0x444444, 10, 250);
87
92
  this._scene.add(this._camera);
88
93
  this.setupScene();
89
94
  }
@@ -129,55 +134,84 @@
129
134
 
130
135
  /** can be used to prepare the user or fade to black */
131
136
  private async onBeforeHandoff() {
132
- const obj = ObjectUtils.createPrimitive(PrimitiveType.Cube);
133
- obj.position.z = -3;
134
- obj.position.y = .5;
135
- this._scene.add(obj);
136
- await delay(4000);
137
+ // for(const sphere of this._spheres) {
138
+ // sphere.removeFromParent();
139
+ // await delay(10);
140
+ // }
141
+
142
+ // const obj = ObjectUtils.createPrimitive(PrimitiveType.Cube);
143
+ // obj.position.z = -3;
144
+ // obj.position.y = .5;
145
+ // this._scene.add(obj);
146
+ await delay(1000);
137
147
  this._scene.clear();
138
- await delay(100);
148
+ // await delay(100);
139
149
  }
140
150
 
141
151
 
142
- private _spheres: Mesh[] = [];
152
+ private _objects: Mesh[] = [];
143
153
  private setupScene() {
144
154
  this._scene.background = new Color(0x000000);
145
- this._scene.add(new GridHelper(5, 10, 0x111111, 0x222222));
155
+ this._scene.add(new GridHelper(5, 10, 0x111111, 0x111111));
146
156
 
147
157
  const light = new DirectionalLight(0xffffff, 1);
148
- light.position.set(2, 2, 2);
158
+ light.position.set(0, 20, 0);
149
159
  light.castShadow = false;
150
160
  this._scene.add(light);
151
161
 
152
162
  const light2 = new DirectionalLight(0xffffff, 1);
153
- light2.position.set(-2, -2, -2);
163
+ light2.position.set(0, -1, 0);
154
164
  light2.castShadow = false;
155
165
  this._scene.add(light2);
156
166
 
157
- const sphereRange = 50;
167
+ const light3 = new PointLight(0xffffff, 1, 100, 1);
168
+ light3.position.set(0, 2, 0);
169
+ light3.castShadow = false;
170
+ light3.distance = 200;
171
+ this._scene.add(light3);
172
+
173
+ const range = 50;
158
174
  for (let i = 0; i < 100; i++) {
159
- const sphere = ObjectUtils.createPrimitive(PrimitiveType.Sphere, {
160
- material: new MeshStandardMaterial({
161
- color: 0x222222,
162
- metalness: 1,
163
- roughness: .8,
164
- })
175
+ const material = new MeshStandardMaterial({
176
+ color: 0x222222,
177
+ metalness: 1,
178
+ roughness: .8,
165
179
  });
166
- sphere.position.x = Mathf.random(-sphereRange, sphereRange);
167
- sphere.position.y = Mathf.random(3, 40);
168
- sphere.position.z = Mathf.random(-sphereRange, sphereRange);
169
- sphere.scale.multiplyScalar(2);
170
- this._spheres.push(sphere);
171
- this._scene.add(sphere);
180
+ // if we're in passthrough
181
+ if (this.isAR) {
182
+ material.emissive = new Color(Math.random(), Math.random(), Math.random());
183
+ material.emissiveIntensity = Math.random();
184
+ }
185
+ const type = Mathf.random(0, 1) > .5 ? PrimitiveType.Sphere : PrimitiveType.Cube;
186
+ const obj = ObjectUtils.createPrimitive(type, {
187
+ material
188
+ });
189
+ obj.position.x = Mathf.random(-range, range);
190
+ obj.position.y = Mathf.random(-2, range);
191
+ obj.position.z = Mathf.random(-range, range);
192
+ // random rotation
193
+ obj.rotation.x = Mathf.random(0, Math.PI * 2);
194
+ obj.rotation.y = Mathf.random(0, Math.PI * 2);
195
+ obj.rotation.z = Mathf.random(0, Math.PI * 2);
196
+ obj.scale.multiplyScalar(.5 + Math.random() * 10);
197
+
198
+ const dist = obj.position.distanceTo(this._camera.position) - obj.scale.x;
199
+ if (dist < 1) {
200
+ obj.position.multiplyScalar(1 + 1 / dist);
201
+ }
202
+
203
+ this._objects.push(obj);
204
+ this._scene.add(obj);
172
205
  }
173
206
  }
174
207
 
175
208
  private update(time: number, _deltaTime: number) {
176
209
 
177
210
  const speed = time * .0004;
178
- for (let i = 0; i < this._spheres.length; i++) {
179
- const sphere = this._spheres[i];
180
- sphere.position.y += Math.sin(speed + i * .5) * 0.002;
211
+ for (let i = 0; i < this._objects.length; i++) {
212
+ const obj = this._objects[i];
213
+ obj.position.y += Math.sin(speed + i * .5) * 0.005;
214
+ obj.rotateY(.002);
181
215
  }
182
216
  }
183
217
  }
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -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, { queue: InputEventQueue.Early });
138
+ this.context.input.addEventListener("pointerup", this.onPlaceScene, { queue: InputEventQueue.Late });
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, { queue: InputEventQueue.Early });
142
+ this.context.input.removeEventListener("pointerup", this.onPlaceScene, { queue: InputEventQueue.Late });
143
143
  this.onRevertSceneChanges();
144
144
  // this._anchor?.delete();
145
145
  this._anchor = null;
@@ -275,6 +275,7 @@
275
275
 
276
276
  private onPlaceScene = (evt: NEPointerEvent) => {
277
277
  if (this._isPlacing == false) return;
278
+ if(evt.used) return;
278
279
 
279
280
  let reticle: IGameObject | undefined = this._reticle[0];
280
281
  let hit = this._hits[0];
src/engine-components/webxr/WebXR.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { serializable } from "../../engine/engine_serialization.js";
6
6
  import { getParam, isDesktop, isiOS, isMobileDevice, isQuest, isSafari } from "../../engine/engine_utils.js";
7
7
  import { type NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
8
+ import { getIconElement } from "../../engine/webcomponents/icons.js";
8
9
  import { PlayerSync } from "../../engine-components-experimental/networking/PlayerSync.js";
9
10
  import { Behaviour, GameObject } from "../Component.js";
10
11
  import { USDZExporter } from "../export/usdz/USDZExporter.js";
@@ -271,36 +272,56 @@
271
272
  private _buttonFactory?: WebXRButtonFactory;
272
273
 
273
274
  private handleCreatingHTML() {
275
+ const xrButtonsPriority = 50;
274
276
 
275
277
  if (this.createARButton || this.createVRButton || this.useQuicklookExport) {
276
278
  // Quicklook / iOS
277
279
  if ((isiOS() && isSafari()) || debugQuicklook) {
278
280
  if (this.useQuicklookExport) {
279
- this.addButton(this.getButtonsFactory().createQuicklookButton());
281
+ const button = this.getButtonsFactory().createQuicklookButton();
282
+ button.prepend(getIconElement("view_in_ar"));
283
+ this.addButton(button, xrButtonsPriority);
280
284
  }
281
285
  }
282
286
  // WebXR
283
- if (this.createARButton) this.addButton(this.getButtonsFactory().createARButton());
284
- if (this.createVRButton) this.addButton(this.getButtonsFactory().createVRButton());
287
+ if (this.createARButton) {
288
+ const arbutton = this.getButtonsFactory().createARButton();
289
+ arbutton.prepend(getIconElement("view_in_ar"))
290
+ this.addButton(arbutton, xrButtonsPriority);
291
+ }
292
+ if (this.createVRButton) {
293
+ const vrbutton = this.getButtonsFactory().createVRButton();
294
+ vrbutton.prepend(getIconElement("panorama_photosphere"));
295
+ this.addButton(vrbutton, xrButtonsPriority);
296
+ }
285
297
  }
286
298
 
287
299
  if (this.createSendToQuestButton && !isQuest()) {
288
300
  NeedleXRSession.isVRSupported().then(supported => {
289
- if (!supported) this.addButton(this.getButtonsFactory().createSendToQuestButton());
301
+ if (!supported) {
302
+ const button = this.getButtonsFactory().createSendToQuestButton();
303
+ button.prepend(getIconElement("share_windows"));
304
+ this.addButton(button, xrButtonsPriority);
305
+ }
290
306
  });
291
307
  }
292
308
 
293
309
  if (this.createQRCode && !isMobileDevice()) {
294
310
  NeedleXRSession.isXRSupported().then(supported => {
295
- if (isDesktop() || !supported) this.addButton(this.getButtonsFactory().createQRCode());
311
+ if (isDesktop() || !supported) {
312
+ const qrCode = this.getButtonsFactory().createQRCode();
313
+ qrCode.prepend(getIconElement("qr_code"));
314
+ this.addButton(qrCode, xrButtonsPriority);
315
+ }
296
316
  });
297
317
  }
298
318
  }
299
319
 
300
320
  private readonly _buttons: HTMLElement[] = [];
301
321
 
302
- private addButton(button: HTMLElement) {
322
+ private addButton(button: HTMLElement, priority: number) {
303
323
  this._buttons.push(button);
324
+ button.setAttribute("priority", priority.toString());
304
325
  this.context.menu.appendChild(button);
305
326
  }
306
327
 
src/engine-components/webxr/WebXRButtons.ts CHANGED
@@ -87,7 +87,7 @@
87
87
  button.classList.add("webxr-button");
88
88
  button.dataset["needle"] = "webxr-ar-button";
89
89
  button.innerText = "Enter AR";
90
- button.title = "Click to start a WebXR session in AR";
90
+ button.title = "Click to start an AR session";
91
91
  button.addEventListener("click", () => NeedleXRSession.start(mode, init));
92
92
  this.updateSessionSupported(button, mode);
93
93
  this.listenToXRSessionState(button, mode);
@@ -120,7 +120,7 @@
120
120
  button.classList.add("webxr-button");
121
121
  button.dataset["needle"] = "webxr-vr-button";
122
122
  button.innerText = "Enter VR";
123
- button.title = "Click to start a WebXR session in VR";
123
+ button.title = "Click to start a VR session";
124
124
  button.addEventListener("click", () => NeedleXRSession.start(mode, init));
125
125
  this.updateSessionSupported(button, mode);
126
126
  this.listenToXRSessionState(button, mode);
@@ -214,9 +214,15 @@
214
214
  qrCodeContainer.style.top = `calc(${buttonRect.bottom}px + ${qrCodeContainer.style.padding} * .6)`;
215
215
  else
216
216
  qrCodeContainer.style.top = `calc(${buttonRect.top - containerRect.height}px - ${qrCodeContainer.style.padding} * 2.5)`;
217
+ qrCodeContainer.style.opacity = "0";
218
+ qrCodeContainer.style.pointerEvents = "all";
219
+ qrCodeContainer.style.transition = "opacity 0.2s ease-in-out";
217
220
 
218
221
  // context click to hide the QR code again, if we dont timeout the event will be triggered immediately
219
- setTimeout(() => window.addEventListener("click", hideQRCode, { once: true }));
222
+ setTimeout(() => {
223
+ qrCodeContainer.style.opacity = "1";
224
+ window.addEventListener("click", hideQRCode, { once: true })
225
+ });
220
226
  window.addEventListener("resize", hideQRCode);
221
227
  window.addEventListener("scroll", hideQRCode);
222
228
 
@@ -225,7 +231,10 @@
225
231
 
226
232
  /** hides to QRCode overlay and unsubscribes from events */
227
233
  function hideQRCode() {
228
- qrCodeContainer.parentNode?.removeChild(qrCodeContainer);
234
+ qrCodeContainer.style.pointerEvents = "none";
235
+ qrCodeContainer.style.transition = "opacity 0.2s";
236
+ qrCodeContainer.style.opacity = "0";
237
+ setTimeout(() => qrCodeContainer.parentNode?.removeChild(qrCodeContainer), 500);
229
238
  window.removeEventListener("click", hideQRCode);
230
239
  window.removeEventListener("resize", hideQRCode);
231
240
  window.removeEventListener("scroll", hideQRCode);
src/engine-components/webxr/WebXRImageTracking.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { Object3D, Quaternion, Vector3 } from "three";
2
2
 
3
- import { showBalloonMessage, showBalloonWarning } from "../../engine/debug/index.js";
3
+ import { showBalloonWarning } from "../../engine/debug/index.js";
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 { type NeedleXREventArgs, NeedleXRSession } from "../../engine/xr/index.js";
7
+ import { type NeedleXREventArgs, NeedleXRSession } from "../../engine/xr/api.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";
11
- import { InstancingUtil, Renderer } from "../Renderer.js";
11
+ import { Renderer } from "../Renderer.js";
12
12
 
13
13
  // https://github.com/immersive-web/marker-tracking/blob/main/explainer.md
14
14
 
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -188,7 +188,7 @@
188
188
  line.quaternion.copy(rot);
189
189
  const scale = session.rigScale;
190
190
  const dist = this._hitDistances[i] ?? 1;
191
- line.scale.set(scale, scale, scale * dist);
191
+ line.scale.set(scale, scale, dist);
192
192
  line.visible = true;
193
193
  line.layers.disableAll();
194
194
  line.layers.enable(2);
@@ -232,7 +232,7 @@
232
232
  this._hitDiscs[i] = disc;
233
233
  }
234
234
  disc.visible = true;
235
- const size = (.01 * (1 + hit.distance));
235
+ const size = (.01 * (rigScale + hit.distance));
236
236
  disc.scale.set(size, size, size);
237
237
  disc.layers.disableAll();
238
238
  disc.layers.enable(2);
src/engine/webcomponents/api.ts ADDED
@@ -0,0 +1,3 @@
1
+
2
+
3
+ export { type NeedleMenuPostMessageModel } from "./needle menu/needle-menu.js"
src/engine/xr/api.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./NeedleXRController.js";
2
+ export * from "./NeedleXRSession.js";
3
+ export * from "./NeedleXRSync.js"
4
+ export * from "./utils.js"
5
+ export * from "./XRRig.js";
src/engine/webcomponents/buttons.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { IContext } from "../engine_types.js";
2
+ import { getIconElement } from "./icons.js";
3
+
4
+ /** Use the ButtonsFactory to create buttons with icons and functionality
5
+ * Get access to the default buttons by using `ButtonsFactory.getOrCreate()`
6
+ * The factory will create the buttons if they don't exist yet, and return the existing ones if they do (this allows you to reparent or modify created buttons)
7
+ */
8
+ export class ButtonsFactory {
9
+
10
+ private static _instance?: ButtonsFactory;
11
+ /** get access to the default factory */
12
+ static getOrCreate() {
13
+ if (!this._instance) {
14
+ this._instance = new ButtonsFactory();
15
+ }
16
+ return this._instance;
17
+ }
18
+ /** create a new buttons factory */
19
+ static create() {
20
+ return new ButtonsFactory();
21
+ }
22
+
23
+
24
+ private _fullscreenButton?: HTMLButtonElement;
25
+ /** Create a fullscreen button (or return the existing one if it already exists) */
26
+ createFullscreenButton(_ctx: IContext) {
27
+ if (this._fullscreenButton) {
28
+ return this._fullscreenButton;
29
+ }
30
+ const button = document.createElement("button");
31
+ this._fullscreenButton = button;
32
+ button.classList.add("fullscreen-button");
33
+ button.title = "Click to enter fullscreen mode";
34
+ const enterFullscreenIcon = getIconElement("fullscreen");
35
+ const exitFullscreenIcon = getIconElement("fullscreen_exit");
36
+ button.appendChild(enterFullscreenIcon);
37
+ button.onclick = () => {
38
+ if (document.fullscreenElement) {
39
+ document.exitFullscreen();
40
+ } else {
41
+ document.documentElement.requestFullscreen();
42
+ }
43
+ };
44
+ document.addEventListener("fullscreenchange", () => {
45
+ if (document.fullscreenElement) {
46
+ enterFullscreenIcon.remove();
47
+ button.appendChild(exitFullscreenIcon);
48
+ button.title = "Click to enter fullscreen mode";
49
+ } else {
50
+ exitFullscreenIcon.remove();
51
+ button.appendChild(enterFullscreenIcon);
52
+ button.title = "Click to exit fullscreen mode";
53
+ }
54
+ });
55
+ // xr session started?
56
+ document.addEventListener("needle-xrsession-start", () => {
57
+ button.style.display = "none";
58
+ });
59
+ document.addEventListener("needle-xrsession-end", () => {
60
+ button.style.display = "";
61
+ });
62
+ return button;
63
+ }
64
+
65
+ private _muteButton?: HTMLButtonElement;
66
+ /** Create a mute button (or return the existing one if it already exists) */
67
+ createMuteButton(ctx: IContext) {
68
+ if (this._muteButton) {
69
+ return this._muteButton;
70
+ }
71
+ const button = document.createElement("button");
72
+ this._muteButton = button;
73
+ button.classList.add("mute-button");
74
+ button.title = "Click to mute/unmute";
75
+ const muteIcon = getIconElement("volume_off");
76
+ const unmuteIcon = getIconElement("volume_up");
77
+
78
+ // save state in session storage (this needs consent)
79
+ // if (sessionStorage.getItem("muted") === "true") {
80
+ // ctx.application.muted = true;
81
+ // }
82
+ // else {
83
+ // ctx.application.muted = false;
84
+ // }
85
+
86
+ if (ctx.application.muted) {
87
+ button.appendChild(muteIcon);
88
+ }
89
+ else {
90
+ button.appendChild(unmuteIcon);
91
+ }
92
+ button.onclick = () => {
93
+ if (ctx.application.muted) {
94
+ muteIcon.remove();
95
+ button.appendChild(unmuteIcon);
96
+ ctx.application.muted = false;
97
+ // sessionStorage.setItem("muted", "false");
98
+ } else {
99
+ unmuteIcon.remove();
100
+ button.appendChild(muteIcon);
101
+ ctx.application.muted = true;
102
+ // sessionStorage.setItem("muted", "true");
103
+ }
104
+ };
105
+ return button;
106
+ }
107
+ }
src/engine/engine_audio.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { AudioContext } from "three";
2
+
3
+
4
+ /** Ensure the audio context is resumed if it gets suspended or interrupted */
5
+ export function ensureAudioContextIsResumed() {
6
+ // this is a fix for https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
7
+ const ctx = AudioContext.getContext();
8
+ ctx.addEventListener("statechange", () => {
9
+ // on iOS the audiocontext can be interrupted: https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
10
+ const state = ctx.state as AudioContextState | "interrupted";
11
+ if (state === "suspended" || state === "interrupted") {
12
+ ctx.resume()
13
+ .then(() => { console.log("AudioContext resumed successfully"); })
14
+ .catch((e) => { console.log("Failed to resume AudioContext: " + e); });
15
+ }
16
+ });
17
+ }
18
+ setTimeout(ensureAudioContextIsResumed, 1000);
src/engine/xr/events.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { NeedleXRSession } from "./NeedleXRSession.js";
2
+
3
+ export declare type XRSessionEventArgs = { session: NeedleXRSession };
4
+
5
+ const onXRSessionStartListeners: ((evt: XRSessionEventArgs) => void)[] = [];
6
+
7
+ export function onXRSessionStart(fn: (evt: XRSessionEventArgs) => void) {
8
+ if (onXRSessionStartListeners.indexOf(fn) === -1) {
9
+ onXRSessionStartListeners.push(fn);
10
+ }
11
+ }
12
+ export function offXRSessionStart(fn: (evt: XRSessionEventArgs) => void) {
13
+ const index = onXRSessionStartListeners.indexOf(fn);
14
+ if (index !== -1) {
15
+ onXRSessionStartListeners.splice(index, 1);
16
+ }
17
+ }
18
+ export function invokeXRSessionStart(evt: XRSessionEventArgs) {
19
+ document.dispatchEvent(new CustomEvent("needle-xrsession-start", { detail: evt }));
20
+ for (let i = 0; i < onXRSessionStartListeners.length; i++) {
21
+ onXRSessionStartListeners[i](evt);
22
+ }
23
+ }
24
+
25
+ export function invokeXRSessionEnd(evt: XRSessionEventArgs) {
26
+ document.dispatchEvent(new CustomEvent("needle-xrsession-end", { detail: evt }));
27
+ }
src/engine/webcomponents/fonts.ts ADDED
@@ -0,0 +1,36 @@
1
+
2
+ declare type LoadFontOptions = {
3
+ element?: HTMLElement | DocumentFragment,
4
+ loadedCallback?: () => void,
5
+ }
6
+
7
+ export function loadFont(url: string, opts?: LoadFontOptions) {
8
+ const element = opts?.element || document.head;
9
+ // Workaround for font loading not being supported in ShadowDOM:
10
+ // Add font import to document header.
11
+ // Note that this is slower than it could be, ideally the font would be prefetched,
12
+ // but for that it needs to be in the actual document and not added by JS.
13
+ const elements = Array.from(element.querySelectorAll(`link[rel=stylesheet][href*='${url}']`));
14
+ if (elements.length <= 0) {
15
+ const fontLink = document.createElement("link");
16
+ fontLink.href = url;
17
+ fontLink.rel = "stylesheet";
18
+ element.appendChild(fontLink);
19
+ elements.push(fontLink);
20
+ }
21
+
22
+ if (opts?.loadedCallback) {
23
+ for (let i = 0; i < elements.length; i++) {
24
+ if (opts?.loadedCallback) {
25
+ const fontLink = elements[i] as HTMLLinkElement;
26
+ fontLink.addEventListener("load", opts.loadedCallback);
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+
33
+ /** Ensure the fonts that needle engine uses are loaded */
34
+ export function ensureFonts() {
35
+ loadFont("https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,[email protected],100..1000&display=swap");
36
+ }
src/engine/webcomponents/icons.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { Texture } from "three";
2
+
3
+
4
+
5
+ /** Returns a HTML element containing an icon. Using https://fonts.google.com/icons
6
+ * As a string you should pass in the name of the icon, e.g. "add" or "delete"
7
+ * @returns HTMLElement containing the icon
8
+ */
9
+ export function getIconElement(str: string): HTMLElement {
10
+ const span = document.createElement("span");
11
+ span.classList.add("material-symbols-outlined");
12
+ span.innerText = str;
13
+ return span;
14
+ }
15
+
16
+ /**@returns true if the element is an needle engine icon element */
17
+ export function isIconElement(element: Node): boolean {
18
+ const span = element as HTMLElement;
19
+ return span.classList?.contains("material-symbols-outlined") || false;
20
+ }
21
+
22
+ const textures = new Map<string, Texture | null>();
23
+
24
+ export async function getIconTexture(str: string): Promise<Texture | null> {
25
+ const fontname = "Material Symbols Outlined";
26
+ // check if font has loaded
27
+ if (!document.fonts.check(`1em '${fontname}'`)) {
28
+ console.log("Font not loaded yet");
29
+ await document.fonts.ready;
30
+ }
31
+ if (textures.has(str)) {
32
+ return textures.get(str) as Texture | null;
33
+ }
34
+ const canvas = document.createElement("canvas");
35
+ const size = 48;
36
+ canvas.width = size;
37
+ canvas.height = size;
38
+ const ctx = canvas.getContext("2d");
39
+ if (ctx) {
40
+ ctx.font = `${size}px '${fontname}'`;
41
+ ctx.fillStyle = "black";
42
+ ctx.fillText(str, 0, size);
43
+ const data = canvas.toDataURL();
44
+ const texture = new Texture();
45
+ texture.name = str + " icon";
46
+ texture.image = new Image();
47
+ texture.image.src = data;
48
+ texture.needsUpdate = true;
49
+ textures.set(str, texture);
50
+ return texture;
51
+ }
52
+ textures.set(str, null);
53
+ return null;
54
+ }
src/engine/webcomponents/needle menu/needle-menu-spatial.ts ADDED
@@ -0,0 +1,545 @@
1
+ import { Mesh, Object3D, Quaternion, TextureLoader, Vector3, Vector4 } from "three";
2
+ import ThreeMeshUI from "three-mesh-ui";
3
+
4
+ import { addNewComponent } from "../../engine_components.js";
5
+ import { hasProLicense } from "../../engine_license.js";
6
+ import { OneEuroFilterXYZ } from "../../engine_math.js";
7
+ import type { Context } from "../../engine_setup.js";
8
+ import { lookAtObject } from "../../engine_three_utils.js";
9
+ import { IComponent, IContext, IGameObject } from "../../engine_types.js";
10
+ import { TypeStore } from "../../engine_typestore.js";
11
+ import { getParam, isDesktop } from "../../engine_utils.js";
12
+ import { getIconTexture, isIconElement } from "../icons.js";
13
+
14
+ const debug = getParam("debugspatialmenu");
15
+
16
+ export class NeedleSpatialMenu {
17
+ private readonly _context: IContext;
18
+ private readonly needleMenu: HTMLElement;
19
+ private readonly htmlButtonsMap = new Map<HTMLElement, SpatialButton>();
20
+
21
+ private enabled: boolean = true;
22
+
23
+ constructor(context: IContext, menu: HTMLElement) {
24
+ this._context = context;
25
+ this._context.pre_render_callbacks.push(this.preRender);
26
+ this.needleMenu = menu;
27
+
28
+ const optionsContainer = this.needleMenu.shadowRoot?.querySelector(".options");
29
+ if (!optionsContainer) {
30
+ console.error("Could not find options container in needle menu");
31
+ }
32
+ else {
33
+ const watcher = new MutationObserver((mutations) => {
34
+
35
+ if (!this.enabled) return;
36
+ if (this._context.isInXR == false && !debug) return;
37
+
38
+ for (const mutation of mutations) {
39
+ if (mutation.type === "childList") {
40
+ mutation.addedNodes.forEach((node) => {
41
+ this.createButtonFromHTMLNode(node);
42
+ });
43
+ mutation.removedNodes.forEach((node) => {
44
+ const button = node as HTMLElement;
45
+ const spatialButton = this.htmlButtonsMap.get(button);
46
+ if (spatialButton) {
47
+ this.htmlButtonsMap.delete(button);
48
+ spatialButton.remove();
49
+ ThreeMeshUI.update();
50
+ }
51
+ });
52
+ }
53
+ }
54
+ });
55
+ watcher.observe(optionsContainer, { childList: true });
56
+ }
57
+ }
58
+
59
+ setEnabled(enabled: boolean) {
60
+ this.enabled = enabled;
61
+ if (!enabled)
62
+ this.menu?.removeFromParent();
63
+ }
64
+
65
+ onDestroy() {
66
+ const index = this._context.pre_render_callbacks.indexOf(this.preRender);
67
+ if (index > -1) {
68
+ this._context.pre_render_callbacks.splice(index, 1);
69
+ }
70
+ }
71
+
72
+ private uiisDirty = false;
73
+ markDirty() {
74
+ this.uiisDirty = true;
75
+ }
76
+
77
+ private _showNeedleLogo: undefined | boolean;
78
+ showNeedleLogo(show: boolean) {
79
+ this._showNeedleLogo = show;
80
+ }
81
+
82
+ private _wasInXR = false;
83
+ private preRender = () => {
84
+
85
+ if (!this.enabled) {
86
+ this.menu?.removeFromParent();
87
+ return;
88
+ }
89
+
90
+ if (debug && isDesktop()) {
91
+ this.updateMenu();
92
+ }
93
+
94
+ const xr = this._context.xr;
95
+ if (!xr?.running) {
96
+ if (this._wasInXR) {
97
+ this._wasInXR = false;
98
+ this.onExitXR();
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (!this._wasInXR) {
104
+ this._wasInXR = true;
105
+ this.onEnterXR();
106
+ }
107
+
108
+ this.updateMenu();
109
+ }
110
+
111
+ private onEnterXR() {
112
+ const nodes = this.needleMenu.shadowRoot?.querySelector(".options");
113
+ if (nodes) {
114
+ nodes.childNodes.forEach((node) => {
115
+ this.createButtonFromHTMLNode(node);
116
+ });
117
+ }
118
+ }
119
+ private onExitXR() {
120
+ this.menu?.removeFromParent();
121
+ }
122
+
123
+ private createButtonFromHTMLNode(node: Node) {
124
+ const menu = this.getMenu();
125
+ const existing = this.htmlButtonsMap.get(node as HTMLElement);
126
+ if (existing) {
127
+ existing.add();
128
+ return;
129
+ }
130
+ const button = node as HTMLButtonElement;
131
+ const spatialButton = this.createButton(menu, button);
132
+ this.htmlButtonsMap.set(button, spatialButton);
133
+ spatialButton.add();
134
+ }
135
+
136
+ private readonly _menuTarget: Object3D = new Object3D();
137
+ private readonly positionFilter = new OneEuroFilterXYZ(90, .5);
138
+
139
+ private updateMenu() {
140
+ const menu = this.getMenu();
141
+ this.handleNeedleWatermark();
142
+ this._context.scene.add(menu as any);
143
+ const camera = this._context.mainCamera as any as IGameObject;
144
+ const xr = this._context.xr;
145
+ const rigScale = xr?.rigScale || 1;
146
+ if (camera) {
147
+ const menuTargetPosition = camera.worldPosition;
148
+ const fwd = camera.worldForward.multiplyScalar(-1);
149
+
150
+ const showMenuThreshold = fwd.y > .6;
151
+ const hideMenuThreshold = fwd.y > .4;
152
+ const newVisibleState = menu.visible ? hideMenuThreshold : showMenuThreshold;
153
+ const becomesVisible = !menu.visible && newVisibleState;
154
+ menu.visible = newVisibleState || (isDesktop() && debug as boolean);
155
+
156
+ fwd.multiplyScalar(3 * rigScale);
157
+ menuTargetPosition.add(fwd);
158
+
159
+ const testBecomesVisible = false;// this._context.time.frame % 200 == 0;
160
+
161
+ if (becomesVisible || testBecomesVisible) {
162
+ menu.position.copy(this._menuTarget.position);
163
+ menu.position.y += 1;
164
+ this._menuTarget.position.copy(menu.position);
165
+ this.positionFilter.reset(menu.position);
166
+ menu.quaternion.copy(this._menuTarget.quaternion);
167
+ this.markDirty();
168
+ }
169
+ const distFromForwardView = this._menuTarget.position.distanceTo(menuTargetPosition);
170
+ if (becomesVisible || distFromForwardView > 1.5 * rigScale) {
171
+ this.ensureRenderOnTop(this.menu as any as Object3D);
172
+ this._menuTarget.position.copy(menuTargetPosition);
173
+ this._context.scene.add(this._menuTarget);
174
+ lookAtObject(this._menuTarget, this._context.mainCamera!, false, true);
175
+ this._menuTarget.removeFromParent();
176
+ }
177
+ this.positionFilter.filter(this._menuTarget.position, menu.position, this._context.time.time);
178
+ const step = 5;
179
+ this.menu?.quaternion.slerp(this._menuTarget.quaternion, this._context.time.deltaTime * step);
180
+ this.menu?.scale.setScalar(rigScale);
181
+ }
182
+
183
+ if (this.uiisDirty) {
184
+ this.uiisDirty = false;
185
+ ThreeMeshUI.update();
186
+ }
187
+ }
188
+
189
+ private ensureRenderOnTop(obj: Object3D, level: number = 0) {
190
+ if (obj instanceof Mesh) {
191
+ obj.material.depthTest = false;
192
+ obj.material.depthWrite = false;
193
+ }
194
+ obj.renderOrder = 1000 + level * 2;
195
+ for (const child of obj.children) {
196
+ this.ensureRenderOnTop(child, level + 1);
197
+ }
198
+ }
199
+
200
+ private familyName = "Needle Spatial Menu";
201
+ private menu?: ThreeMeshUI.Block;
202
+
203
+ private getMenu() {
204
+ if (this.menu) {
205
+ return this.menu;
206
+ }
207
+
208
+ this.ensureFont();
209
+
210
+ this.menu = new ThreeMeshUI.Block({
211
+ boxSizing: 'border-box',
212
+ fontFamily: this.familyName,
213
+ height: "auto",
214
+ fontSize: .1,
215
+ color: 0x000000,
216
+ lineHeight: 1,
217
+ backgroundColor: 0xffffff,
218
+ backgroundOpacity: .55,
219
+ borderRadius: .04,
220
+ whiteSpace: 'pre-wrap',
221
+ flexDirection: 'row',
222
+ alignItems: 'center',
223
+ padding: new Vector4(.0, .05, .0, .05),
224
+ borderColor: 0x000000,
225
+ borderOpacity: .05,
226
+ borderWidth: .005
227
+ });
228
+ // ensure the menu has a raycaster
229
+ const raycaster = TypeStore.get("ObjectRaycaster");
230
+ if (raycaster)
231
+ addNewComponent(this.menu as any, new raycaster())
232
+
233
+ return this.menu;
234
+ }
235
+ private _poweredByNeedleElement: ThreeMeshUI.Block | undefined;
236
+ private handleNeedleWatermark() {
237
+ if (!this._poweredByNeedleElement) {
238
+ this._poweredByNeedleElement = new ThreeMeshUI.Block({
239
+ width: "auto",
240
+ height: "auto",
241
+ fontSize: .05,
242
+ whiteSpace: 'pre-wrap',
243
+ flexDirection: 'row',
244
+ flexWrap: 'wrap',
245
+ justifyContent: 'center',
246
+ margin: 0.02,
247
+ borderRadius: .02,
248
+ padding: .02,
249
+ backgroundColor: 0xffffff,
250
+ backgroundOpacity: 1,
251
+ });
252
+ this._poweredByNeedleElement["needle:use_eventsystem"] = true;
253
+ const onClick = new OnClick(this._context, () => globalThis.open("https://needle.tools", "_self"));
254
+ addNewComponent(this._poweredByNeedleElement as any as Object3D, onClick as any as IComponent);
255
+
256
+ const firstLabel = new ThreeMeshUI.Text({
257
+ textContent: "Powered by",
258
+ width: "auto",
259
+ height: "auto",
260
+ });
261
+ const secondLabel = new ThreeMeshUI.Text({
262
+ textContent: "needle",
263
+ width: "auto",
264
+ height: "auto",
265
+ fontSize: .07,
266
+ margin: new Vector4(0, 0, 0, .02),
267
+ });
268
+ this._poweredByNeedleElement.add(firstLabel as any);
269
+ this._poweredByNeedleElement.add(secondLabel as any);
270
+ this.menu?.add(this._poweredByNeedleElement as any);
271
+ this.markDirty();
272
+ // const logoObject = needleLogoAsSVGObject();
273
+ // logoObject.position.y = 1;
274
+ // this._context.scene.add(logoObject);
275
+ const textureLoader = new TextureLoader();
276
+ textureLoader.load("./include/needle/poweredbyneedle.webp", (texture) => {
277
+ onClick.allowModifyUI = false;
278
+ firstLabel.removeFromParent();
279
+ secondLabel.removeFromParent();
280
+ const aspect = texture.image.width / texture.image.height;
281
+ this._poweredByNeedleElement?.set({
282
+ backgroundImage: texture,
283
+ backgroundOpacity: 1,
284
+ width: .1 * aspect,
285
+ height: .1
286
+ });
287
+ this.markDirty();
288
+ });
289
+
290
+ }
291
+ if (this.menu) {
292
+ const index = this.menu.children.indexOf(this._poweredByNeedleElement as any);
293
+ if (!this._showNeedleLogo && hasProLicense()) {
294
+ if (index >= 0) {
295
+ this._poweredByNeedleElement.removeFromParent();
296
+ this.markDirty();
297
+ }
298
+ }
299
+ else {
300
+ this._poweredByNeedleElement.visible = true;
301
+ this.menu.add(this._poweredByNeedleElement as any);
302
+ const newIndex = this.menu.children.indexOf(this._poweredByNeedleElement as any);
303
+ if (index !== newIndex) {
304
+ this.markDirty();
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ private ensureFont() {
311
+ let fontFamily = ThreeMeshUI.FontLibrary.getFontFamily(this.familyName);
312
+
313
+ if (!fontFamily) {
314
+ fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(this.familyName);
315
+ const normal = fontFamily.addVariant("normal", "normal", "./include/needle/arial-msdf.json", "./include/needle/arial.png") as any as ThreeMeshUI.FontVariant;
316
+ normal?.addEventListener('ready', () => {
317
+ this.markDirty();
318
+ });
319
+ }
320
+ }
321
+
322
+ private createButton(menu: ThreeMeshUI.Block, htmlButton: HTMLButtonElement): SpatialButton {
323
+ const buttonParent = new ThreeMeshUI.Block({
324
+ width: "auto",
325
+ height: "auto",
326
+ whiteSpace: 'pre-wrap',
327
+ flexDirection: 'row',
328
+ flexWrap: 'wrap',
329
+ justifyContent: 'center',
330
+ backgroundColor: 0xffffff,
331
+ backgroundOpacity: 0,
332
+ padding: 0.02,
333
+ margin: 0.01,
334
+ borderRadius: 0.02,
335
+ cursor: 'pointer',
336
+ fontSize: 0.05,
337
+ });
338
+ const text = new ThreeMeshUI.Text({
339
+ textContent: "",
340
+ width: "auto",
341
+ justifyContent: 'center',
342
+ alignItems: 'center',
343
+ backgroundOpacity: 0,
344
+ backgroundColor: 0xffffff,
345
+ fontFamily: this.familyName,
346
+ color: 0x000000,
347
+ borderRadius: 0.02,
348
+ padding: .01,
349
+ });
350
+ buttonParent.add(text as any);
351
+
352
+ buttonParent["needle:use_eventsystem"] = true;
353
+ const onClick = new OnClick(this._context, () => htmlButton.click());
354
+ addNewComponent(buttonParent as any as Object3D, onClick as any as IComponent);
355
+
356
+ const spatialButton = new SpatialButton(this, menu, htmlButton, buttonParent, text);
357
+ return spatialButton;
358
+ }
359
+
360
+ }
361
+
362
+ class SpatialButton {
363
+
364
+ readonly menu: NeedleSpatialMenu;
365
+ readonly root: ThreeMeshUI.Block;
366
+ readonly htmlbutton: HTMLButtonElement;
367
+ readonly spatialContainer: ThreeMeshUI.Block;
368
+ readonly spatialText: ThreeMeshUI.Text;
369
+
370
+ private spatialIcon?: ThreeMeshUI.InlineBlock;
371
+
372
+ constructor(menu: NeedleSpatialMenu, root: ThreeMeshUI.Block, htmlbutton: HTMLButtonElement, buttonContainer: ThreeMeshUI.Block, buttonText: ThreeMeshUI.Text) {
373
+ this.menu = menu;
374
+ this.root = root;
375
+ this.htmlbutton = htmlbutton;
376
+ this.spatialContainer = buttonContainer;
377
+ this.spatialText = buttonText;
378
+ const styleObserver = new MutationObserver((mutations) => {
379
+ for (const mutation of mutations) {
380
+ if (mutation.type === "attributes") {
381
+ if (mutation.attributeName === "style") {
382
+ this.updateVisible();
383
+ }
384
+ }
385
+ else if (mutation.type === "childList") {
386
+ this.updateText();
387
+ }
388
+ }
389
+ });
390
+ // watch attributes and content
391
+ styleObserver.observe(htmlbutton, { attributes: true, childList: true });
392
+ this.updateText();
393
+ }
394
+
395
+ add() {
396
+ if (this.spatialContainer.parent != this.root as any) {
397
+ this.root.add(this.spatialContainer as any);
398
+ this.menu.markDirty();
399
+ this.updateVisible();
400
+ this.updateText();
401
+ }
402
+ }
403
+
404
+ remove() {
405
+ if (this.spatialContainer.parent) {
406
+ this.spatialContainer.removeFromParent();
407
+ this.menu.markDirty();
408
+ }
409
+ }
410
+
411
+ private updateVisible() {
412
+ const wasVisible = this.spatialContainer.visible;
413
+ this.spatialContainer.visible = this.htmlbutton.style.display !== "none";
414
+ if (wasVisible !== this.spatialContainer.visible) {
415
+ this.menu.markDirty();
416
+ }
417
+ }
418
+
419
+ private _lastText = "";
420
+ private updateText() {
421
+ let newText = "";
422
+ let iconToCreate = "";
423
+ this.htmlbutton.childNodes.forEach((child) => {
424
+ if (child.nodeType === Node.TEXT_NODE) {
425
+ newText += child.textContent;
426
+ }
427
+ else if (child instanceof HTMLElement && isIconElement(child) && child.textContent) {
428
+ iconToCreate = child.textContent;
429
+ }
430
+ });
431
+ if (this._lastText !== newText) {
432
+ this._lastText = newText;
433
+ this.spatialText.name = newText;
434
+ this.spatialText.set({ textContent: newText });
435
+ this.menu.markDirty();
436
+ }
437
+ if (newText.length <= 0) {
438
+ if (this.spatialText.parent) {
439
+ this.spatialText.removeFromParent();
440
+ this.menu.markDirty();
441
+ }
442
+ }
443
+ else {
444
+ if (!this.spatialText.parent) {
445
+ this.spatialContainer.add(this.spatialText as any);
446
+ this.menu.markDirty();
447
+ }
448
+ }
449
+ if (iconToCreate) {
450
+ this.createIcon(iconToCreate);
451
+ }
452
+ }
453
+
454
+ private _lastTexture?: string;
455
+ private async createIcon(str: string) {
456
+ if (!this.spatialIcon) {
457
+ const texture = await getIconTexture(str);
458
+ if (texture && !this.spatialIcon) {
459
+ const size = 0.08;
460
+ const icon = new ThreeMeshUI.Block({
461
+ width: size,
462
+ height: size,
463
+ backgroundColor: 0xffffff,
464
+ backgroundImage: texture,
465
+ backgroundOpacity: 1,
466
+ margin: new Vector4(0, .005, 0, 0),
467
+ });
468
+ this.spatialIcon = icon;
469
+ this.spatialContainer.add(icon as any);
470
+ this.menu.markDirty();
471
+ }
472
+ }
473
+ if (str != this._lastTexture) {
474
+ this._lastTexture = str;
475
+ const texture = await getIconTexture(str);
476
+ if (texture) {
477
+ this.spatialIcon?.set({ backgroundImage: texture });
478
+ this.menu.markDirty();
479
+ }
480
+ }
481
+
482
+ // make sure the icon is at the first index
483
+ const index = this.spatialContainer.children.indexOf(this.spatialIcon as any);
484
+ if (index > 0) {
485
+ this.spatialContainer.children.splice(index, 1);
486
+ this.spatialContainer.children.unshift(this.spatialIcon as any);
487
+ this.menu.markDirty();
488
+ }
489
+ }
490
+ }
491
+
492
+ // TODO: perhaps we should have a basic IComponent implementation in the engine folder to be able to write this more easily. OR possibly reduce the IComponent interface to the minimum
493
+ class OnClick implements Pick<IComponent, "__internalAwake"> {
494
+
495
+ readonly isComponent = true;
496
+ readonly enabled = true;
497
+ get activeAndEnabled() { return true; }
498
+ __internalAwake() { }
499
+ __internalEnable() { }
500
+ __internalDisable() { }
501
+ __internalStart() { }
502
+ onEnable() { }
503
+ onDisable() { }
504
+
505
+ gameObject!: IGameObject;
506
+
507
+ allowModifyUI = true;
508
+
509
+ get element() {
510
+ return this.gameObject as any as ThreeMeshUI.MeshUIBaseElement;
511
+ }
512
+
513
+ readonly context: Context;
514
+ readonly onclick: () => void;
515
+
516
+ constructor(context: Context, onclick: () => void) {
517
+ this.context = context;
518
+ this.onclick = onclick;
519
+ }
520
+
521
+ onPointerEnter() {
522
+ this.context.input.setCursorPointer();
523
+ if (this.allowModifyUI) {
524
+ this.element.set({ backgroundOpacity: 1 });
525
+ ThreeMeshUI.update();
526
+ }
527
+ }
528
+ onPointerExit() {
529
+ this.context.input.setCursorNormal();
530
+ if (this.allowModifyUI) {
531
+ this.element.set({ backgroundOpacity: 0 });
532
+ ThreeMeshUI.update();
533
+ }
534
+ }
535
+ onPointerDown(e) {
536
+ e.use();
537
+ }
538
+ onPointerUp(e) {
539
+ e.use();
540
+ }
541
+ onPointerClick(e) {
542
+ e.use();
543
+ this.onclick();
544
+ }
545
+ }
src/engine/webcomponents/needle menu/needle-menu.ts ADDED
@@ -0,0 +1,620 @@
1
+ import { isDevEnvironment } from "../../debug/index.js";
2
+ import type { Context } from "../../engine_context.js";
3
+ import { hasProLicense, onLicenseCheckResultChanged } from "../../engine_license.js";
4
+ import { getParam } from "../../engine_utils.js";
5
+ import { ButtonsFactory } from "../buttons.js";
6
+ import { ensureFonts, loadFont } from "../fonts.js";
7
+ import { getIconElement } from "../icons.js";
8
+ import { NeedleLogoElement } from "../logo-element.js";
9
+ import { NeedleSpatialMenu } from "./needle-menu-spatial.js";
10
+
11
+ const elementName = "needle-menu";
12
+ const debug = getParam("debugmenu");
13
+
14
+ /** This is the model for the postMessage event that the needle engine will send to create menu items */
15
+ export declare type NeedleMenuPostMessageModel = {
16
+ type: "needle:menu",
17
+ button?: {
18
+ label?: string,
19
+ /** Google icon name */
20
+ icon?: string,
21
+ /** currently only URLs are supported */
22
+ onclick?: string,
23
+ target?: "_blank" | "_self" | "_parent" | "_top",
24
+ /** Low priority is icon is on the left, high priority is icon is on the right. Default is 0 */
25
+ priority?: number,
26
+ }
27
+ }
28
+
29
+ export class NeedleMenu {
30
+ private readonly _context: Context;
31
+ private readonly _menu: NeedleMenuElement;
32
+ private readonly _spatialMenu: NeedleSpatialMenu;
33
+
34
+ constructor(context: Context) {
35
+ this._menu = NeedleMenuElement.getOrCreate(context.domElement);
36
+ this._context = context;
37
+ this._spatialMenu = new NeedleSpatialMenu(context, this._menu);
38
+ window.addEventListener("message", this.onPostMessage);
39
+ }
40
+
41
+ /** @ignore internal method */
42
+ onDestroy() {
43
+ window.removeEventListener("message", this.onPostMessage);
44
+ this._menu.remove();
45
+ this._spatialMenu.onDestroy();
46
+ }
47
+
48
+ private onPostMessage = (e: MessageEvent) => {
49
+ // lets just allow the same origin for now
50
+ if (e.origin !== globalThis.location.origin) return;
51
+ if (typeof e.data === "object") {
52
+ const data = e.data as NeedleMenuPostMessageModel;
53
+ const type = data.type;
54
+ if (type === "needle:menu") {
55
+ const buttoninfo = data.button;
56
+ if (buttoninfo) {
57
+ if (!buttoninfo.label) return console.error("NeedleMenu: buttoninfo.label is required");
58
+ if (!buttoninfo.onclick) return console.error("NeedleMenu: buttoninfo.onclick is required");
59
+ const button = document.createElement("button");
60
+ button.textContent = buttoninfo.label;
61
+ if (buttoninfo.icon) {
62
+ const icon = getIconElement(buttoninfo.icon);
63
+ button.prepend(icon);
64
+ }
65
+ if (buttoninfo.priority) {
66
+ button.setAttribute("priority", buttoninfo.priority.toString());
67
+ }
68
+ button.onclick = () => {
69
+ if (buttoninfo.onclick) {
70
+ const isLink = buttoninfo.onclick.startsWith("http") || buttoninfo.onclick.startsWith("www.");
71
+ const target = buttoninfo.target || "_blank";
72
+ if (isLink) {
73
+ globalThis.open(buttoninfo.onclick, target);
74
+ }
75
+ else console.error("NeedleMenu: onclick is not a valid link", buttoninfo.onclick);
76
+ }
77
+ }
78
+ this._menu.appendChild(button);
79
+ }
80
+ else if (debug) console.error("NeedleMenu: unknown postMessage event", data);
81
+ }
82
+ else if (debug) console.warn("NeedleMenu: unknown postMessage type", type, data);
83
+ }
84
+ };
85
+
86
+ /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent
87
+ * @param position "top" or "bottom"
88
+ */
89
+ setPosition(position: "top" | "bottom") {
90
+ this._menu.setPosition(position);
91
+ }
92
+
93
+ /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a needle engine license */
94
+ showNeedleLogo(visible: boolean) {
95
+ this._menu.showNeedleLogo(visible);
96
+ this._spatialMenu?.showNeedleLogo(visible);
97
+ // setTimeout(()=>this.showNeedleLogo(!visible), 1000);
98
+ }
99
+ /** When enabled=true the menu will be visible in VR/AR sessions */
100
+ showSpatialMenu(enabled: boolean) {
101
+ this._spatialMenu.setEnabled(enabled);
102
+ }
103
+
104
+ /** Call to add or remove a button to the menu to mute or unmute the application
105
+ * Clicking the button will mute or unmute the application
106
+ */
107
+ showAudioPlaybackOption(visible: boolean): void {
108
+ if (!visible) {
109
+ this._muteButton?.remove();
110
+ return;
111
+ }
112
+ this._muteButton = ButtonsFactory.getOrCreate().createMuteButton(this._context);
113
+ this._muteButton.setAttribute("priority", "100");
114
+ this._menu.appendChild(this._muteButton);
115
+ }
116
+ private _muteButton?: HTMLButtonElement;
117
+
118
+
119
+ showFullscreenOption(visible: boolean): void {
120
+ if (!visible) {
121
+ this._fullscreenButton?.remove();
122
+ return;
123
+ }
124
+ this._fullscreenButton = ButtonsFactory.getOrCreate().createFullscreenButton(this._context);
125
+ this._fullscreenButton.setAttribute("priority", "150");
126
+ this._menu.appendChild(this._fullscreenButton);
127
+ }
128
+ private _fullscreenButton?: HTMLButtonElement;
129
+
130
+
131
+
132
+ appendChild(child: HTMLElement) {
133
+ this._menu.appendChild(child);
134
+ }
135
+
136
+ }
137
+
138
+ export class NeedleMenuElement extends HTMLElement {
139
+
140
+ static create() {
141
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement#is
142
+ return document.createElement(elementName, { is: elementName });
143
+ }
144
+
145
+ static getOrCreate(domElement: HTMLElement) {
146
+ let element = domElement.querySelector(elementName) as NeedleMenuElement | null;
147
+ if (!element && domElement.shadowRoot) {
148
+ element = domElement.shadowRoot.querySelector(elementName);
149
+ }
150
+ if (!element) {
151
+ element = NeedleMenuElement.create() as NeedleMenuElement;
152
+ element._domElement = domElement;
153
+ if (domElement.shadowRoot)
154
+ domElement.shadowRoot.appendChild(element);
155
+ else
156
+ domElement.appendChild(element);
157
+ }
158
+ return element as NeedleMenuElement;
159
+ }
160
+
161
+ private _domElement: HTMLElement | null = null;
162
+
163
+ constructor() {
164
+ super();
165
+
166
+ const template = document.createElement('template');
167
+ // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space
168
+ template.innerHTML = `<style>
169
+
170
+ #root {
171
+ position: absolute;
172
+ width: auto;
173
+ max-width: 95%;
174
+ left: 50%;
175
+ transform: translateX(-50%);
176
+ top: 20px;
177
+ padding: 0.3rem;
178
+ background: #ffffff5c;
179
+ display: flex;
180
+ flex-direction: row-reverse; /* if we overflow this should be right aligned so the logo is always visible */
181
+ outline: rgb(0 0 0 / 5%) 1px solid;
182
+ border: 1px solid rgba(255, 255, 255, .1);
183
+ border-radius: 1.1999999999999993rem;
184
+ overflow: clip;
185
+ box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05);
186
+ backdrop-filter: blur(16px);
187
+ }
188
+
189
+ /** using a div here because then we can change the class for placement **/
190
+ #root.bottom {
191
+ top: auto;
192
+ bottom: 30px;
193
+ }
194
+
195
+ .wrapper {
196
+ position: relative;
197
+ display: flex;
198
+ flex-direction: row;
199
+ justify-content: center;
200
+ align-items: stretch;
201
+ gap: 0px;
202
+ padding: 0 .3rem;
203
+ }
204
+
205
+ .wrapper > *, .options > * {
206
+ position: relative;
207
+ border: none;
208
+ border-radius: 0;
209
+ outline: 1px solid rgba(0,0,0,0);
210
+ display: flex;
211
+ justify-content: center;
212
+ align-items: center;
213
+
214
+ /** basic font settings for all entries **/
215
+ font-size: 1rem;
216
+ font-family: 'Roboto Flex', sans-serif;
217
+ font-optical-sizing: auto;
218
+ font-weight: 500;
219
+ font-weight: 200;
220
+ font-variation-settings: "wdth" 100;
221
+ color: rgb(40,40,40);
222
+ }
223
+
224
+ .options > * {
225
+ padding: .4rem .5rem;
226
+ }
227
+
228
+ :host .options > * {
229
+ background: transparent;
230
+ border: none;
231
+ white-space: nowrap;
232
+ transition: all 0.1s linear .02s;
233
+ border-radius: 0.8rem;
234
+ }
235
+ :host .options > *:hover {
236
+ cursor: pointer;
237
+ color: black;
238
+ background: rgba(245, 245, 245, .8);
239
+ outline: rgba(0,0,0,.05) 1px solid;
240
+ }
241
+
242
+ button {
243
+ gap: 0.3rem;
244
+ }
245
+
246
+ /** XR button animation **/
247
+ :host button.this-mode-is-requested {
248
+ background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
249
+ background-size: 200% auto;
250
+ background-position: 0 100%;
251
+ animation: AnimationName .7s ease infinite forwards;
252
+ }
253
+ :host button.other-mode-is-requested {
254
+ opacity: .5;
255
+ }
256
+
257
+ @keyframes AnimationName {
258
+ 0% { background-position: 0% 0 }
259
+ 100% { background-position: -200% 0 }
260
+ }
261
+
262
+
263
+
264
+
265
+ .logo {
266
+ cursor: pointer;
267
+ padding-left: 0.6rem;
268
+ }
269
+ :host .logo.any-options {
270
+ border-left: 1px solid rgba(40,40,40,.4);
271
+ margin-left: 0.3rem;
272
+ }
273
+
274
+ .logo > span {
275
+ white-space: nowrap;
276
+ }
277
+
278
+
279
+
280
+ /** COMPACT */
281
+ .compact .wrapper, .compact .options {
282
+ height: auto;
283
+ max-height: initial;
284
+ flex-direction: column-reverse;
285
+ }
286
+
287
+ .compact .options > * {
288
+ font-size: 1.2rem;
289
+ padding: .6rem .5rem;
290
+ }
291
+ .compact .top .options {
292
+ height: auto;
293
+ flex-direction: column-reverse;
294
+ }
295
+ .compact .bottom .wrapper {
296
+ height: auto;
297
+ flex-direction: column;
298
+ }
299
+ .compact .logo {
300
+ padding-left: 0;
301
+ margin-left: 0.3rem;
302
+ }
303
+ .compact.bottom .logo.any-options {
304
+ border: none;
305
+ border-bottom: 1px solid rgba(40,40,40,.4);
306
+ padding-bottom: .4rem;
307
+ margin-bottom: .5rem;
308
+ }
309
+ .compact.top .logo.any-options {
310
+ border: none;
311
+ border-top: 1px solid rgba(40,40,40,.4);
312
+ padding-top: .4rem;
313
+ margin-top: .5rem;
314
+ }
315
+ .compact .options > button {
316
+ width: 100%;
317
+ }
318
+
319
+
320
+
321
+ /* dark mode */
322
+ /*
323
+ @media (prefers-color-scheme: dark) {
324
+ :host {
325
+ background: rgba(0,0,0, .6);
326
+ }
327
+ :host button {
328
+ color: rgba(200,200,200);
329
+ }
330
+ :host button:hover {
331
+ background: rgba(100,100,100, .8);
332
+ }
333
+ }
334
+ */
335
+
336
+ </style>
337
+
338
+ <div id="root" class="bottom">
339
+ <div class="wrapper">
340
+ <div class="options"></div>
341
+ <div class="logo">
342
+ <span class="madewith">powered by</span>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ `;
347
+
348
+ // we dont need to expose the shadow root
349
+ const shadow = this.attachShadow({ mode: 'open' });
350
+
351
+ // we need to add the icons to both the shadow dom as well as the HEAD to work
352
+ // https://github.com/google/material-design-icons/issues/1165
353
+ ensureFonts();
354
+ // add to document head AND shadow dom to work
355
+ loadFont("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200", { loadedCallback: () => { this.handleSizeChange() } });
356
+ loadFont("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200", { element: shadow });
357
+
358
+ const content = template.content.cloneNode(true) as DocumentFragment;
359
+ shadow?.appendChild(content);
360
+ this.root = shadow.querySelector("#root") as HTMLDivElement;
361
+
362
+ this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement;
363
+ this.options = this.root?.querySelector(".options") as HTMLDivElement;
364
+ this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement;
365
+
366
+ this.root?.appendChild(this.wrapper);
367
+ this.wrapper.classList.add("wrapper");
368
+
369
+ const logo = NeedleLogoElement.create();
370
+ logo.style.minHeight = "1rem";
371
+ this.logoContainer.append(logo);
372
+ this.logoContainer.addEventListener("click", () => {
373
+ globalThis.open("https://needle.tools", "_blank");
374
+ });
375
+
376
+ // if the user has a license then we CAN hide the needle logo
377
+ onLicenseCheckResultChanged(res => {
378
+ if (res == true && hasProLicense()) {
379
+ this.logoContainer.style.display = this._userRequestedLogoVisible ? "" : "none";
380
+ }
381
+ });
382
+
383
+
384
+ // watch changes
385
+ const rootObserver = new MutationObserver(mutations => {
386
+ this.onChangeDetected(mutations);
387
+ });
388
+ rootObserver.observe(this.root, { childList: true, subtree: true, attributes: true });
389
+
390
+
391
+
392
+ if (debug) {
393
+ this.___insertDebugOptions();
394
+ }
395
+ }
396
+
397
+ private _sizeChangeInterval;
398
+
399
+ connectedCallback() {
400
+ window.addEventListener("resize", this.handleSizeChange);
401
+ this._domElement?.addEventListener("resize", this.handleSizeChange);
402
+ this.handleMenuVisible();
403
+ this._sizeChangeInterval = setInterval(() => this.handleSizeChange(undefined, true), 5000);
404
+ }
405
+ disconnectedCallback() {
406
+ window.removeEventListener("resize", this.handleSizeChange);
407
+ this._domElement?.removeEventListener("resize", this.handleSizeChange);
408
+ clearInterval(this._sizeChangeInterval);
409
+ }
410
+
411
+ private _userRequestedLogoVisible?: boolean = undefined;
412
+ showNeedleLogo(visible: boolean) {
413
+ this._userRequestedLogoVisible = visible;
414
+ if (!hasProLicense()) {
415
+ if (isDevEnvironment()) console.warn("Needle Menu: You need a PRO license to hide the Needle Engine logo.");
416
+ return;
417
+ }
418
+ this.logoContainer.style.display = visible ? "" : "none";
419
+ }
420
+
421
+ setPosition(position: "top" | "bottom") {
422
+ // ensure the position is of a known type:
423
+ if (position !== "top" && position !== "bottom") {
424
+ return console.error("NeedleMenu.setPosition: invalid position", position);
425
+ }
426
+ this.root.classList.remove("top", "bottom");
427
+ this.root.classList.add(position);
428
+ }
429
+
430
+ // private _root: ShadowRoot | null = null;
431
+ private readonly root: HTMLDivElement;
432
+ /** wraps the whole content */
433
+ private readonly wrapper: HTMLDivElement;
434
+ /** contains the buttons and dynamic elements */
435
+ private readonly options: HTMLDivElement;
436
+ /** contains the needle-logo html element */
437
+ private readonly logoContainer: HTMLDivElement;
438
+
439
+ append(...nodes: (string | Node)[]): void {
440
+ for (const node of nodes) {
441
+ if (typeof node === "string") {
442
+ const element = document.createTextNode(node);
443
+ this.options.appendChild(element);
444
+ } else {
445
+ this.options.appendChild(node);
446
+ }
447
+ }
448
+ }
449
+ appendChild<T extends Node>(node: T): T {
450
+ const res = this.options.appendChild(node);
451
+ return res;
452
+ }
453
+ prepend(...nodes: (string | Node)[]): void {
454
+ for (const node of nodes) {
455
+ if (typeof node === "string") {
456
+ const element = document.createTextNode(node);
457
+ this.options.prepend(element);
458
+ } else {
459
+ this.options.prepend(node);
460
+ }
461
+ }
462
+ }
463
+
464
+ private _isHandlingChange = false;
465
+
466
+ /** Called when any change in the web component is detected (including in children and child attributes) */
467
+ private onChangeDetected(_mut: MutationRecord[]) {
468
+ if (this._isHandlingChange) return;
469
+ this._isHandlingChange = true;
470
+ try {
471
+ // if (debug) console.log("NeedleMenu.onChangeDetected", _mut);
472
+ this.handleMenuVisible();
473
+ for (const mut of _mut) {
474
+ if (mut.target == this.options) {
475
+ this.onOptionsChildrenChanged(mut);
476
+ }
477
+ }
478
+ }
479
+ finally {
480
+ this._isHandlingChange = false;
481
+ }
482
+ }
483
+
484
+ private onOptionsChildrenChanged(_mut: MutationRecord) {
485
+ let anyVisibleOptions = false;
486
+ for (let i = 0; i < this.options.children.length; i++) {
487
+ const child = this.options.children[i] as HTMLElement;
488
+ if (child.style.display != "none") {
489
+ anyVisibleOptions = true;
490
+ break;
491
+ }
492
+ }
493
+ this.logoContainer.classList.toggle("any-options", anyVisibleOptions);
494
+ this.handleSizeChange();
495
+
496
+ if (_mut.type === "childList") {
497
+ let needsSorting = false;
498
+ const now = Date.now();
499
+ // sort children by priority only when necessary
500
+ for (let i = 0; i < _mut.addedNodes.length; i++) {
501
+ const child = _mut.addedNodes[i] as HTMLElement;
502
+ const lastTime = this._didSort.get(child);
503
+ if (typeof lastTime === "number" && now - lastTime < 100) continue;
504
+ this._didSort.set(child, now);
505
+ needsSorting = true;
506
+ }
507
+ if (needsSorting) {
508
+ const children = Array.from(this.options.children);
509
+ children.sort((a, b) => {
510
+ const p1 = parseInt(a.getAttribute("priority") || "0");
511
+ const p2 = parseInt(b.getAttribute("priority") || "0");
512
+ return p1 - p2;
513
+ });
514
+ for (const child of children) {
515
+ this.options.appendChild(child);
516
+ }
517
+ }
518
+ }
519
+ }
520
+
521
+ private _didSort: Map<HTMLElement, number> = new Map();
522
+
523
+
524
+ /** checks if the menu has any content and should be rendered at all
525
+ * if we dont have any content and logo then we hide the menu
526
+ */
527
+ private handleMenuVisible() {
528
+ if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent);
529
+ if (this.hasAnyContent) {
530
+ this.root.style.display = "";
531
+ } else {
532
+ this.root.style.display = "none";
533
+ }
534
+ }
535
+
536
+ /** @returns true if we have any content OR a logo */
537
+ get hasAnyContent() {
538
+ // is the logo visible?
539
+ if (this.logoContainer.style.display != "none") return true;
540
+ // do we have any visible buttons?
541
+ for (let i = 0; i < this.options.children.length; i++) {
542
+ const child = this.options.children[i] as HTMLElement;
543
+ if (child.style.display != "none") return true;
544
+ }
545
+ return false;
546
+ }
547
+
548
+
549
+ private _lastAvailableWidthChange = 0;
550
+ private _timeoutHandle: number = 0;
551
+
552
+ private handleSizeChange = (_evt?:Event, forceOrEvent?: boolean) => {
553
+ if (!this._domElement) return;
554
+
555
+ const width = this._domElement.clientWidth;
556
+ if (width < 500) {
557
+ this.root.classList.add("compact");
558
+ return;
559
+ }
560
+
561
+ const padding = 40;
562
+ const availableWidth = width - padding * 2;
563
+
564
+ // if the available width has not changed significantly then we can skip the rest
565
+ if (!forceOrEvent && Math.abs(availableWidth - this._lastAvailableWidthChange) < 1) return;
566
+ this._lastAvailableWidthChange = availableWidth;
567
+
568
+ clearTimeout(this._timeoutHandle!);
569
+
570
+ this._timeoutHandle = setTimeout(() => {
571
+ const currentWidth = this.options.clientWidth + this.logoContainer.clientWidth;
572
+ const spaceLeft = availableWidth - currentWidth;
573
+ if (spaceLeft <= 0) {
574
+ this.root.classList.add("compact")
575
+ }
576
+ else if (spaceLeft > 5) {
577
+ this.root.classList.remove("compact")
578
+ }
579
+ }, 200) as unknown as number;
580
+
581
+ }
582
+
583
+
584
+
585
+ private ___insertDebugOptions() {
586
+ window.addEventListener("keydown", (e) => {
587
+ if (e.key === "p") {
588
+ this.setPosition(this.root.classList.contains("top") ? "bottom" : "top");
589
+ }
590
+ });
591
+ const removeOptionsButton = document.createElement("button");
592
+ removeOptionsButton.textContent = "Hide Buttons";
593
+ removeOptionsButton.onclick = () => {
594
+ const optionsChildren = new Array(this.options.children.length);
595
+ for (let i = 0; i < this.options.children.length; i++) {
596
+ optionsChildren[i] = this.options.children[i];
597
+ }
598
+ for (const child of optionsChildren) {
599
+ this.options.removeChild(child);
600
+ }
601
+ setTimeout(() => {
602
+ for (const child of optionsChildren) {
603
+ this.options.appendChild(child);
604
+ }
605
+
606
+ }, 1000)
607
+ };
608
+ this.appendChild(removeOptionsButton);
609
+ const anotherButton = document.createElement("button");
610
+ anotherButton.textContent = "Toggle Logo";
611
+ anotherButton.addEventListener("click", () => {
612
+ this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none";
613
+ });
614
+ this.appendChild(anotherButton);
615
+ }
616
+ }
617
+
618
+
619
+ if (!customElements.get(elementName))
620
+ customElements.define(elementName, NeedleMenuElement);
src/include/needle/poweredbyneedle.webp ADDED
File without changes