@@ -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"
|
@@ -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/
|
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";
|
@@ -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;
|
@@ -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/
|
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";
|
@@ -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
|
27
|
-
if (
|
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 (
|
33
|
+
if (enableConsole) {
|
34
34
|
const engineElement = document.querySelector("needle-engine");
|
35
35
|
// setTimeout(() => {
|
36
36
|
// const el = getConsoleElement();
|
@@ -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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
{
|
42
|
-
if (
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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>) {
|
@@ -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
|
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(
|
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 =
|
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.
|
196
|
-
|
197
|
-
|
198
|
-
|
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();
|
@@ -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();
|
@@ -340,7 +340,7 @@
|
|
340
340
|
}
|
341
341
|
}
|
342
342
|
|
343
|
-
this.handleRuntimeLicense(
|
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 = "
|
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
|
|
@@ -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>
|
@@ -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
|
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
|
|
@@ -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
|
}
|
@@ -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
|
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 {
|
@@ -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
|
-
|
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
|
@@ -1,2 +1,1 @@
|
|
1
|
-
|
2
|
-
export * from "./xr/index.js"
|
1
|
+
export * from "./xr/api.js"
|
@@ -428,11 +428,15 @@
|
|
428
428
|
isShadow = true;
|
429
429
|
}
|
430
430
|
}
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
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)
|
@@ -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
|
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
|
}
|
@@ -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
|
+
}
|
@@ -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";
|
@@ -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);
|
@@ -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
|
9
|
-
import { type NeedleXREventArgs } from "../engine/xr/
|
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
|
|
@@ -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";
|
@@ -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);
|
@@ -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
|
}
|
@@ -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
|
-
|
88
|
-
|
89
|
-
|
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
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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 (
|
752
|
-
|
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
|
-
|
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
|
}
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
-
|
124
|
-
|
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 + "_" +
|
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
|
}
|
@@ -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
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
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
|
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,
|
155
|
+
this._scene.add(new GridHelper(5, 10, 0x111111, 0x111111));
|
146
156
|
|
147
157
|
const light = new DirectionalLight(0xffffff, 1);
|
148
|
-
light.position.set(
|
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(
|
163
|
+
light2.position.set(0, -1, 0);
|
154
164
|
light2.castShadow = false;
|
155
165
|
this._scene.add(light2);
|
156
166
|
|
157
|
-
const
|
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
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
roughness: .8,
|
164
|
-
})
|
175
|
+
const material = new MeshStandardMaterial({
|
176
|
+
color: 0x222222,
|
177
|
+
metalness: 1,
|
178
|
+
roughness: .8,
|
165
179
|
});
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
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.
|
179
|
-
const
|
180
|
-
|
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
|
}
|
@@ -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.
|
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.
|
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];
|
@@ -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.
|
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)
|
284
|
-
|
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)
|
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)
|
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
|
|
@@ -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
|
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
|
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(() =>
|
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.
|
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);
|
@@ -1,14 +1,14 @@
|
|
1
1
|
import { Object3D, Quaternion, Vector3 } from "three";
|
2
2
|
|
3
|
-
import {
|
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/
|
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 {
|
11
|
+
import { Renderer } from "../Renderer.js";
|
12
12
|
|
13
13
|
// https://github.com/immersive-web/marker-tracking/blob/main/explainer.md
|
14
14
|
|
@@ -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,
|
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 * (
|
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);
|
@@ -0,0 +1,3 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
export { type NeedleMenuPostMessageModel } from "./needle menu/needle-menu.js"
|
@@ -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";
|
@@ -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
|
+
}
|
@@ -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);
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|
@@ -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) {
|
< |