Needle Engine

Changes between version 3.32.25-alpha and 3.32.26-alpha
Files changed (35) hide show
  1. src/include/three/ARButton.js +0 -232
  2. plugins/next/next.js +0 -6
  3. src/include/three/VRButton.js +0 -195
  4. src/engine-components/postprocessing/Effects/ColorAdjustments.ts +56 -14
  5. src/engine-components/codegen/components.ts +3 -1
  6. src/engine/debug/debug_overlay.ts +2 -2
  7. src/engine/debug/debug_spatial_console.ts +9 -8
  8. src/engine/engine_addressables.ts +3 -3
  9. src/engine/engine_components.ts +18 -25
  10. src/engine/engine_context.ts +6 -2
  11. src/engine/engine_element_loading.ts +2 -2
  12. src/engine/engine_element.ts +10 -1
  13. src/engine/engine_input.ts +4 -4
  14. src/engine/engine_license.ts +77 -214
  15. src/engine/engine_physics.ts +11 -4
  16. src/engine/engine_serialization_builtin_serializer.ts +4 -3
  17. src/engine/engine_serialization_core.ts +31 -48
  18. src/engine/engine_serialization.ts +1 -4
  19. src/engine/assets/index.ts +17 -3
  20. src/engine-components/Interactable.ts +5 -1
  21. src/engine/xr/NeedleXRController.ts +2 -1
  22. src/engine/xr/NeedleXRSession.ts +6 -4
  23. src/engine-components/postprocessing/PostProcessingEffect.ts +2 -1
  24. src/engine-components/postprocessing/PostProcessingHandler.ts +8 -4
  25. src/engine/codegen/register_types.ts +6 -2
  26. src/engine-components/postprocessing/Effects/Tonemapping.ts +9 -10
  27. src/engine-components/export/usdz/USDZExporter.ts +4 -4
  28. src/engine-components/postprocessing/Volume.ts +2 -2
  29. src/engine-components/webxr/WebXR.ts +35 -22
  30. src/engine-components/webxr/WebXRButtons.ts +108 -154
  31. src/engine/webcomponents/index.ts +2 -0
  32. src/engine/webcomponents/license-banner.ts +48 -0
  33. src/engine/webcomponents/logo-element.ts +79 -0
  34. src/engine/webcomponents/needle-menu.ts +470 -0
  35. src/engine-components/NeedleMenu.ts +19 -0
src/include/three/ARButton.js DELETED
@@ -1,232 +0,0 @@
1
- class ARButton {
2
-
3
- static createButton( renderer, options = {}, beforeRequestSession ) {
4
-
5
- const button = document.createElement( 'button' );
6
- let ARButtonControlsDomOverlay = false;
7
-
8
- function showStartAR( /*device*/ ) {
9
-
10
- options.optionalFeatures = options.optionalFeatures || [];
11
-
12
- if ( options.domOverlay === undefined) {
13
-
14
- var overlay = document.createElement( 'div' );
15
- overlay.style.display = 'none';
16
- document.body.appendChild( overlay );
17
-
18
- var svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
19
- svg.setAttribute( 'width', 38 );
20
- svg.setAttribute( 'height', 38 );
21
- svg.style.position = 'absolute';
22
- svg.style.right = '20px';
23
- svg.style.top = '20px';
24
- svg.addEventListener( 'click', function () {
25
-
26
- currentSession.end();
27
-
28
- } );
29
- overlay.appendChild( svg );
30
-
31
- var path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
32
- path.setAttribute( 'd', 'M 12,12 L 28,28 M 28,12 12,28' );
33
- path.setAttribute( 'stroke', '#fff' );
34
- path.setAttribute( 'stroke-width', 2 );
35
- svg.appendChild( path );
36
-
37
- options.optionalFeatures.push( 'dom-overlay' );
38
- options.domOverlay = { root: overlay };
39
- ARButtonControlsDomOverlay = true;
40
-
41
- }
42
-
43
- //
44
-
45
- let currentSession = null;
46
- let originalDomOverlayParent = null;
47
-
48
- async function onSessionStarted( session ) {
49
-
50
- // Workaround: seems WebXR Viewer has a non-standard behaviour when it comes to DOM Overlay and Canvas;
51
- // HTMLElements that are inside the Canvas element are not visible in the DOM Overlay.
52
- const isWebXRViewer = /WebXRViewer\//i.test( navigator.userAgent );
53
- const overlayElement = options.domOverlay.root;
54
- if (isWebXRViewer)
55
- {
56
- if(options.domOverlay?.root) {
57
- originalDomOverlayParent = overlayElement.parentNode;
58
- if (originalDomOverlayParent)
59
- {
60
- console.log("Reparent DOM Overlay to body", overlayElement, overlayElement.style.display);
61
- // mozilla webxr does hide elements on session start
62
- // this is only necessary if we generated the overlay element
63
- overlayElement.style.display = "";
64
- overlayElement.style.visibility = "";
65
- document.body.appendChild(overlayElement);
66
- }
67
- }
68
- else {
69
- console.warn("WebXRViewer: No DOM Overlay found");
70
- }
71
- }
72
-
73
- session.addEventListener( 'end', onSessionEnded );
74
-
75
- // 'local' is head-relative, 'local-floor' is what the device thinks the floor is (e.g. Hololens knows pretty well)
76
- // renderer.xr.setReferenceSpaceType( 'local' );
77
-
78
- await renderer.xr.setSession( session );
79
-
80
- button.textContent = 'STOP AR';
81
-
82
- if (ARButtonControlsDomOverlay)
83
- options.domOverlay.root.style.display = '';
84
-
85
- currentSession = session;
86
-
87
- }
88
-
89
- function onSessionEnded( /*event*/ ) {
90
-
91
- currentSession.removeEventListener( 'end', onSessionEnded );
92
-
93
- button.textContent = 'START AR';
94
-
95
- const overlayElement = options.domOverlay.root;
96
- // if we reparented the DOM overlay, we're reverting it here
97
- if (originalDomOverlayParent)
98
- originalDomOverlayParent.appendChild(overlayElement);
99
-
100
- if (ARButtonControlsDomOverlay)
101
- overlayElement.style.display = 'none';
102
-
103
- currentSession = null;
104
-
105
- }
106
-
107
- //
108
-
109
- button.style.display = '';
110
-
111
- button.style.cursor = 'pointer';
112
- button.style.left = 'calc(50% - 50px)';
113
- button.style.width = '100px';
114
-
115
- button.textContent = 'START AR';
116
-
117
- button.onmouseenter = function () {
118
-
119
- button.style.opacity = '1.0';
120
-
121
- };
122
-
123
- button.onmouseleave = function () {
124
-
125
- button.style.opacity = '0.5';
126
-
127
- };
128
-
129
- button.onclick = function () {
130
-
131
- if ( currentSession === null ) {
132
- beforeRequestSession?.call(this, options);
133
- navigator.xr.requestSession( 'immersive-ar', options ).then( onSessionStarted );
134
-
135
- } else {
136
-
137
- currentSession.end();
138
-
139
- }
140
-
141
- };
142
-
143
- }
144
-
145
- function disableButton() {
146
-
147
- button.disabled = true;
148
-
149
- button.style.display = '';
150
-
151
- button.style.cursor = 'auto';
152
- button.style.left = 'calc(50% - 75px)';
153
- button.style.width = '150px';
154
-
155
- button.onmouseenter = null;
156
- button.onmouseleave = null;
157
-
158
- button.onclick = null;
159
-
160
- }
161
-
162
- function showARNotSupported() {
163
-
164
- disableButton();
165
-
166
- button.textContent = 'AR NOT SUPPORTED';
167
-
168
- }
169
-
170
- function stylizeElement( element ) {
171
-
172
- element.style.position = 'absolute';
173
- element.style.bottom = '20px';
174
- element.style.padding = '12px 6px';
175
- element.style.border = '1px solid #fff';
176
- element.style.borderRadius = '4px';
177
- element.style.background = 'rgba(0,0,0,0.1)';
178
- element.style.color = '#fff';
179
- element.style.font = 'normal 13px sans-serif';
180
- element.style.textAlign = 'center';
181
- element.style.opacity = '0.5';
182
- element.style.outline = 'none';
183
- element.style.zIndex = '999';
184
-
185
- }
186
-
187
- if ( 'xr' in navigator ) {
188
-
189
- button.id = 'ARButton';
190
- button.style.display = 'none';
191
-
192
- stylizeElement( button );
193
-
194
- navigator.xr.isSessionSupported( 'immersive-ar' ).then( function ( supported ) {
195
-
196
- supported ? showStartAR() : showARNotSupported();
197
-
198
- } ).catch( showARNotSupported );
199
-
200
- return button;
201
-
202
- } else {
203
-
204
- const message = document.createElement( 'a' );
205
-
206
- if ( window.isSecureContext === false ) {
207
-
208
- message.href = document.location.href.replace( /^http:/, 'https:' );
209
- message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message
210
-
211
- } else {
212
-
213
- message.href = 'https://immersiveweb.dev/';
214
- message.innerHTML = 'WEBXR NOT AVAILABLE';
215
-
216
- }
217
-
218
- message.style.left = 'calc(50% - 90px)';
219
- message.style.width = '180px';
220
- message.style.textDecoration = 'none';
221
-
222
- stylizeElement( message );
223
-
224
- return message;
225
-
226
- }
227
-
228
- }
229
-
230
- }
231
-
232
- export { ARButton };
plugins/next/next.js CHANGED
@@ -41,12 +41,6 @@
41
41
  /** @param {import ('next').NextConfig config } */
42
42
  function nextWebPack(config, { buildId, dev, isServer, defaultLoaders, webpack }) {
43
43
 
44
- // workaround: fix type finding in production builds
45
- if (config.optimization.minimize) {
46
- console.log("WARN: 😳 disabling minimization for custom scripts. If you don't use custom scripts in your webproject you can enable minimize in the webpack callback like so: needleNext({webpack: (config) => { config.optimization.minimize = true; return config; }})\n")
47
- config.optimization.minimize = false;
48
- }
49
-
50
44
  const meta = getMeta();
51
45
  let useRapier = true;
52
46
  if (userSettings.useRapier === false) useRapier = false;
src/include/three/VRButton.js DELETED
@@ -1,195 +0,0 @@
1
- class VRButton {
2
-
3
- static createButton( renderer, options ) {
4
-
5
- const button = document.createElement( 'button' );
6
-
7
- function showEnterVR( /*device*/ ) {
8
-
9
- let currentSession = null;
10
-
11
- async function onSessionStarted( session ) {
12
-
13
- session.addEventListener( 'end', onSessionEnded );
14
-
15
- // console.log("Session started, features: ",session)
16
-
17
- await renderer.xr.setSession( session );
18
- button.textContent = 'EXIT VR';
19
-
20
- currentSession = session;
21
-
22
- }
23
-
24
- function onSessionEnded( /*event*/ ) {
25
-
26
- currentSession.removeEventListener( 'end', onSessionEnded );
27
-
28
- button.textContent = 'ENTER VR';
29
-
30
- currentSession = null;
31
-
32
- }
33
-
34
- //
35
-
36
- button.style.display = '';
37
-
38
- button.style.cursor = 'pointer';
39
- button.style.left = 'calc(50% - 50px)';
40
- button.style.width = '100px';
41
-
42
- button.textContent = 'ENTER VR';
43
-
44
- button.onmouseenter = function () {
45
-
46
- button.style.opacity = '1.0';
47
-
48
- };
49
-
50
- button.onmouseleave = function () {
51
-
52
- button.style.opacity = '0.5';
53
-
54
- };
55
-
56
- button.onclick = function () {
57
-
58
- if ( currentSession === null ) {
59
-
60
- // WebXR's requestReferenceSpace only works if the corresponding feature
61
- // was requested at session creation time. For simplicity, just ask for
62
- // the interesting ones as optional features, but be aware that the
63
- // requestReferenceSpace call will fail if it turns out to be unavailable.
64
- // ('local' is always available for immersive sessions and doesn't need to
65
- // be requested separately.)
66
-
67
- const sessionInit = { optionalFeatures: options.optionalFeatures };
68
- navigator.xr.requestSession( 'immersive-vr', sessionInit ).then( onSessionStarted );
69
-
70
- } else {
71
-
72
- currentSession.end();
73
-
74
- }
75
-
76
- };
77
-
78
- }
79
-
80
- function disableButton() {
81
-
82
- button.disabled = true;
83
-
84
- button.style.display = '';
85
-
86
- button.style.cursor = 'auto';
87
- button.style.left = 'calc(50% - 75px)';
88
- button.style.width = '150px';
89
-
90
- button.onmouseenter = null;
91
- button.onmouseleave = null;
92
-
93
- button.onclick = null;
94
-
95
- }
96
-
97
- function showWebXRNotFound() {
98
-
99
- disableButton();
100
-
101
- button.textContent = 'VR NOT SUPPORTED';
102
-
103
- }
104
-
105
- function stylizeElement( element ) {
106
-
107
- element.style.position = 'absolute';
108
- element.style.bottom = '20px';
109
- element.style.padding = '12px 6px';
110
- element.style.border = '1px solid #fff';
111
- element.style.borderRadius = '4px';
112
- element.style.background = 'rgba(0,0,0,0.1)';
113
- element.style.color = '#fff';
114
- element.style.font = 'normal 13px sans-serif';
115
- element.style.textAlign = 'center';
116
- element.style.opacity = '0.5';
117
- element.style.outline = 'none';
118
- element.style.zIndex = '999';
119
-
120
- }
121
-
122
- if ( 'xr' in navigator ) {
123
-
124
- button.id = 'VRButton';
125
- button.style.display = 'none';
126
-
127
- stylizeElement( button );
128
-
129
- navigator.xr.isSessionSupported( 'immersive-vr' ).then( function ( supported ) {
130
-
131
- supported ? showEnterVR() : showWebXRNotFound();
132
-
133
- if(VRButton.xrSessionIsGranted){
134
- console.log("XR session is granted - will enter immersive web now")
135
- button.click();
136
- }
137
-
138
- } );
139
-
140
- return button;
141
-
142
- } else {
143
-
144
- const message = document.createElement( 'a' );
145
-
146
- if ( window.isSecureContext === false ) {
147
-
148
- message.href = document.location.href.replace( /^http:/, 'https:' );
149
- message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message
150
-
151
- } else {
152
-
153
- message.href = 'https://immersiveweb.dev/';
154
- message.innerHTML = 'WEBXR NOT AVAILABLE';
155
-
156
- }
157
-
158
- message.style.left = 'calc(50% - 90px)';
159
- message.style.width = '180px';
160
- message.style.textDecoration = 'none';
161
-
162
- stylizeElement( message );
163
-
164
- return message;
165
-
166
- }
167
-
168
- }
169
-
170
-
171
- static xrSessionIsGranted = false;
172
-
173
- static registerSessionGrantedListener() {
174
-
175
- if ( 'xr' in navigator ) {
176
-
177
- // WebXRViewer (based on Firefox) has a bug where addEventListener
178
- // throws a silent exception and aborts execution entirely.
179
- if ( /WebXRViewer\//i.test( navigator.userAgent ) ) return;
180
-
181
- navigator.xr.addEventListener( 'sessiongranted', () => {
182
-
183
- VRButton.xrSessionIsGranted = true;
184
-
185
- } );
186
-
187
- }
188
-
189
- }
190
-
191
- }
192
-
193
- VRButton.registerSessionGrantedListener();
194
-
195
- export { VRButton };
src/engine-components/postprocessing/Effects/ColorAdjustments.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { BrightnessContrastEffect, HueSaturationEffect } from "postprocessing";
2
- import { LinearToneMapping, NoToneMapping } from "three";
1
+ import { BrightnessContrastEffect, HueSaturationEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing";
2
+ import { ACESFilmicToneMapping, AgXToneMapping, LinearToneMapping, NoToneMapping } from "three";
3
3
 
4
4
  import { serializable } from "../../../engine/engine_serialization.js";
5
+ import { GameObject } from "../../Component.js";
5
6
  import { type EffectProviderResult, PostProcessingEffect } from "../PostProcessingEffect.js";
7
+ import { Volume } from "../Volume.js";
6
8
  import { VolumeParameter } from "../VolumeParameter.js";
7
9
  import { registerCustomEffectType } from "../VolumeProfile.js";
10
+ import { ToneMapping } from "./Tonemapping.js";
8
11
 
9
12
 
10
13
  export class ColorAdjustments extends PostProcessingEffect {
@@ -27,7 +30,7 @@
27
30
 
28
31
  init() {
29
32
  this.postExposure!.valueProcessor = v => {
30
- v = Math.pow(2, v);
33
+ v = Math.pow(2.0, v);
31
34
  return v;
32
35
  }
33
36
  this.postExposure.defaultValue = 0;
@@ -47,36 +50,75 @@
47
50
 
48
51
  this.saturation.valueProcessor = (v: number) => {
49
52
  if (v < 0) return (v / 100);
50
- return (v / (100 * Math.PI));
53
+ const sat = (v / (100 * Math.PI));
54
+ return sat;
51
55
  }
52
56
  this.saturation.defaultValue = 0;
53
57
  }
54
58
 
55
59
  unapply() {
56
- this.context.renderer.toneMappingExposure = 1;
60
+ // reset to the current value in the toneMappingEffect if that exists, else Linear
61
+ const currentMode: any = this.toneMappingEffect?.mode.value;
62
+ const newMode = this.toneMappingEffect?.getThreeToneMapping(currentMode)
63
+ this.context.renderer.toneMapping = newMode ?? LinearToneMapping;
57
64
  }
58
65
 
66
+ private threeToneMappingToEffectMode(mode: number | undefined) {
67
+ switch (mode) {
68
+ case LinearToneMapping: return ToneMappingMode.ACES_FILMIC; // TODO, no Linear mode in postprocessing, see https://github.com/pmndrs/postprocessing/issues/605
69
+ case ACESFilmicToneMapping: return ToneMappingMode.ACES_FILMIC;
70
+ case AgXToneMapping: return ToneMappingMode.AGX;
71
+ default: return ToneMappingMode.ACES_FILMIC; // TODO, no Linear mode in postprocessing, see https://github.com/pmndrs/postprocessing/issues/605
72
+ }
73
+ }
59
74
 
75
+ private toneMappingEffect: ToneMapping | null = null;
60
76
  onCreateEffect(): EffectProviderResult {
61
- if (this.context.renderer.toneMapping === NoToneMapping && this.postExposure.overrideState)
62
- this.context.renderer.toneMapping = LinearToneMapping;
63
- const brightnesscontrast = new BrightnessContrastEffect();
64
- this.postExposure!.onValueChanged = v => {
65
- if (this.context.renderer.toneMapping === NoToneMapping)
66
- this.context.renderer.toneMapping = LinearToneMapping;
77
+ const effects: EffectProviderResult = [];
78
+
79
+ if (this.context.renderer.toneMapping !== NoToneMapping && this.postExposure.overrideState)
80
+ this.context.renderer.toneMapping = NoToneMapping;
81
+
82
+
83
+ // workaround: find the ToneMapping effect in the scene so we can apply the mode
84
+ this.toneMappingEffect = GameObject.findObjectOfType(Volume)?.sharedProfile?.components.find(c => c.typeName === "ToneMapping") as ToneMapping | null;
85
+ const currentMode: any = this.toneMappingEffect?.mode.value;
86
+ const expectedThreeMode = this.toneMappingEffect?.getThreeToneMapping(currentMode);
87
+
88
+ // We need this effect if someone uses ACES or AgX tonemapping;
89
+ // problem is that we CAN'T use this effect for the "Linear" case, the package expects that in this case you remove the effect
90
+ const tonemapping = new ToneMappingEffect({
91
+ mode: this.threeToneMappingToEffectMode(expectedThreeMode),
92
+ // middleGrey: 0.5,
93
+ // averageLuminance: 1,
94
+ });
95
+
96
+ const apply = (v) => {
67
97
  if (this.postExposure.overrideState)
68
98
  this.context.renderer.toneMappingExposure = v;
99
+
100
+ // this is a workaround so that we can apply tonemapping options – no access to the ToneMappingEffect instance from the Tonemapping effect right now...
101
+ const currentMode = this.toneMappingEffect?.mode.value;
102
+ tonemapping.mode = this.threeToneMappingToEffectMode(this.toneMappingEffect?.getThreeToneMapping(currentMode));
69
103
  }
70
- this.contrast!.onValueChanged = v => {
71
- brightnesscontrast.contrast = v;
104
+
105
+ this.postExposure!.onValueChanged = v => {
106
+ apply(v);
72
107
  }
73
108
 
109
+ const brightnesscontrast = new BrightnessContrastEffect();
110
+ this.contrast!.onValueChanged = v => brightnesscontrast.contrast = v;
74
111
 
75
112
  const hueSaturationEffect = new HueSaturationEffect();
113
+
114
+ effects.push(brightnesscontrast);
115
+ effects.push(hueSaturationEffect);
116
+ effects.push(tonemapping);
117
+
76
118
  this.hueShift!.onValueChanged = v => hueSaturationEffect.hue = v;
77
119
  this.saturation!.onValueChanged = v => hueSaturationEffect.saturation = v;
78
120
 
79
- return [hueSaturationEffect, brightnesscontrast];
121
+ return effects;
80
122
  }
81
123
 
82
124
  // apply() {
src/engine-components/codegen/components.ts CHANGED
@@ -87,6 +87,7 @@
87
87
  export { Image } from "../ui/Image.js";
88
88
  export { InheritVelocityModule } from "../ParticleSystemModules.js";
89
89
  export { InputField } from "../ui/InputField.js";
90
+ export { Interactable } from "../Interactable.js";
90
91
  export { Light } from "../Light.js";
91
92
  export { LimitVelocityOverLifetimeModule } from "../ParticleSystemModules.js";
92
93
  export { LODGroup } from "../LODGroup.js";
@@ -100,7 +101,7 @@
100
101
  export { MeshRenderer } from "../Renderer.js";
101
102
  export { MinMaxCurve } from "../ParticleSystemModules.js";
102
103
  export { MinMaxGradient } from "../ParticleSystemModules.js";
103
- export { NeedleWebXRHtmlElement } from "../webxr/WebXRButtons.js";
104
+ export { NeedleMenu } from "../NeedleMenu.js";
104
105
  export { NestedGltf } from "../NestedGltf.js";
105
106
  export { Networking } from "../Networking.js";
106
107
  export { NoiseModule } from "../ParticleSystemModules.js";
@@ -199,6 +200,7 @@
199
200
  export { WebARCameraBackground } from "../webxr/WebARCameraBackground.js";
200
201
  export { WebARSessionRoot } from "../webxr/WebARSessionRoot.js";
201
202
  export { WebXR } from "../webxr/WebXR.js";
203
+ export { WebXRButtonFactory } from "../webxr/WebXRButtons.js";
202
204
  export { WebXRImageTracking } from "../webxr/WebXRImageTracking.js";
203
205
  export { WebXRImageTrackingModel } from "../webxr/WebXRImageTracking.js";
204
206
  export { WebXRPlaneTracking } from "../webxr/WebXRPlaneTracking.js";
src/engine/debug/debug_overlay.ts CHANGED
@@ -144,10 +144,10 @@
144
144
 
145
145
  const logsContainerStyles = `
146
146
 
147
- @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
147
+ @import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap');
148
148
 
149
149
  div[data-needle_engine_debug_overlay] {
150
- font-family: 'Roboto', sans-serif;
150
+ font-family: 'Roboto Flex', sans-serif;
151
151
  font-weight: 400;
152
152
  }
153
153
 
src/engine/debug/debug_spatial_console.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Layers, Material, Matrix3, Matrix4, Mesh, Object3D, PerspectiveCamera, Quaternion, Texture, Vector2, Vector3, Vector4 } from "three";
2
- import ThreeMeshUI from "three-mesh-ui";
2
+ import ThreeMeshUI, { Text } from "three-mesh-ui";
3
3
  import type { Options } from "three-mesh-ui/build/types/core/elements/MeshUIBaseElement";
4
4
 
5
5
  import { ContextRegistry } from "../engine_context_registry.js";
@@ -57,7 +57,7 @@
57
57
  private readonly targetObject = new Object3D();
58
58
  /** this is a point in forward view of the user */
59
59
  private readonly userForwardViewPoint = new Vector3();
60
- private readonly oneEuroFilter = new OneEuroFilterXYZ(90, .2);
60
+ private readonly oneEuroFilter = new OneEuroFilterXYZ(90, .8);
61
61
  private _lastElementRemoveTime = 0;
62
62
 
63
63
  private onBeforeRender = () => {
@@ -76,7 +76,7 @@
76
76
  const dist = 3.5;
77
77
  const forward = cam.worldForward;
78
78
  forward.y = 0;
79
- forward.normalize().multiplyScalar((dist * 100) / cam.fov);
79
+ forward.normalize().multiplyScalar(dist);
80
80
  this.userForwardViewPoint.copy(cam.worldPosition).sub(forward);
81
81
 
82
82
  const distFromForwardView = this.targetObject.position.distanceTo(this.userForwardViewPoint);
@@ -88,7 +88,7 @@
88
88
 
89
89
  this.oneEuroFilter.filter(this.targetObject.position, root.position, this.context.time.time);
90
90
  const step = this.context.time.deltaTime;
91
- root.quaternion.slerp(this.targetObject.quaternion, step);
91
+ root.quaternion.slerp(this.targetObject.quaternion, step * 5);
92
92
 
93
93
  this.targetObject.removeFromParent();
94
94
  this.context.scene.add(this.getRoot() as any);
@@ -174,7 +174,7 @@
174
174
  };
175
175
  private readonly _textBuffer: ThreeMeshUI.Text[] = [];
176
176
  private readonly _activeTexts: ThreeMeshUI.Text[] = [];
177
- private getText() {
177
+ private getText(): Text {
178
178
  const root = this.getRoot();
179
179
  if (this._textBuffer.length > 0) {
180
180
  const text = this._textBuffer.pop() as any as ThreeMeshUI.Text;
@@ -182,9 +182,9 @@
182
182
  setTimeout(() => this.disableDepthTestRecursive(text as any), 100);
183
183
  return text;
184
184
  }
185
- if (root.children.length > 20) {
186
- const active = this._activeTexts[0];
187
- return active;
185
+ if (root.children.length > 20 && this._activeTexts.length > 0) {
186
+ const active = this._activeTexts.shift();
187
+ return active!;
188
188
  }
189
189
  const newText = new ThreeMeshUI.Text(this.textOptions);
190
190
  setTimeout(() => this.disableDepthTestRecursive(newText as any), 500);
@@ -195,6 +195,7 @@
195
195
  obj.traverseVisible((t: Object3D) => {
196
196
  t.renderOrder = 1000;
197
197
  t.layers.set(2);
198
+ t.position.z = .05;
198
199
  const mat = (t as Mesh).material as Material;
199
200
  if (mat) {
200
201
  mat.depthWrite = false;
src/engine/engine_addressables.ts CHANGED
@@ -365,7 +365,7 @@
365
365
  class AddressableSerializer extends TypeSerializer {
366
366
 
367
367
  constructor() {
368
- super([AssetReference]);
368
+ super([AssetReference], "AssetReferenceSerializer");
369
369
  }
370
370
 
371
371
  onSerialize(data: any, _context: SerializationContext) {
@@ -498,7 +498,7 @@
498
498
 
499
499
  export class ImageReferenceSerializer extends TypeSerializer {
500
500
  constructor() {
501
- super([ImageReference]);
501
+ super([ImageReference], "ImageReferenceSerializer");
502
502
  }
503
503
 
504
504
  onSerialize(_data: string, _context: SerializationContext) {
@@ -561,7 +561,7 @@
561
561
 
562
562
  export class FileReferenceSerializer extends TypeSerializer {
563
563
  constructor() {
564
- super([FileReference]);
564
+ super([FileReference], "FileReferenceSerializer");
565
565
  }
566
566
 
567
567
  onSerialize(_data: string, _context: SerializationContext) {
src/engine/engine_components.ts CHANGED
@@ -9,13 +9,12 @@
9
9
  import type { Constructor, ConstructorConcrete, IComponent, IGameObject } from "./engine_types.js";
10
10
  import { getParam } from "./engine_utils.js";
11
11
 
12
+
12
13
  const debug = getParam("debuggetcomponent");
14
+ const debugEnabled = () => {
15
+ return debug || globalThis["NEEDLE_DEBUG_GETCOMPONENT"] === true;
16
+ }
13
17
 
14
-
15
-
16
-
17
-
18
-
19
18
  function tryGetObject(obj) {
20
19
  if (obj === null || obj === undefined) return obj;
21
20
  if (obj.isObject3D) return obj;
@@ -140,32 +139,26 @@
140
139
  console.warn(`Accessing components by name is not supported.\nPlease use the component type instead. This may keep working in local development but it will fail when bundling your application.\n\nYou can import other modules your main module to get access to types\nor if you use npmdefs you can make types available globally using globalThis:\nhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis`, componentType);
141
140
  }
142
141
  }
143
- if (debug)
144
- console.log("FIND", componentType);
145
- if (componentType === undefined || componentType === null) return null;
142
+
143
+ if (debugEnabled())
144
+ console.log("[onGetComponent] FIND", componentType);
145
+
146
+ if (componentType === undefined || componentType === null)
147
+ return null;
148
+
149
+ // find component
146
150
  for (let i = 0; i < obj.userData.components.length; i++) {
147
151
  const component = obj.userData.components[i];
148
- if (componentType === null || component.constructor.name === componentType["name"] || component.constructor.name === componentType) {
149
- if (debug)
150
- console.log("MATCH BY NAME\n", component, "\n\ncomponentType:", componentType, "\n\ncomponentType[name]:", component.constructor.name, "==", componentType["name"], "\n\nconstructor.name:", component.constructor.name, "==", componentType);
151
- if (arr) arr.push(component);
152
- else return component;
153
- }
154
- }
155
- // find in base classes
156
- for (let i = 0; i < obj.userData.components.length; i++) {
157
- const component = obj.userData.components[i];
158
- let parent = Object.getPrototypeOf(component.constructor);
159
- do {
160
- if (parent === componentType) {
161
- if (debug)
162
- console.log("MATCH BY PROTOYPE", parent);
152
+ let prototype = Object.getPrototypeOf(component);
153
+ while (prototype) {
154
+ if (prototype === componentType.prototype) {
155
+ if (debugEnabled())
156
+ console.log("[onGetComponent] MATCH BY PROTOYPE", prototype);
163
157
  if (arr) arr.push(component);
164
158
  else return component;
165
159
  }
166
- parent = Object.getPrototypeOf(parent);
160
+ prototype = Object.getPrototypeOf(prototype);
167
161
  }
168
- while (parent);
169
162
  }
170
163
  if (!arr) return null;
171
164
  return arr;
src/engine/engine_context.ts CHANGED
@@ -31,6 +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
35
 
35
36
 
36
37
  const debug = utils.getParam("debugcontext");
@@ -128,6 +129,7 @@
128
129
  private static _defaultWebglRendererParameters: WebGLRendererParameters = {
129
130
  antialias: true,
130
131
  alpha: false,
132
+ powerPreference: "high-performance",
131
133
  };
132
134
  static get DefaultWebGLRendererParameters(): WebGLRendererParameters {
133
135
  return Context._defaultWebglRendererParameters;
@@ -257,8 +259,8 @@
257
259
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame
258
260
  */
259
261
  get xrFrame() { return this._xrFrame }
260
- /** @returns the current WebXR camera (shorthand for `context.renderer.xr.getCamera()`) */
261
- get xrCamera(): WebXRArrayCamera | undefined { return this.renderer?.xr?.getCamera(); }
262
+ /** @returns the current WebXR camera while the WebXRManager is active (shorthand for `context.renderer.xr.getCamera()`) */
263
+ get xrCamera(): WebXRArrayCamera | undefined { return this.renderer.xr.isPresenting ? this.renderer?.xr?.getCamera() : undefined }
262
264
  private _xrFrame: XRFrame | null = null;
263
265
  get arOverlayElement(): HTMLElement {
264
266
  const el = this.domElement as any;
@@ -346,6 +348,7 @@
346
348
  addressables: Addressables;
347
349
  lightmaps: ILightDataRegistry;
348
350
  players: PlayerViewManager;
351
+ readonly menu: NeedleMenu;
349
352
 
350
353
  get isCreated() { return this._isCreated; }
351
354
 
@@ -381,6 +384,7 @@
381
384
  this.addressables = new Addressables(this);
382
385
  this.lightmaps = new LightDataRegistry(this);
383
386
  this.players = new PlayerViewManager(this);
387
+ this.menu = new NeedleMenu(this);
384
388
 
385
389
 
386
390
  const resizeCallback = () => this._sizeChanged = true;
src/engine/engine_element_loading.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { logoSVG } from "./assets/index.js"
1
+ import { needleLogoOnlySVG } from "./assets/index.js"
2
2
  import { showBalloonWarning } from "./debug/index.js";
3
3
  import { hasCommercialLicense, hasProLicense, runtimeLicenseCheckPromise } from "./engine_license.js";
4
4
  import { Mathf } from "./engine_math.js";
@@ -244,7 +244,7 @@
244
244
  setTimeout(() => {
245
245
  logo.style.transform = "translateY(0px)";
246
246
  }, 1);
247
- logo.src = logoSVG;
247
+ logo.src = needleLogoOnlySVG;
248
248
  let isUsingCustomLogo = false;
249
249
  if (hasLicense && this._element) {
250
250
  const customLogo = this._element.getAttribute("loading-logo-src");
src/engine/engine_element.ts CHANGED
@@ -95,10 +95,19 @@
95
95
  // TODO: do we want to rename this event?
96
96
  this.addEventListener("ready", this.onReady);
97
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
107
  this.attachShadow({ mode: 'open' });
99
108
  const template = document.createElement('template');
100
109
  template.innerHTML = `<style>
101
- @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300&display=swap');
110
+ @import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap');
102
111
 
103
112
  :host {
104
113
  position: absolute;
src/engine/engine_input.ts CHANGED
@@ -897,7 +897,7 @@
897
897
  }
898
898
  if (debug) console.log(evt.pointerType, "DOWN", index);
899
899
  if (!this.isInRect(evt)) return;
900
- if (this.isMouseEventEmmitedFromTouch(evt)) return;
900
+ if (this.isMouseEventFromTouch(evt)) return;
901
901
 
902
902
  this.setPointerState(index, this._pointerPressed, true);
903
903
  this.setPointerState(index, this._pointerDown, true);
@@ -925,7 +925,7 @@
925
925
  const isDown = this.getPointerPressed(index);
926
926
  if (isDown === false && !this.isInRect(evt)) return;
927
927
  if (evt.pointerType === PointerType.Touch && !isDown) return;
928
- if (this.isMouseEventEmmitedFromTouch(evt)) return;
928
+ if (this.isMouseEventFromTouch(evt)) return;
929
929
  if (debug) console.log(evt.pointerType, "MOVE", index, "hasSpace=" + evt.space != null);
930
930
 
931
931
  this.updatePointerPosition(evt);
@@ -939,7 +939,7 @@
939
939
  if (debug) console.log(evt.pointerType, "UP", index, "was not down");
940
940
  return;
941
941
  }
942
- if (this.isMouseEventEmmitedFromTouch(evt)) return;
942
+ if (this.isMouseEventFromTouch(evt)) return;
943
943
  if (debug) console.log(evt.pointerType, "UP", index);
944
944
 
945
945
  this.setPointerState(index, this._pointerPressed, false);
@@ -981,7 +981,7 @@
981
981
  this.onDispatchEvent(evt);
982
982
  }
983
983
 
984
- private isMouseEventEmmitedFromTouch(evt: NEPointerEvent) {
984
+ private isMouseEventFromTouch(evt: NEPointerEvent) {
985
985
  if (evt.pointerType === PointerType.Mouse) {
986
986
  const lastPointer = this._pointerUpTimestamp.map((v, i) => ({ timestamp: v, index: i })).sort((a, b) => a.timestamp - b.timestamp).at(-1);
987
987
  if (lastPointer && this._pointerTypes[lastPointer.index] === PointerType.Touch) {
src/engine/engine_license.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { logoSVG } from "./assets/index.js";
1
+ import { showBalloonError, showBalloonWarning } from "./debug/index.js";
2
2
  import { GENERATOR, VERSION } from "./engine_constants.js";
3
3
  import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
4
4
  import type { IContext } from "./engine_types.js";
5
5
  import { getParam, isMobileDevice } from "./engine_utils.js";
6
+ import { LicenseBanner } from "./webcomponents/license-banner.js";
6
7
 
7
8
  const debug = getParam("debuglicense");
8
9
 
@@ -32,6 +33,22 @@
32
33
  return hasProLicense() || hasIndieLicense();
33
34
  }
34
35
 
36
+ const _licenseCheckResultChangedCallbacks: ((result: boolean) => void)[] = [];
37
+ export function onLicenseCheckResultChanged(cb: (result: boolean) => void) {
38
+ if (hasProLicense() || hasIndieLicense())
39
+ return cb(true);
40
+ _licenseCheckResultChangedCallbacks.push(cb);
41
+ }
42
+ function invokeLicenseCheckResultChanged(result: boolean) {
43
+ for (const cb of _licenseCheckResultChangedCallbacks) {
44
+ try {
45
+ cb(result);
46
+ }
47
+ catch {
48
+ // ignore
49
+ }
50
+ }
51
+ }
35
52
 
36
53
  ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => {
37
54
  showLicenseInfo(evt.context);
@@ -58,6 +75,7 @@
58
75
  applicationIsForbidden = false;
59
76
  if (debug) console.log("License check succeeded");
60
77
  NEEDLE_ENGINE_LICENSE_TYPE = "pro";
78
+ invokeLicenseCheckResultChanged(true);
61
79
  }
62
80
  else if (res?.status === 403) {
63
81
  applicationIsForbidden = true;
@@ -134,9 +152,6 @@
134
152
  }
135
153
 
136
154
 
137
- const licenseElementIdentifier = "needle-license-element";
138
- const licenseDuration = 10000;
139
- const licenseDelay = 1200;
140
155
 
141
156
  async function onNonCommercialVersionDetected(ctx: IContext) {
142
157
  // if the engine loads faster than the license check, we need to capture the ready event here
@@ -148,78 +163,70 @@
148
163
  logNonCommercialUse();
149
164
 
150
165
  // check if the engine is already ready (meaning has finished loading)
151
- if (isReady) {
152
- insertNonCommercialUseHint(ctx);
153
- }
154
- else {
155
- ctx.domElement.addEventListener("ready", () => {
156
- insertNonCommercialUseHint(ctx);
157
- });
158
- }
166
+ // if (isReady) {
167
+ // insertNonCommercialUseHint(ctx);
168
+ // }
169
+ // else {
170
+ // ctx.domElement.addEventListener("ready", () => {
171
+ // insertNonCommercialUseHint(ctx);
172
+ // });
173
+ // }
159
174
  }
160
175
 
161
- function insertNonCommercialUseHint(ctx: IContext) {
162
- const licenseElement = createLicenseElement();
163
- const style = createLicenseStyle();
176
+ // const licenseElementIdentifier = "needle-license-element";
177
+ // const licenseDuration = 10000;
178
+ // const licenseDelay = 1200;
179
+ // function insertNonCommercialUseHint(ctx: IContext) {
164
180
 
165
- const imgElement = document.createElement("img");
166
- imgElement.src = logoSVG;
167
- imgElement.classList.add("logo");
168
- const imageElementCssText = `width: 55px; height: 55px; margin-right: 2px; vertical-align: middle; margin-bottom: 2px;`;
169
- imgElement.style.cssText = imageElementCssText;
170
- licenseElement.appendChild(imgElement);
181
+ // return;
182
+ // const banner = LicenseBanner.create();
183
+ // ctx.domElement.shadowRoot?.appendChild(banner);
184
+ // let bannerStyle = `
185
+ // position: absolute;
186
+ // bottom: 20px;
187
+ // right: 20px;
188
+ // opacity: 0;
189
+ // transform: translateY(10px);
190
+ // transition: all .5s ease-in-out 1s;
191
+ // pointer: cursor;
192
+ // `;
193
+ // banner.style.cssText = bannerStyle;
194
+ // let expectedBannerStyle = banner.style.cssText;
195
+ // setTimeout(() => {
196
+ // bannerStyle = bannerStyle.replace("opacity: 0", "opacity: 1");
197
+ // bannerStyle = bannerStyle.replace("transform: translateY(10px)", "transform: translateY(0)");
198
+ // banner.style.cssText = bannerStyle;
199
+ // expectedBannerStyle = banner.style.cssText;
200
+ // }, 100);
171
201
 
172
- const setAndUpdateStyle = () => {
173
- if (!licenseElement) return;
174
- const parent = ctx.domElement.shadowRoot || ctx.domElement;
175
- if (licenseElement.parentNode !== parent) {
176
- parent.appendChild(licenseElement);
177
- if (style) parent.appendChild(style);
178
- }
179
- if (imgElement.parentElement !== licenseElement) {
180
- licenseElement.appendChild(imgElement);
181
- }
182
- if (imgElement.src !== logoSVG || imageElementCssText !== imgElement.style.cssText) {
183
- imgElement.setAttribute("src", logoSVG);
184
- imgElement.style.cssText = imageElementCssText
185
- }
186
- };
202
+ // // ensure the banner is always visible
203
+ // const interval = setInterval(() => {
204
+ // const parent = ctx.domElement.shadowRoot || ctx.domElement;
205
+ // if (banner.parentNode !== parent) {
206
+ // parent.appendChild(banner);
207
+ // }
208
+ // if (expectedBannerStyle != banner.style.cssText) {
209
+ // banner.style.cssText = bannerStyle;
210
+ // expectedBannerStyle = banner.style.cssText;
211
+ // showBalloonError("This website violates the Needle Engine License! Please contact [email protected]");
212
+ // }
213
+ // }, 1000);
187
214
 
188
- // call once and then ensure
189
- setAndUpdateStyle();
190
- const interval = setInterval(() => {
191
- setAndUpdateStyle();
192
- }, 1000);
215
+ // if (hasIndieLicense()) {
216
+ // const removeDelay = licenseDuration + licenseDelay;
217
+ // setTimeout(() => {
218
+ // clearInterval(interval);
219
+ // banner?.remove();
220
+ // // show the logo every x minutes
221
+ // const intervalInMinutes = 5;
222
+ // setTimeout(() => {
223
+ // if (ctx.domElement.parentNode)
224
+ // insertNonCommercialUseHint(ctx);
225
+ // }, 1000 * 60 * intervalInMinutes)
226
+ // }, removeDelay);
227
+ // }
193
228
 
194
- const textElement = document.createElement("div");
195
- textElement.classList.add("text");
196
- // if (!isMobileDevice())
197
- // textElement.innerHTML = "Made with Needle";
198
- licenseElement.appendChild(textElement);
199
-
200
- licenseElement.title = "Needle Engine";
201
- if (!hasCommercialLicense())
202
- licenseElement.title += " No active license found - Non Commercial Use";
203
- licenseElement.addEventListener("click", () => {
204
- globalThis.open("https://needle.tools", "_blank");
205
- });
206
-
207
- if (hasIndieLicense()) {
208
- const removeDelay = licenseDuration + licenseDelay;
209
- setTimeout(() => {
210
- clearInterval(interval);
211
- licenseElement?.remove();
212
- style?.remove();
213
- // show the logo every x minutes
214
- const intervalInMinutes = 5;
215
- setTimeout(() => {
216
- if (ctx.domElement.parentNode)
217
- insertNonCommercialUseHint(ctx);
218
- }, 1000 * 60 * intervalInMinutes)
219
- }, removeDelay);
220
- }
221
-
222
- }
229
+ // }
223
230
  let lastLogTime = 0;
224
231
  async function logNonCommercialUse(_logo?: string) {
225
232
  const now = Date.now();
@@ -249,151 +256,7 @@
249
256
  console.log("%c " + licenseText, style);
250
257
  }
251
258
 
252
- function createLicenseElement() {
253
- const licenseElement = document.createElement("div");
254
- licenseElement.setAttribute(licenseElementIdentifier, "");
255
- licenseElement.style.position = "absolute";
256
- licenseElement.style.bottom = "12px";
257
- licenseElement.style.right = "15px";
258
259
 
259
- // licenseElement.style.textShadow = "0 0 2px rgba(200,200,200, 1)";
260
- const maxPossibleZIndex = 2147483647;
261
- licenseElement.style.zIndex = `${maxPossibleZIndex}`;
262
- return licenseElement;
263
- }
264
-
265
- // TODO: would be better to put this into a web element and use shadow dom
266
- function createLicenseStyle() {
267
- const licenseStyle = document.createElement("style");
268
- const selector = `div[${licenseElementIdentifier}]`;
269
- licenseStyle.innerHTML = `
270
- ${selector} {
271
- font-family: 'Roboto', sans-serif !important;
272
- font-weight: 300;
273
- transition: all 0.1s ease-in-out !important;
274
- pointer-events: all;
275
- overflow: hidden;
276
- }
277
-
278
- ${selector}:hover {
279
- cursor: pointer;
280
- transition: all 0.3s ease-in-out !important;
281
- }
282
-
283
- ${selector}, ${selector} > * {
284
- display: inline-block !important;
285
- visibility: visible !important;
286
- border: none !important;
287
- text-decoration: none !important;
288
- vertical-align: middle !important;
289
- }
290
-
291
- @keyframes license-animation-text {
292
- 1% {
293
- opacity: 0;
294
- }
295
- 2.5% {
296
- opacity: 1;
297
- }
298
- 100% {
299
- opacity: 1;
300
- }
301
- }
302
- ${selector} .text {
303
- position: relative;
304
- display: inline-block;
305
- opacity: 0;
306
- animation: license-animation-text;
307
- animation-duration: ${(licenseDuration / 1000)}s;
308
- animation-delay: ${licenseDelay / 1000}s;
309
- animation-fill-mode: forwards;
310
- line-height: 1em;
311
- margin-left: -3px;
312
- color: rgba(20,20,20,1);
313
- font-size: 14px;
314
- font-weight: 800;
315
- background-color: rgba(220, 220, 220, .8);
316
- // padding: .51em .6em .43em .6em;
317
- border-radius: .7em;
318
- }
319
-
320
- ${selector} .text .non-commercial {
321
- font-size: 0.8em;
322
- font-weight: 600;
323
- text-transform: uppercase;
324
- }
325
-
326
- @keyframes logo-animation {
327
- 0% {
328
- transform: translate(0px, 10px);
329
- pointer-events: none;
330
- }
331
- 3% {
332
- transform: translate(0, 0px);
333
- pointer-events: all;
334
- opacity: 1;
335
- transform: scale(1.1)
336
- }
337
- 12% {
338
- transform: scale(1)
339
- }
340
- 100% {
341
- opacity: 1;
342
- pointer-events: all;
343
- transform: scale(1)
344
- }
345
- }
346
-
347
- ${selector} .logo {
348
- opacity: 0;
349
- pointer-events: none;
350
- animation: logo-animation;
351
- animation-duration: ${(licenseDuration / 1000)}s;
352
- animation-delay: ${licenseDelay / 1000}s;
353
- animation-easing: ease-in-out;
354
- animation-fill-mode: forwards;
355
- }
356
-
357
- ${selector} .logo {
358
- position: relative !important;
359
- display: inline-block !important;
360
- border-radius: 50% !important;
361
- background-color: transparent !important;
362
- padding: 5px !important;
363
- transition: all 0.2s ease-in-out !important;
364
- }
365
-
366
- ${selector}:hover .logo {
367
- transition: all 0.2s ease-in-out !important;
368
- transform: scale(1.1) !important;
369
- cursor: pointer !important;
370
- }
371
-
372
- @media (prefers-reduced-motion: reduce) {
373
- ${selector} .text {
374
- animation: none !important;
375
- transition: none !important;
376
- /* animation end state */
377
- opacity: 1;
378
- }
379
-
380
- ${selector} .logo {
381
- animation: none !important;
382
- transition: none !important;
383
- /* animation end state */
384
- opacity: 1;
385
- pointer-events: all;
386
- transform: scale(1)
387
- }
388
- ${selector} .logo:hover {
389
- transition: none !important;
390
- }
391
- }
392
- `
393
- return licenseStyle;
394
- }
395
-
396
-
397
260
  async function sendUsageMessageToAnalyticsBackend() {
398
261
  try {
399
262
  const analyticsUrl = "https://needle-engine-analytics-v2-r26roub2hq-lz.a.run.app";
src/engine/engine_physics.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AxesHelper, Box3, Camera, type Intersection, Layers, Line,Mesh, Object3D, Ray, Raycaster, Sphere, Vector2, Vector3 } from 'three'
1
+ import { ArrayCamera, AxesHelper, Box3, Camera, type Intersection, Layers, Line,Mesh, Object3D, PerspectiveCamera,Ray, Raycaster, Sphere, Vector2, Vector3 } from 'three'
2
2
 
3
3
  import { Gizmos } from './engine_gizmos.js';
4
4
  import { Context } from './engine_setup.js';
@@ -226,7 +226,13 @@
226
226
  if (this.defaultRaycastOptions.results) this.defaultRaycastOptions.results.length = 0;
227
227
  return this.defaultRaycastOptions.results ?? [];
228
228
  }
229
- rc.setFromCamera(mp, cam);
229
+ const xrCam = this.context.xrCamera;
230
+ if (this.context.isInXR && xrCam instanceof ArrayCamera && xrCam.cameras.length > 0) {
231
+ rc.setFromCamera(mp, xrCam.cameras[0] as PerspectiveCamera);
232
+ }
233
+ else {
234
+ rc.setFromCamera(mp, cam);
235
+ }
230
236
  }
231
237
  let targets = options.targets;
232
238
  if (!targets) {
@@ -256,8 +262,9 @@
256
262
  rc.layers.disable(2);
257
263
  }
258
264
 
259
- // console.log(rc)
260
- // console.log(targets);
265
+ if (debugPhysics) {
266
+ Gizmos.DrawRay(rc.ray.origin, rc.ray.direction, 0xff0000, 2);
267
+ }
261
268
 
262
269
  // shoot
263
270
  results.length = 0;
src/engine/engine_serialization_builtin_serializer.ts CHANGED
@@ -32,7 +32,7 @@
32
32
 
33
33
  class ColorSerializer extends TypeSerializer {
34
34
  constructor() {
35
- super([Color, RGBAColor])
35
+ super([Color, RGBAColor], "ColorSerializer")
36
36
  }
37
37
  onDeserialize(data: any): THREE.Color | RGBAColor | void {
38
38
  if (data === undefined || data === null) return;
@@ -60,7 +60,7 @@
60
60
  }
61
61
  class ObjectSerializer extends TypeSerializer {
62
62
  constructor() {
63
- super(Object3D);
63
+ super(Object3D, "ObjectSerializer");
64
64
  }
65
65
 
66
66
  onSerialize(data: any, context: SerializationContext) {
@@ -144,8 +144,9 @@
144
144
 
145
145
 
146
146
  class ComponentSerializer extends TypeSerializer {
147
+
147
148
  constructor() {
148
- super([Component, Behaviour]);
149
+ super([Component, Behaviour], "ComponentSerializer");
149
150
  }
150
151
 
151
152
  onSerialize(data: any, _context: SerializationContext) {
src/engine/engine_serialization_core.ts CHANGED
@@ -19,21 +19,23 @@
19
19
  // internal helper class that we can ask for registered type serializers
20
20
  // register your own type by deriving from ITypeSerializer and calling helper.register
21
21
  class SerializationHelper {
22
- register(type: string, ser: ITypeSerializer) {
23
- if (this.typeMap[type] !== undefined) {
24
- if (this.typeMap[type] === ser) return;
25
- console.warn("Type " + type + " is already registered", ser, this.typeMap[type]);
22
+ register(type: Constructor<any>, ser: ITypeSerializer) {
23
+ if (this.typeMap.has(type)) {
24
+ const existing = this.typeMap.get(type);
25
+ if (existing === ser) return;
26
+ console.warn("Type " + type + " is already registered", ser, existing);
26
27
  }
27
28
  if (debug)
28
- console.log("Register type serializer for " + type, ser);
29
- this.typeMap[type] = ser;
29
+ console.log("Register type serializer", ser.name, ser, type);
30
+ this.typeMap.set(type, ser);
30
31
  }
31
32
 
32
- typeMap: { [type: string]: ITypeSerializer } = {};
33
+ /** type > serializer map */
34
+ private readonly typeMap = new Map<Constructor<any>, ITypeSerializer>();
33
35
 
34
- getSerializer(type: string): ITypeSerializer | undefined {
36
+ private getSerializer(type: Constructor<any>): ITypeSerializer | undefined {
35
37
  if (!type) return undefined;
36
- return this.typeMap[type];
38
+ return this.typeMap.get(type)
37
39
  }
38
40
 
39
41
  getSerializerForConstructor(type: any, level: number = 0): ITypeSerializer | undefined {
@@ -43,51 +45,26 @@
43
45
  console.log("invalid type");
44
46
  return undefined;
45
47
  }
46
- const name = type.name ?? type.constructor?.name;
47
- if (!name) {
48
- if (debug)
49
- console.log("invalid name", name);
50
- return undefined;
51
- }
52
- const res = this.getSerializer(name);
48
+ const name = type.name;
49
+ const res = this.getSerializer(type);
53
50
  if (res !== undefined) {
54
51
  if (debug)
55
- console.log("FOUND " + name, type.name, type.constructor.name, res, this.typeMap);
52
+ console.log("FOUND SERIALIZER", (res as TypeSerializer)?.name, type.name, type.constructor.name, "for type: " + name, res, type, this.typeMap);
56
53
  return res;
57
54
  }
58
55
  const parent = Object.getPrototypeOf(type);
59
- const hasPrototypeOrConstructor = parent.prototype || parent.constructor;
60
- // console.log(name, type, parent);
61
- if (!hasPrototypeOrConstructor) {
62
- if (debug)
63
- console.warn("No prototype for " + name, type, type.name, type.prototype, type.constructor.name);
64
- // console.log(type.constructor);
65
- // console.dir(type);
66
- // console.log(Object.getPrototypeOf(type))
67
- // console.dir(parent, Object.getPrototypeOf(type));
68
- // if(level <= 0){
69
- // const t = TypeStore.get(type.name);
70
- // console.log(type['__proto__'].name);
71
- // if(t) return this.getSerializerForConstructor(t, level + 1);
72
- // }
73
- return undefined;
74
- }
75
- const prot = parent.prototype ?? parent.constructor;
76
- if (prot !== type) {
77
- const resultFromChildren = this.getSerializerForConstructor(prot, ++level);
56
+ if (parent && parent !== type) {
57
+ const resultFromChildren = this.getSerializerForConstructor(parent, ++level);
78
58
  if (resultFromChildren) {
59
+ const prot = parent.constructor || parent.prototype;
79
60
  if (debug)
80
- console.log("FOUND " + prot.constructor.name, prot.name, prot, resultFromChildren);
61
+ console.log("FOUND SERIALIZER(in constructor) " + prot.constructor.name, prot.name, prot, resultFromChildren);
81
62
  // register sub type
82
- const typeName = prot.name ?? prot.constructor.name;
83
- if (typeName === "Function") {
84
- console.error("Registering Function is not allowed, something went wrong", type, prot, resultFromChildren);
85
- }
86
- else
87
- this.register(typeName, resultFromChildren);
63
+ this.register(prot, resultFromChildren);
88
64
  }
89
65
  return resultFromChildren;
90
66
  }
67
+ if (debug) console.warn("No serializer found for " + name, type, type.name, type.constructor.name);
91
68
  return undefined;
92
69
  }
93
70
  }
@@ -96,6 +73,7 @@
96
73
 
97
74
 
98
75
  export interface ITypeSerializer {
76
+ readonly name?: string;
99
77
  onSerialize(data: any, context: SerializationContext): any;
100
78
  onDeserialize(data: any, context: SerializationContext): any;
101
79
  }
@@ -113,6 +91,8 @@
113
91
  */
114
92
  export abstract class TypeSerializer implements ITypeSerializer {
115
93
 
94
+ readonly name?: string;
95
+
116
96
  // register<T>(c: Constructor<T> | Constructor<T>[])
117
97
  // {
118
98
  // if (Array.isArray(c)) {
@@ -125,13 +105,15 @@
125
105
  // }
126
106
  // }
127
107
 
128
- constructor(type: Constructor<any> | Constructor<any>[]) {
108
+ constructor(type: Constructor<any> | Constructor<any>[], name?: string) {
109
+ this.name = name;
129
110
  if (Array.isArray(type)) {
130
- for (const key of type)
131
- helper.register(key.name, this);
111
+ for (const key of type) {
112
+ helper.register(key, this);
113
+ }
132
114
  }
133
115
  else
134
- helper.register(type.name, this);
116
+ helper.register(type, this);
135
117
  }
136
118
  abstract onSerialize(data: any, context: SerializationContext): any | void;
137
119
  abstract onDeserialize(data: any, context: SerializationContext): any | void;
@@ -570,8 +552,9 @@
570
552
 
571
553
  // console.log(type.prototype.get("$serializedTypes"));
572
554
 
555
+
573
556
  let instance: any = undefined;
574
- if (data && (data.isMaterial || data.isTexture || data.isObject3D)) {
557
+ if (data && (data.isMaterial || data.isTexture || data.isObject3D || data instanceof AnimationClip)) {
575
558
  // if the data is already a threejs object we dont want to create a new instance
576
559
  // e.g. if we have a serialized class with a serializable(Material)
577
560
  instance = data;
src/engine/engine_serialization.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { deserializeObject,serializeObject } from "./engine_serialization_core.js";
2
-
3
- export { deserializeObject,serializeObject };
4
-
5
1
  export * from "./engine_serialization_builtin_serializer.js";
2
+ export { deserializeObject, SerializationContext,serializeObject } from "./engine_serialization_core.js";
6
3
  export { serializable, serializeable } from "./engine_serialization_decorator.js"
src/engine/assets/index.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  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
+ const logoSvgBlob = new Blob([logoSvgString], { type: "image/svg+xml;charset=utf-8" });
3
+ const logoSvgUrl = URL.createObjectURL(logoSvgBlob);
4
+ /** Logo Only */
5
+ export const needleLogoOnlySVG: string = logoSvgUrl;
2
6
 
3
- const svgBlob = new Blob([logoSvgString], { type: "image/svg+xml;charset=utf-8" });
4
- const svgUrl = URL.createObjectURL(svgBlob);
5
- export const logoSVG: string = svgUrl;
7
+
8
+ const madeWithNeedleSvgString = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'> <svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 1014 282" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m665.95 132.73v44.88l-10.56-8.4c-0.8-0.64-1.2-1.44-1.2-2.4v-32.4c0-6.48-4.12-9.72-12.36-9.72-2.16 0-4.18 0.4-6.06 1.2s-3.54 1.8-4.98 3-2.56 2.5-3.36 3.9-1.2 2.7-1.2 3.9v40.92l-10.68-8.4c-0.72-0.64-1.08-1.44-1.08-2.4v-53.76l10.92 8.52c0.32 0.24 0.56 0.44 0.72 0.6s0.36 0.32 0.6 0.48c0.96-1.2 2.14-2.28 3.54-3.24s2.92-1.76 4.56-2.4 3.34-1.14 5.1-1.5 3.44-0.54 5.04-0.54c1.44 0 2.92 0.04 4.44 0.12s2.84 0.28 3.96 0.6c4.56 1.12 7.8 3.12 9.72 6s2.88 6.56 2.88 11.04z" fill-rule="nonzero"/> </g> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m732.38 146.05c0 0.88 0.02 1.5 0.06 1.86s-0.02 0.98-0.18 1.86h-7.08c-2.08 0-4.44-0.02-7.08-0.06s-5.36-0.06-8.16-0.06h-22.08c0 2.88 0.56 5.36 1.68 7.44s2.6 3.8 4.44 5.16 3.94 2.36 6.3 3 4.74 0.96 7.14 0.96c3.04 0 5.9-0.76 8.58-2.28s4.94-3.52 6.78-6c0.64 0.56 1.54 1.48 2.7 2.76s2.94 3.2 5.34 5.76c-2.8 3.36-6.22 6.02-10.26 7.98s-8.42 2.94-13.14 2.94-8.92-0.64-12.84-1.92-7.32-3.24-10.2-5.88-5.12-5.98-6.72-10.02-2.4-8.82-2.4-14.34c0-4.96 0.66-9.42 1.98-13.38s3.22-7.32 5.7-10.08 5.44-4.9 8.88-6.42 7.32-2.28 11.64-2.28c5.76 0 10.52 0.88 14.28 2.64s6.72 4.16 8.88 7.2 3.66 6.54 4.5 10.5 1.26 8.18 1.26 12.66zm-29.4-22.8c-2.16 0.16-4.16 0.72-6 1.68s-3.42 2.2-4.74 3.72-2.36 3.28-3.12 5.28-1.14 4.12-1.14 6.36h33.12c0-2-0.22-4.06-0.66-6.18s-1.3-4.02-2.58-5.7-3.1-3.02-5.46-4.02-5.5-1.38-9.42-1.14z" fill-rule="nonzero"/> </g> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m795.93 146.05c0 0.88 0.02 1.5 0.06 1.86s-0.02 0.98-0.18 1.86h-7.08c-2.08 0-4.44-0.02-7.08-0.06s-5.36-0.06-8.16-0.06h-22.08c0 2.88 0.56 5.36 1.68 7.44s2.6 3.8 4.44 5.16 3.94 2.36 6.3 3 4.74 0.96 7.14 0.96c3.04 0 5.9-0.76 8.58-2.28s4.94-3.52 6.78-6c0.64 0.56 1.54 1.48 2.7 2.76s2.94 3.2 5.34 5.76c-2.8 3.36-6.22 6.02-10.26 7.98s-8.42 2.94-13.14 2.94-8.92-0.64-12.84-1.92-7.32-3.24-10.2-5.88-5.12-5.98-6.72-10.02-2.4-8.82-2.4-14.34c0-4.96 0.66-9.42 1.98-13.38s3.22-7.32 5.7-10.08 5.44-4.9 8.88-6.42 7.32-2.28 11.64-2.28c5.76 0 10.52 0.88 14.28 2.64s6.72 4.16 8.88 7.2 3.66 6.54 4.5 10.5 1.26 8.18 1.26 12.66zm-29.4-22.8c-2.16 0.16-4.16 0.72-6 1.68s-3.42 2.2-4.74 3.72-2.36 3.28-3.12 5.28-1.14 4.12-1.14 6.36h33.12c0-2-0.22-4.06-0.66-6.18s-1.3-4.02-2.58-5.7-3.1-3.02-5.46-4.02-5.5-1.38-9.42-1.14z" fill-rule="nonzero"/> </g> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m858.57 97.21c0.64 0.48 0.96 1.16 0.96 2.04v74.88c-0.08 1.04-0.12 2.12-0.12 3.24-1.84-1.52-3.56-2.92-5.16-4.2-1.36-1.12-2.66-2.18-3.9-3.18s-2.06-1.66-2.46-1.98c-1.76 2.48-4.26 4.44-7.5 5.88s-7.02 2.16-11.34 2.16c-3.84 0-7.4-0.7-10.68-2.1s-6.14-3.44-8.58-6.12-4.34-5.94-5.7-9.78-2.04-8.16-2.04-12.96c0-4.32 0.78-8.34 2.34-12.06s3.6-6.92 6.12-9.6 5.38-4.78 8.58-6.3 6.48-2.28 9.84-2.28c2.56 0 4.82 0.22 6.78 0.66s3.68 1.06 5.16 1.86 2.78 1.74 3.9 2.82 2.16 2.22 3.12 3.42v-35.04l10.68 8.64zm-27.96 67.92c3.6 0 6.52-0.68 8.76-2.04s3.98-3.06 5.22-5.1 2.1-4.22 2.58-6.54 0.72-4.44 0.72-6.36v-1.2c0-1.12-0.22-2.7-0.66-4.74s-1.28-4.06-2.52-6.06-3-3.7-5.28-5.1-5.22-2.02-8.82-1.86c-3.44 0-6.26 0.74-8.46 2.22s-3.96 3.26-5.28 5.34-2.24 4.2-2.76 6.36-0.78 3.92-0.78 5.28c0 1.84 0.24 3.92 0.72 6.24s1.36 4.48 2.64 6.48 3.04 3.68 5.28 5.04 5.12 2.04 8.64 2.04z" fill-rule="nonzero"/> </g> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m882.81 97.09c0.64 0.48 0.96 1.12 0.96 1.92l-0.12 41.04v37.08l-10.56-8.4c-0.72-0.64-1.08-1.44-1.08-2.4v-77.88l10.8 8.64z" fill-rule="nonzero"/> </g> <g transform="matrix(1.008 0 0 1.008 -2.239 .61874)"> <path d="m950.36 146.05c0 0.88 0.02 1.5 0.06 1.86s-0.02 0.98-0.18 1.86h-7.08c-2.08 0-4.44-0.02-7.08-0.06s-5.36-0.06-8.16-0.06h-22.08c0 2.88 0.56 5.36 1.68 7.44s2.6 3.8 4.44 5.16 3.94 2.36 6.3 3 4.74 0.96 7.14 0.96c3.04 0 5.9-0.76 8.58-2.28s4.94-3.52 6.78-6c0.64 0.56 1.54 1.48 2.7 2.76s2.94 3.2 5.34 5.76c-2.8 3.36-6.22 6.02-10.26 7.98s-8.42 2.94-13.14 2.94-8.92-0.64-12.84-1.92-7.32-3.24-10.2-5.88-5.12-5.98-6.72-10.02-2.4-8.82-2.4-14.34c0-4.96 0.66-9.42 1.98-13.38s3.22-7.32 5.7-10.08 5.44-4.9 8.88-6.42 7.32-2.28 11.64-2.28c5.76 0 10.52 0.88 14.28 2.64s6.72 4.16 8.88 7.2 3.66 6.54 4.5 10.5 1.26 8.18 1.26 12.66zm-29.4-22.8c-2.16 0.16-4.16 0.72-6 1.68s-3.42 2.2-4.74 3.72-2.36 3.28-3.12 5.28-1.14 4.12-1.14 6.36h33.12c0-2-0.22-4.06-0.66-6.18s-1.3-4.02-2.58-5.7-3.1-3.02-5.46-4.02-5.5-1.38-9.42-1.14z" fill-rule="nonzero"/> </g> <g transform="matrix(1.8559 0 0 .7642 45.348 36.475)"> <g transform="translate(2.7114)"> <path d="m3.935 173.02c-0.331 0-0.497-0.402-0.497-1.207v-51.002c0-0.738 0.138-1.107 0.414-1.107h1.781c0.277 0 0.415 0.335 0.415 1.006v5.935c0 0.336 0.027 0.553 0.083 0.654 0.055 0.101 0.151-0.017 0.289-0.352 0.912-1.744 1.754-3.236 2.527-4.477 0.773-1.24 1.554-2.179 2.341-2.816s1.65-0.956 2.588-0.956c1.685 0 3.011 0.922 3.977 2.766 0.967 1.845 1.602 3.84 1.905 5.986 0.056 0.268 0.139 0.369 0.249 0.302s0.221-0.235 0.331-0.503c0.939-1.811 1.802-3.353 2.589-4.628 0.787-1.274 1.581-2.246 2.382-2.917s1.671-1.006 2.61-1.006c2.016 0 3.569 1.392 4.66 4.175 1.09 2.783 1.636 6.421 1.636 10.915v37.925c0 0.871-0.18 1.307-0.539 1.307h-1.739c-0.138 0-0.249-0.1-0.332-0.301-0.083-0.202-0.124-0.503-0.124-0.906v-36.315c0-3.555-0.338-6.321-1.015-8.3-0.676-1.978-1.76-2.967-3.251-2.967-0.884 0-1.726 0.386-2.527 1.157s-1.519 1.727-2.154 2.867-1.201 2.213-1.699 3.219c-0.248 0.469-0.421 0.905-0.517 1.308-0.097 0.402-0.145 0.972-0.145 1.71v37.221c0 0.871-0.166 1.307-0.497 1.307h-1.74c-0.166 0-0.29-0.1-0.373-0.301-0.083-0.202-0.124-0.503-0.124-0.906v-36.315c0-3.555-0.332-6.321-0.994-8.3-0.663-1.978-1.754-2.967-3.273-2.967-1.242 0-2.375 0.704-3.396 2.112-1.022 1.409-2.223 3.555-3.604 6.439v39.031c0 0.805-0.18 1.207-0.539 1.207h-1.698z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m53.642 166.28c-1.077 2.549-2.237 4.477-3.479 5.785-1.243 1.307-2.61 1.961-4.101 1.961-2.154 0-3.853-1.324-5.095-3.973-1.243-2.649-1.864-6.187-1.864-10.613 0-3.488 0.4-6.489 1.201-9.004s1.988-4.51 3.562-5.985c1.574-1.476 3.521-2.414 5.841-2.817l3.686-0.704c0.221-0.067 0.394-0.218 0.518-0.453 0.124-0.234 0.187-0.587 0.187-1.056v-2.917c0-3.89-0.504-6.975-1.512-9.255s-2.354-3.42-4.039-3.42c-1.298 0-2.472 0.72-3.521 2.162s-2.002 3.572-2.858 6.388c-0.083 0.268-0.159 0.453-0.228 0.554-0.069 0.1-0.172 0.083-0.311-0.051l-1.698-1.71c-0.083-0.134-0.138-0.285-0.166-0.453-0.027-0.167 0.014-0.452 0.125-0.855 0.856-3.353 2.009-6.052 3.459-8.098 1.449-2.045 3.224-3.068 5.322-3.068 1.74 0 3.211 0.687 4.412 2.062s2.112 3.37 2.734 5.986c0.621 2.615 0.932 5.7 0.932 9.255v35.712c0 0.536-0.035 0.888-0.104 1.056s-0.2 0.251-0.393 0.251h-1.533c-0.166 0-0.29-0.117-0.373-0.352-0.083-0.234-0.124-0.553-0.124-0.955l-0.083-5.231c-0.055-0.939-0.221-1.006-0.497-0.202zm0.456-19.314c0-1.14-0.194-1.643-0.58-1.509l-3.107 0.603c-1.436 0.202-2.686 0.638-3.749 1.308-1.063 0.671-1.953 1.543-2.671 2.616s-1.257 2.33-1.616 3.772-0.538 3.102-0.538 4.98c0 3.152 0.455 5.616 1.367 7.393 0.911 1.778 2.14 2.666 3.686 2.666 0.939 0 1.85-0.419 2.734-1.257s1.671-1.895 2.361-3.169c0.663-1.408 1.181-2.85 1.553-4.326 0.373-1.475 0.56-2.883 0.56-4.225v-8.852z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m79.034 173.02c-0.166 0-0.297-0.117-0.394-0.352-0.096-0.234-0.145-0.553-0.145-0.955v-4.628c0-0.536-0.041-0.838-0.124-0.905s-0.207 0.1-0.373 0.503c-0.276 0.67-0.69 1.593-1.242 2.766-0.553 1.174-1.271 2.23-2.154 3.169-0.884 0.939-1.961 1.408-3.231 1.408-1.74 0-3.314-0.989-4.722-2.967-1.409-1.979-2.534-4.963-3.376-8.953-0.843-3.991-1.264-8.937-1.264-14.838 0-5.701 0.415-10.68 1.243-14.939s1.988-7.595 3.479-10.009c1.492-2.415 3.204-3.622 5.137-3.622 1.436 0 2.616 0.57 3.541 1.71 0.926 1.14 1.719 2.381 2.382 3.722 0.249 0.47 0.414 0.637 0.497 0.503s0.125-0.536 0.125-1.207v-23.841c0-0.805 0.151-1.208 0.455-1.208h1.864c0.276 0 0.414 0.369 0.414 1.107v72.128c0 0.537-0.041 0.905-0.124 1.107-0.083 0.201-0.235 0.301-0.455 0.301h-1.533zm-0.621-42.049c-0.939-2.213-1.885-3.94-2.838-5.181s-2.009-1.861-3.169-1.861c-1.463 0-2.768 0.889-3.914 2.666s-2.044 4.376-2.693 7.796-0.973 7.578-0.973 12.474c0 5.097 0.338 9.272 1.015 12.524 0.676 3.253 1.567 5.651 2.672 7.193 1.104 1.543 2.305 2.314 3.603 2.314 1.188 0 2.258-0.704 3.211-2.113 0.952-1.408 1.705-3.118 2.257-5.13s0.829-3.957 0.829-5.835v-24.847z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m89.514 149.38c0 3.42 0.345 6.606 1.035 9.557 0.691 2.951 1.609 5.315 2.755 7.092s2.437 2.666 3.873 2.666c1.519 0 2.837-0.738 3.956-2.213 1.118-1.476 2.064-3.655 2.837-6.539 0.083-0.336 0.166-0.52 0.249-0.554 0.083-0.033 0.179 0.017 0.29 0.151l1.408 1.912c0.221 0.268 0.235 0.67 0.041 1.207-0.69 2.548-1.47 4.661-2.34 6.337-0.87 1.677-1.857 2.935-2.962 3.773-1.104 0.838-2.319 1.257-3.645 1.257-2.043 0-3.838-1.14-5.385-3.42-1.546-2.28-2.761-5.482-3.645-9.607-0.884-4.124-1.325-8.836-1.325-14.134 0-5.901 0.455-10.931 1.367-15.089 0.911-4.158 2.14-7.377 3.686-9.658 1.547-2.28 3.3-3.42 5.261-3.42 1.988 0 3.714 1.073 5.178 3.219 1.463 2.146 2.595 5.231 3.396 9.255s1.201 8.886 1.201 14.587c0 0.469-0.02 0.939-0.062 1.408-0.041 0.469-0.214 0.704-0.517 0.704h-16.362c-0.083 0-0.152 0.151-0.207 0.453-0.056 0.302-0.083 0.654-0.083 1.056zm13.752-6.237c0.304 0 0.497-0.1 0.58-0.302 0.083-0.201 0.124-0.57 0.124-1.106 0-3.219-0.283-6.187-0.849-8.903s-1.367-4.896-2.402-6.539c-1.036-1.643-2.272-2.464-3.708-2.464-1.629 0-2.996 0.955-4.101 2.867-1.104 1.911-1.94 4.342-2.506 7.293s-0.849 6.002-0.849 9.154h13.711z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m148.54 119.7c0.165 0 0.283 0.117 0.352 0.352s0.076 0.52 0.02 0.855l-6.254 50.902c-0.028 0.47-0.104 0.788-0.228 0.956s-0.297 0.251-0.518 0.251h-1.615c-0.442 0-0.718-0.402-0.829-1.207l-5.26-40.138c-0.111-0.604-0.201-0.905-0.27-0.905s-0.131 0.301-0.186 0.905l-5.012 40.138c-0.028 0.47-0.097 0.788-0.207 0.956-0.111 0.168-0.277 0.251-0.497 0.251h-1.74c-0.442 0-0.718-0.402-0.829-1.207l-6.503-50.801c-0.055-0.403-0.048-0.721 0.021-0.956s0.2-0.352 0.393-0.352h1.823c0.166 0 0.297 0.067 0.393 0.201 0.097 0.134 0.159 0.403 0.187 0.805l5.302 41.848c0.083 0.671 0.179 0.989 0.29 0.956 0.11-0.034 0.207-0.386 0.29-1.056l5.219-41.949c0.055-0.268 0.124-0.47 0.207-0.604s0.193-0.201 0.331-0.201h1.533c0.138 0 0.262 0.067 0.373 0.201 0.11 0.134 0.179 0.403 0.207 0.805l5.468 41.848c0.083 0.671 0.179 0.989 0.29 0.956 0.11-0.034 0.207-0.386 0.29-1.056l5.053-41.849c0.055-0.335 0.138-0.57 0.249-0.704 0.11-0.134 0.234-0.201 0.373-0.201h1.284z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m156.49 171.51c0 0.604-0.042 1.006-0.125 1.208-0.082 0.201-0.262 0.301-0.538 0.301h-1.533c-0.221 0-0.366-0.083-0.435-0.251s-0.103-0.486-0.103-0.956v-50.902c0-0.805 0.152-1.207 0.456-1.207h1.822c0.304 0 0.456 0.402 0.456 1.207v50.6zm0.165-63.979c0 1.207-0.207 1.811-0.621 1.811h-1.905c-0.221 0-0.366-0.135-0.435-0.403s-0.104-0.67-0.104-1.207v-7.847c0-1.006 0.18-1.509 0.539-1.509h1.988c0.359 0 0.538 0.47 0.538 1.409v7.746z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m168.3 124.83c-0.221 0-0.331 0.269-0.331 0.805v33.801c0 3.42 0.221 5.667 0.663 6.74 0.441 1.073 1.09 1.609 1.946 1.609h3.024c0.138 0 0.242 0.084 0.311 0.252 0.069 0.167 0.103 0.419 0.103 0.754v2.716c0 0.537-0.138 0.906-0.414 1.107-0.248 0.067-0.614 0.134-1.098 0.201-0.483 0.067-0.959 0.118-1.429 0.151-0.469 0.034-0.828 0.05-1.077 0.05-1.712 0-2.934-0.955-3.665-2.867-0.732-1.911-1.098-5.013-1.098-9.305v-35.108c0-0.604-0.124-0.906-0.373-0.906h-3.521c-0.248 0-0.373-0.268-0.373-0.804v-3.521c0-0.537 0.111-0.805 0.332-0.805h3.686c0.166 0 0.263-0.268 0.29-0.805l0.415-16.095c0-0.805 0.124-1.207 0.372-1.207h1.492c0.303 0 0.455 0.436 0.455 1.307v15.995c0 0.537 0.097 0.805 0.29 0.805h5.468c0.221 0 0.331 0.268 0.331 0.805v3.521c0 0.536-0.124 0.804-0.373 0.804h-5.426z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> <g transform="translate(2.7114)"> <path d="m179.4 173.02c-0.331 0-0.497-0.402-0.497-1.207v-72.329c0-0.738 0.138-1.107 0.414-1.107h1.782c0.276 0 0.414 0.336 0.414 1.006v27.162c0 0.335 0.034 0.536 0.103 0.603s0.159-0.033 0.27-0.302c0.994-1.81 1.898-3.319 2.713-4.526 0.814-1.208 1.629-2.113 2.444-2.717 0.814-0.603 1.691-0.905 2.63-0.905 2.182 0 3.839 1.375 4.971 4.125 1.132 2.749 1.698 6.404 1.698 10.965v37.925c0 0.871-0.166 1.307-0.497 1.307h-1.74c-0.165 0-0.29-0.1-0.373-0.301-0.082-0.202-0.124-0.503-0.124-0.906v-36.315c0-3.555-0.366-6.321-1.097-8.3-0.732-1.978-1.899-2.967-3.501-2.967-0.883 0-1.705 0.318-2.464 0.956-0.76 0.637-1.526 1.576-2.299 2.816-0.773 1.241-1.643 2.834-2.61 4.779v39.031c0 0.805-0.179 1.207-0.538 1.207h-1.699z" fill-rule="nonzero" stroke="#000" stroke-width=".7px"/> </g> </g> <g transform="matrix(.80638 0 0 .80638 452.53 65.421)" fill-rule="nonzero"> <path d="m79.32 36.98v150.76l15.68-13.2 6.59-156.31-22.27 18.75z" fill="url(#f)"/> <path d="m79.32 36.98-22.27-18.75 6.59 156.31 15.68 13.2v-150.76z" fill="url(#e)"/> <path d="m25.19 104.83 8.63 49.04 12.5-14.95-2.46-56.42-18.67 22.33z" fill="url(#d)"/> <path d="m25.19 104.83-25.19-14.59 16.97 53.86 16.85 9.77-8.63-49.04z" fill="url(#c)"/> <path d="M43.86,82.5L18.69,67.98L0,90.24L25.18,104.83L43.86,82.5Z" fill="#9c3"/> <path d="m134.82 78.69-9.97 56.5 15.58-9.04 19.57-62.05-25.18 14.59z" fill="url(#b)"/> <path d="m134.82 78.69-18.68-22.33-2.86 65 11.57 13.83 9.97-56.5z" fill="url(#a)"/> <path d="m160 64.1-18.69-22.26-25.17 14.52 18.67 22.33 25.19-14.59z" fill="#ffe113"/> <path d="M101.59,18.23L79.32,0L57.05,18.23L79.32,36.98L101.59,18.23Z" fill="#f3e600"/> </g> <defs> <linearGradient id="f" x2="1" gradientTransform="matrix(.84 -162.96 162.96 .84 89.64 184.81)" gradientUnits="userSpaceOnUse"><stop stop-color="#62d399" offset="0"/><stop stop-color="#acd842" offset=".51"/><stop stop-color="#d7db0a" offset=".9"/><stop stop-color="#d7db0a" offset="1"/></linearGradient> <linearGradient id="e" x2="1" gradientTransform="matrix(-1.6,-162.13,162.13,-1.6,69.68,178.9)" gradientUnits="userSpaceOnUse"><stop stop-color="#0ba398" offset="0"/><stop stop-color="#4ca352" offset=".5"/><stop stop-color="#76a30a" offset="1"/></linearGradient> <linearGradient id="d" x2="1" gradientTransform="matrix(-1.9,-67.98,67.98,-1.9,36.6,152.17)" gradientUnits="userSpaceOnUse"><stop stop-color="#36a382" offset="0"/><stop stop-color="#36a382" offset=".19"/><stop stop-color="#49a459" offset=".54"/><stop stop-color="#76a30b" offset="1"/></linearGradient> <linearGradient id="c" x2="1" gradientTransform="matrix(2.18,-62.38,62.38,2.18,15.82,153.24)" gradientUnits="userSpaceOnUse"><stop stop-color="#267880" offset="0"/><stop stop-color="#457a5c" offset=".51"/><stop stop-color="#717516" offset="1"/></linearGradient> <linearGradient id="b" x2="1" gradientTransform="matrix(13.85,-71.96,71.96,13.85,135.08,135.43)" gradientUnits="userSpaceOnUse"><stop stop-color="#b0d939" offset="0"/><stop stop-color="#eadb04" offset="1"/></linearGradient> <linearGradient id="a" x2="1" gradientTransform="matrix(26.159 -64.737 64.737 26.159 107.42 128.14)" gradientUnits="userSpaceOnUse"><stop stop-color="#74af52" offset="0"/><stop stop-color="#74af52" offset=".17"/><stop stop-color="#99be32" offset=".48"/><stop stop-color="#c0c40a" offset="1"/></linearGradient> </defs> </svg>`
9
+ const madeWithNeedleBlob = new Blob([madeWithNeedleSvgString], { type: "image/svg+xml;charset=utf-8" });
10
+ const madeWithNeedleUrl = URL.createObjectURL(madeWithNeedleBlob);
11
+ /** Made With Needle Logo */
12
+ export const madeWithNeedleSVG: string = madeWithNeedleUrl;
13
+
14
+
15
+ const needleLogoSvgString = `<?xml version="1.0" encoding="UTF-8"?><svg id="Ebene_3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 499.26 149.65"><defs><style>.cls-1{fill:url(#Unbenannter_Verlauf_122);}.cls-2{fill:#f3e600;}.cls-3{fill:url(#Unbenannter_Verlauf_113);}.cls-4{fill:url(#gradient-6);}.cls-5{fill:url(#Unbenannter_Verlauf_110);}.cls-6{fill:url(#Unbenannter_Verlauf_101);}.cls-7{fill:#9c3;}.cls-8{fill:#ffe113;}.cls-9{fill:url(#gradient-5);}.cls-10{fill:#02022b;}</style><linearGradient id="Unbenannter_Verlauf_113" x1="89.64" y1="184.81" x2="90.48" y2="21.85" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62d399"/><stop offset=".51" stop-color="#acd842"/><stop offset=".9" stop-color="#d7db0a"/></linearGradient><linearGradient id="Unbenannter_Verlauf_110" x1="69.68" y1="178.9" x2="68.08" y2="16.77" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ba398"/><stop offset=".5" stop-color="#4ca352"/><stop offset="1" stop-color="#76a30a"/></linearGradient><linearGradient id="Unbenannter_Verlauf_101" x1="36.6" y1="152.17" x2="34.7" y2="84.19" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset=".19" stop-color="#36a382"/><stop offset=".54" stop-color="#49a459"/><stop offset="1" stop-color="#76a30b"/></linearGradient><linearGradient id="Unbenannter_Verlauf_122" x1="15.82" y1="153.24" x2="18" y2="90.86" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#267880"/><stop offset=".51" stop-color="#457a5c"/><stop offset="1" stop-color="#717516"/></linearGradient><linearGradient id="gradient-6" x1="135.08" y1="135.43" x2="148.93" y2="63.47" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0d939"/><stop offset="1" stop-color="#eadb04"/></linearGradient><linearGradient id="gradient-5" x1="-4163.25" y1="2285.12" x2="-4160.81" y2="2215.34" gradientTransform="translate(4801.15 -595.25) rotate(20)" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#74af52"/><stop offset=".48" stop-color="#99be32"/><stop offset="1" stop-color="#c0c40a"/></linearGradient><symbol id="needle-icon" viewBox="0 0 160 187.74"><g><polygon class="cls-3" points="79.32 36.98 79.32 187.74 95 174.54 101.59 18.23 79.32 36.98"/><polygon class="cls-5" points="79.32 36.98 57.05 18.23 63.64 174.54 79.32 187.74 79.32 36.98"/><polygon class="cls-6" points="25.19 104.83 33.82 153.87 46.32 138.92 43.86 82.5 25.19 104.83"/><polygon class="cls-1" points="25.19 104.83 0 90.24 16.97 144.1 33.82 153.87 25.19 104.83"/><polygon class="cls-7" points="43.86 82.5 18.69 67.98 0 90.24 25.18 104.83 43.86 82.5"/><polygon class="cls-4" points="134.82 78.69 124.85 135.19 140.43 126.15 160 64.1 134.82 78.69"/><polygon class="cls-9" points="134.82 78.69 116.14 56.36 113.28 121.36 124.85 135.19 134.82 78.69"/><polygon class="cls-8" points="160 64.1 141.31 41.84 116.14 56.36 134.81 78.69 160 64.1"/><polygon class="cls-2" points="101.59 18.23 79.32 0 57.05 18.23 79.32 36.98 101.59 18.23"/></g></symbol></defs><g><path class="cls-10" d="M214.78,77.8v35.52l-10.56-8.4c-.8-.64-1.2-1.44-1.2-2.4v-32.4c0-6.48-4.12-9.72-12.36-9.72-2.16,0-4.18,.4-6.06,1.2-1.88,.8-3.54,1.8-4.98,3-1.44,1.2-2.56,2.5-3.36,3.9-.8,1.4-1.2,2.7-1.2,3.9v40.92l-10.68-8.4c-.72-.64-1.08-1.44-1.08-2.4V48.76l10.92,8.52c.32,.24,.56,.44,.72,.6,.16,.16,.36,.32,.6,.48,.96-1.2,2.14-2.28,3.54-3.24,1.4-.96,2.92-1.76,4.56-2.4,1.64-.64,3.34-1.14,5.1-1.5,1.76-.36,3.44-.54,5.04-.54,1.44,0,2.92,.04,4.44,.12,1.52,.08,2.84,.28,3.96,.6,4.56,1.12,7.8,3.12,9.72,6,1.92,2.88,2.88,6.56,2.88,11.04v9.36Z"/><path class="cls-10" d="M281.21,81.76c0,.88,.02,1.5,.06,1.86,.04,.36-.02,.98-.18,1.86h-7.08c-2.08,0-4.44-.02-7.08-.06-2.64-.04-5.36-.06-8.16-.06h-22.08c0,2.88,.56,5.36,1.68,7.44,1.12,2.08,2.6,3.8,4.44,5.16,1.84,1.36,3.94,2.36,6.3,3,2.36,.64,4.74,.96,7.14,.96,3.04,0,5.9-.76,8.58-2.28,2.68-1.52,4.94-3.52,6.78-6,.64,.56,1.54,1.48,2.7,2.76,1.16,1.28,2.94,3.2,5.34,5.76-2.8,3.36-6.22,6.02-10.26,7.98-4.04,1.96-8.42,2.94-13.14,2.94s-8.92-.64-12.84-1.92c-3.92-1.28-7.32-3.24-10.2-5.88-2.88-2.64-5.12-5.98-6.72-10.02-1.6-4.04-2.4-8.82-2.4-14.34,0-4.96,.66-9.42,1.98-13.38,1.32-3.96,3.22-7.32,5.7-10.08,2.48-2.76,5.44-4.9,8.88-6.42,3.44-1.52,7.32-2.28,11.64-2.28,5.76,0,10.52,.88,14.28,2.64,3.76,1.76,6.72,4.16,8.88,7.2,2.16,3.04,3.66,6.54,4.5,10.5,.84,3.96,1.26,8.18,1.26,12.66Zm-29.4-22.8c-2.16,.16-4.16,.72-6,1.68-1.84,.96-3.42,2.2-4.74,3.72-1.32,1.52-2.36,3.28-3.12,5.28-.76,2-1.14,4.12-1.14,6.36h33.12c0-2-.22-4.06-.66-6.18-.44-2.12-1.3-4.02-2.58-5.7-1.28-1.68-3.1-3.02-5.46-4.02-2.36-1-5.5-1.38-9.42-1.14Z"/><path class="cls-10" d="M344.76,81.76c0,.88,.02,1.5,.06,1.86,.04,.36-.02,.98-.18,1.86h-7.08c-2.08,0-4.44-.02-7.08-.06-2.64-.04-5.36-.06-8.16-.06h-22.08c0,2.88,.56,5.36,1.68,7.44,1.12,2.08,2.6,3.8,4.44,5.16,1.84,1.36,3.94,2.36,6.3,3,2.36,.64,4.74,.96,7.14,.96,3.04,0,5.9-.76,8.58-2.28,2.68-1.52,4.94-3.52,6.78-6,.64,.56,1.54,1.48,2.7,2.76,1.16,1.28,2.94,3.2,5.34,5.76-2.8,3.36-6.22,6.02-10.26,7.98-4.04,1.96-8.42,2.94-13.14,2.94s-8.92-.64-12.84-1.92c-3.92-1.28-7.32-3.24-10.2-5.88-2.88-2.64-5.12-5.98-6.72-10.02-1.6-4.04-2.4-8.82-2.4-14.34,0-4.96,.66-9.42,1.98-13.38,1.32-3.96,3.22-7.32,5.7-10.08,2.48-2.76,5.44-4.9,8.88-6.42,3.44-1.52,7.32-2.28,11.64-2.28,5.76,0,10.52,.88,14.28,2.64,3.76,1.76,6.72,4.16,8.88,7.2,2.16,3.04,3.66,6.54,4.5,10.5,.84,3.96,1.26,8.18,1.26,12.66Zm-29.4-22.8c-2.16,.16-4.16,.72-6,1.68-1.84,.96-3.42,2.2-4.74,3.72-1.32,1.52-2.36,3.28-3.12,5.28-.76,2-1.14,4.12-1.14,6.36h33.12c0-2-.22-4.06-.66-6.18-.44-2.12-1.3-4.02-2.58-5.7-1.28-1.68-3.1-3.02-5.46-4.02-2.36-1-5.5-1.38-9.42-1.14Z"/><path class="cls-10" d="M407.4,32.92c.64,.48,.96,1.16,.96,2.04V109.84c-.08,1.04-.12,2.12-.12,3.24-1.84-1.52-3.56-2.92-5.16-4.2-1.36-1.12-2.66-2.18-3.9-3.18-1.24-1-2.06-1.66-2.46-1.98-1.76,2.48-4.26,4.44-7.5,5.88-3.24,1.44-7.02,2.16-11.34,2.16-3.84,0-7.4-.7-10.68-2.1-3.28-1.4-6.14-3.44-8.58-6.12-2.44-2.68-4.34-5.94-5.7-9.78-1.36-3.84-2.04-8.16-2.04-12.96,0-4.32,.78-8.34,2.34-12.06,1.56-3.72,3.6-6.92,6.12-9.6,2.52-2.68,5.38-4.78,8.58-6.3,3.2-1.52,6.48-2.28,9.84-2.28,2.56,0,4.82,.22,6.78,.66,1.96,.44,3.68,1.06,5.16,1.86,1.48,.8,2.78,1.74,3.9,2.82,1.12,1.08,2.16,2.22,3.12,3.42V24.28l10.68,8.64Zm-27.96,67.92c3.6,0,6.52-.68,8.76-2.04,2.24-1.36,3.98-3.06,5.22-5.1,1.24-2.04,2.1-4.22,2.58-6.54,.48-2.32,.72-4.44,.72-6.36v-1.2c0-1.12-.22-2.7-.66-4.74-.44-2.04-1.28-4.06-2.52-6.06-1.24-2-3-3.7-5.28-5.1s-5.22-2.02-8.82-1.86c-3.44,0-6.26,.74-8.46,2.22-2.2,1.48-3.96,3.26-5.28,5.34-1.32,2.08-2.24,4.2-2.76,6.36-.52,2.16-.78,3.92-.78,5.28,0,1.84,.24,3.92,.72,6.24,.48,2.32,1.36,4.48,2.64,6.48,1.28,2,3.04,3.68,5.28,5.04,2.24,1.36,5.12,2.04,8.64,2.04Z"/><path class="cls-10" d="M431.64,32.8c.64,.48,.96,1.12,.96,1.92l-.12,41.04v37.08l-10.56-8.4c-.72-.64-1.08-1.44-1.08-2.4V24.16l10.8,8.64Z"/><path class="cls-10" d="M499.19,81.76c0,.88,.02,1.5,.06,1.86,.04,.36-.02,.98-.18,1.86h-7.08c-2.08,0-4.44-.02-7.08-.06-2.64-.04-5.36-.06-8.16-.06h-22.08c0,2.88,.56,5.36,1.68,7.44,1.12,2.08,2.6,3.8,4.44,5.16,1.84,1.36,3.94,2.36,6.3,3,2.36,.64,4.74,.96,7.14,.96,3.04,0,5.9-.76,8.58-2.28,2.68-1.52,4.94-3.52,6.78-6,.64,.56,1.54,1.48,2.7,2.76,1.16,1.28,2.94,3.2,5.34,5.76-2.8,3.36-6.22,6.02-10.26,7.98-4.04,1.96-8.42,2.94-13.14,2.94s-8.92-.64-12.84-1.92c-3.92-1.28-7.32-3.24-10.2-5.88-2.88-2.64-5.12-5.98-6.72-10.02-1.6-4.04-2.4-8.82-2.4-14.34,0-4.96,.66-9.42,1.98-13.38,1.32-3.96,3.22-7.32,5.7-10.08,2.48-2.76,5.44-4.9,8.88-6.42,3.44-1.52,7.32-2.28,11.64-2.28,5.76,0,10.52,.88,14.28,2.64,3.76,1.76,6.72,4.16,8.88,7.2,2.16,3.04,3.66,6.54,4.5,10.5,.84,3.96,1.26,8.18,1.26,12.66Zm-29.4-22.8c-2.16,.16-4.16,.72-6,1.68-1.84,.96-3.42,2.2-4.74,3.72-1.32,1.52-2.36,3.28-3.12,5.28-.76,2-1.14,4.12-1.14,6.36h33.12c0-2-.22-4.06-.66-6.18-.44-2.12-1.3-4.02-2.58-5.7-1.28-1.68-3.1-3.02-5.46-4.02-2.36-1-5.5-1.38-9.42-1.14Z"/></g><use width="160" height="187.74" transform="scale(.8)" xlink:href="#needle-icon"/></svg>`;
16
+ const needleLogoBlob = new Blob([needleLogoSvgString], { type: "image/svg+xml;charset=utf-8" });
17
+ const needleLogoUrl = URL.createObjectURL(needleLogoBlob);
18
+ /** Logo + Needle Typo */
19
+ export const needleLogoSVG: string = needleLogoUrl
src/engine-components/Interactable.ts CHANGED
@@ -8,4 +8,8 @@
8
8
  {
9
9
  public isUsed: boolean = true;
10
10
  public usedBy: any = null;
11
- }
11
+ }
12
+
13
+
14
+ /** @deprecated */
15
+ export class Interactable extends Behaviour {}
src/engine/xr/NeedleXRController.ts CHANGED
@@ -450,7 +450,8 @@
450
450
 
451
451
  private readonly _needleGamepadButtons: { [key: number | string]: NeedleGamepadButton } = {};
452
452
  /** combine the InputState information + the GamepadButton information (since GamepadButtons can not be extended) */
453
- private toNeedleGamepadButton(index: number): NeedleGamepadButton {
453
+ private toNeedleGamepadButton(index: number): NeedleGamepadButton | undefined {
454
+ if(!this.inputSource.gamepad?.buttons) return undefined
454
455
  const button = this.inputSource.gamepad?.buttons[index];
455
456
  const state = this.states[index];
456
457
  const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton();
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -110,7 +110,7 @@
110
110
 
111
111
  if (isDesktop() && isDevEnvironment()) {
112
112
  window.addEventListener("keydown", (evt) => {
113
- if (evt.key === "x") {
113
+ if (evt.key === "x" || evt.key === "Escape") {
114
114
  if (NeedleXRSession.active) {
115
115
  NeedleXRSession.stop();
116
116
  }
@@ -741,8 +741,6 @@
741
741
  this._originalCameraParent = this.context.mainCamera.parent;
742
742
  }
743
743
 
744
- this.context.mainCameraComponent?.applyClearFlags();
745
-
746
744
  this._defaultRig = new ImplictXRRig();
747
745
  this.context.scene.add(this._defaultRig.gameObject);
748
746
  this.addRig(this._defaultRig);
@@ -779,6 +777,7 @@
779
777
  this.context.renderer.xr.enabled = true;
780
778
  // calling update camera here once to prevent the xr camera left/right NaN in the first frame due to false near/far plane values, see NE-4126
781
779
  this.context.renderer.xr.updateCamera(this.context.mainCamera as PerspectiveCamera);
780
+ this.context.mainCameraComponent?.applyClearFlags();
782
781
  }
783
782
 
784
783
  private onInputSourceAdded = (newInputSource: XRInputSource) => {
@@ -857,7 +856,10 @@
857
856
 
858
857
  this.context.xr = null;
859
858
  this.context.renderer.xr.enabled = false;
860
- this.context.mainCameraComponent?.applyClearFlags();
859
+ // apply the clearflags at the beginning of the next frame
860
+ this.context.pre_update_oneshot_callbacks.push(()=>{
861
+ this.context.mainCameraComponent?.applyClearFlags()
862
+ });
861
863
 
862
864
  for (const listener of NeedleXRSession._xrEndListeners) {
863
865
  listener({ xr: this });
src/engine-components/postprocessing/PostProcessingEffect.ts CHANGED
@@ -117,7 +117,8 @@
117
117
  }
118
118
  }
119
119
 
120
-
120
+ // TODO this is currently not used for post processing effects that are part of Volume stacks,
121
+ // since these handle that already.
121
122
  onEditorModification(modification: EditorModification): void | boolean | undefined {
122
123
  // Handle a property modification if the property is a VolumeParameter and the modification is just a plain value
123
124
  const key = modification.propertyName;
src/engine-components/postprocessing/PostProcessingHandler.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { N8AOPostPass } from "n8ao";
2
- import { BloomEffect, BrightnessContrastEffect, ChromaticAberrationEffect, DepthDownsamplingPass, DepthOfFieldEffect, Effect, EffectComposer, EffectPass, HueSaturationEffect, NormalPass, Pass, PixelationEffect, RenderPass, SelectiveBloomEffect, SSAOEffect, VignetteEffect } from "postprocessing";
2
+ import { BloomEffect, BrightnessContrastEffect, ChromaticAberrationEffect, DepthDownsamplingPass, DepthOfFieldEffect, Effect, EffectComposer, EffectPass, HueSaturationEffect, NormalPass, Pass, PixelationEffect, RenderPass, SelectiveBloomEffect, SMAAEffect, SSAOEffect, ToneMappingEffect, VignetteEffect } from "postprocessing";
3
3
  import { HalfFloatType } from "three";
4
4
 
5
5
  import { showBalloonWarning } from "../../engine/debug/index.js";
@@ -131,13 +131,14 @@
131
131
  // https://github.com/pmndrs/postprocessing/blob/271944b74b543a5b743a62803a167b60cc6bb4ee/src/core/EffectComposer.js#L230C12-L230C12
132
132
  renderer[autoclearSetting] = renderer.autoClear;
133
133
 
134
+ const maxSamples = renderer.capabilities.maxSamples;
134
135
  // create composer and set active on context
135
136
  if (!this._composer) {
136
137
  // const hdrRenderTarget = new WebGLRenderTarget(window.innerWidth, window.innerHeight, { type: HalfFloatType });
137
138
  this._composer = new EffectComposer(renderer, {
138
139
  frameBufferType: HalfFloatType,
139
140
  stencilBuffer: true,
140
- multisampling: isMobileDevice() ? 0 : 8,
141
+ multisampling: Math.min(isMobileDevice() ? 4 : 8, maxSamples),
141
142
  });
142
143
  }
143
144
  context.composer = this._composer;
@@ -152,6 +153,7 @@
152
153
 
153
154
  // Render to screen pass
154
155
  const screenpass = new RenderPass(scene, cam);
156
+ screenpass.name = "Render To Screen";
155
157
  screenpass.mainScene = scene;
156
158
  composer.addPass(screenpass);
157
159
 
@@ -177,6 +179,7 @@
177
179
  // create and apply uber pass
178
180
  if (effects.length > 0) {
179
181
  const pass = new EffectPass(cam, ...effects);
182
+ pass.name = effects.map(e => e.name).join(" ");
180
183
  pass.mainScene = scene;
181
184
  pass.enabled = true;
182
185
  composer.addPass(pass);
@@ -185,7 +188,6 @@
185
188
  catch(e) {
186
189
  console.error("Error while applying postprocessing effects", e);
187
190
  composer.removeAllPasses();
188
- composer.addPass(screenpass);
189
191
  }
190
192
 
191
193
 
@@ -221,14 +223,16 @@
221
223
  export const effectsOrder: Array<Constructor<Effect | Pass>> = [
222
224
  NormalPass,
223
225
  DepthDownsamplingPass,
226
+ SMAAEffect,
224
227
  SSAOEffect,
225
228
  N8AOPostPass,
226
229
  DepthOfFieldEffect,
230
+ ChromaticAberrationEffect,
227
231
  BloomEffect,
228
232
  SelectiveBloomEffect,
229
233
  VignetteEffect,
234
+ ToneMappingEffect,
230
235
  HueSaturationEffect,
231
- ChromaticAberrationEffect,
232
236
  BrightnessContrastEffect,
233
237
  PixelationEffect,
234
238
  ];
src/engine/codegen/register_types.ts CHANGED
@@ -89,6 +89,7 @@
89
89
  import { Image } from "../../engine-components/ui/Image.js";
90
90
  import { InheritVelocityModule } from "../../engine-components/ParticleSystemModules.js";
91
91
  import { InputField } from "../../engine-components/ui/InputField.js";
92
+ import { Interactable } from "../../engine-components/Interactable.js";
92
93
  import { Light } from "../../engine-components/Light.js";
93
94
  import { LimitVelocityOverLifetimeModule } from "../../engine-components/ParticleSystemModules.js";
94
95
  import { LODGroup } from "../../engine-components/LODGroup.js";
@@ -102,7 +103,7 @@
102
103
  import { MeshRenderer } from "../../engine-components/Renderer.js";
103
104
  import { MinMaxCurve } from "../../engine-components/ParticleSystemModules.js";
104
105
  import { MinMaxGradient } from "../../engine-components/ParticleSystemModules.js";
105
- import { NeedleWebXRHtmlElement } from "../../engine-components/webxr/WebXRButtons.js";
106
+ import { NeedleMenu } from "../../engine-components/NeedleMenu.js";
106
107
  import { NestedGltf } from "../../engine-components/NestedGltf.js";
107
108
  import { Networking } from "../../engine-components/Networking.js";
108
109
  import { NoiseModule } from "../../engine-components/ParticleSystemModules.js";
@@ -204,6 +205,7 @@
204
205
  import { WebARCameraBackground } from "../../engine-components/webxr/WebARCameraBackground.js";
205
206
  import { WebARSessionRoot } from "../../engine-components/webxr/WebARSessionRoot.js";
206
207
  import { WebXR } from "../../engine-components/webxr/WebXR.js";
208
+ import { WebXRButtonFactory } from "../../engine-components/webxr/WebXRButtons.js";
207
209
  import { WebXRImageTracking } from "../../engine-components/webxr/WebXRImageTracking.js";
208
210
  import { WebXRImageTrackingModel } from "../../engine-components/webxr/WebXRImageTracking.js";
209
211
  import { WebXRPlaneTracking } from "../../engine-components/webxr/WebXRPlaneTracking.js";
@@ -303,6 +305,7 @@
303
305
  TypeStore.add("Image", Image);
304
306
  TypeStore.add("InheritVelocityModule", InheritVelocityModule);
305
307
  TypeStore.add("InputField", InputField);
308
+ TypeStore.add("Interactable", Interactable);
306
309
  TypeStore.add("Light", Light);
307
310
  TypeStore.add("LimitVelocityOverLifetimeModule", LimitVelocityOverLifetimeModule);
308
311
  TypeStore.add("LODGroup", LODGroup);
@@ -316,7 +319,7 @@
316
319
  TypeStore.add("MeshRenderer", MeshRenderer);
317
320
  TypeStore.add("MinMaxCurve", MinMaxCurve);
318
321
  TypeStore.add("MinMaxGradient", MinMaxGradient);
319
- TypeStore.add("NeedleWebXRHtmlElement", NeedleWebXRHtmlElement);
322
+ TypeStore.add("NeedleMenu", NeedleMenu);
320
323
  TypeStore.add("NestedGltf", NestedGltf);
321
324
  TypeStore.add("Networking", Networking);
322
325
  TypeStore.add("NoiseModule", NoiseModule);
@@ -418,6 +421,7 @@
418
421
  TypeStore.add("WebARCameraBackground", WebARCameraBackground);
419
422
  TypeStore.add("WebARSessionRoot", WebARSessionRoot);
420
423
  TypeStore.add("WebXR", WebXR);
424
+ TypeStore.add("WebXRButtonFactory", WebXRButtonFactory);
421
425
  TypeStore.add("WebXRImageTracking", WebXRImageTracking);
422
426
  TypeStore.add("WebXRImageTrackingModel", WebXRImageTrackingModel);
423
427
  TypeStore.add("WebXRPlaneTracking", WebXRPlaneTracking);
src/engine-components/postprocessing/Effects/Tonemapping.ts CHANGED
@@ -33,21 +33,20 @@
33
33
  this._apply(this.mode!.value)
34
34
  }
35
35
 
36
- unapply() {
37
- this.context.renderer.toneMapping = NoToneMapping;
36
+ private _apply(v: TonemappingMode) {
37
+ this.context.renderer.toneMapping = this.getThreeToneMapping(v);
38
38
  }
39
39
 
40
- private _apply(v) {
41
- switch (v) {
40
+ getThreeToneMapping(mode: TonemappingMode | undefined) {
41
+ switch (mode) {
42
42
  case TonemappingMode.None:
43
- this.context.renderer.toneMapping = LinearToneMapping;
44
- break;
43
+ return LinearToneMapping;
45
44
  case TonemappingMode.Neutral:
46
- this.context.renderer.toneMapping = ReinhardToneMapping;
47
- break;
45
+ return ReinhardToneMapping;
48
46
  case TonemappingMode.ACES:
49
- this.context.renderer.toneMapping = ACESFilmicToneMapping;
50
- break;
47
+ return ACESFilmicToneMapping;
48
+ default:
49
+ return LinearToneMapping; // TODO should be Linear
51
50
  }
52
51
  }
53
52
 
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  import { Behaviour, GameObject } from "../../Component.js";
9
9
  import { Renderer } from "../../Renderer.js"
10
10
  import { WebARSessionRoot } from "../../webxr/WebARSessionRoot.js";
11
- import { NeedleWebXRHtmlElement } from "../../webxr/WebXRButtons.js";
12
- import { XRState, XRStateFlag } from "../../webxr/XRFlag.js";
11
+ import { WebXRButtonFactory } from "../../webxr/WebXRButtons.js";
12
+ import { XRFlag, XRState, XRStateFlag } from "../../webxr/XRFlag.js";
13
13
  import type { IUSDExporterExtension } from "./Extension.js";
14
14
  import { AnimationExtension } from "./extensions/Animation.js"
15
15
  import { AudioExtension } from "./extensions/behavior/AudioExtension.js";
@@ -465,9 +465,9 @@
465
465
 
466
466
 
467
467
  private createQuicklookButton() {
468
- const buttoncontainer = NeedleWebXRHtmlElement.getOrCreate(this.context);
468
+ const buttoncontainer = WebXRButtonFactory.getOrCreate();
469
469
  const button = buttoncontainer.createQuicklookButton();
470
- if(!button.parentNode) buttoncontainer.appendChild(button);
470
+ if(!button.parentNode) this.context.menu.appendChild(button);
471
471
  return button;
472
472
  }
473
473
  }
src/engine-components/postprocessing/Volume.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EffectComposer } from "postprocessing";
2
2
 
3
- import { isDevEnvironment } from "../../engine/debug/index.js";
3
+ import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
4
4
  import type { EditorModification, IEditorModification as IEditorModificationReceiver } from "../../engine/engine_editor-sync.js";
5
5
  import { serializeable } from "../../engine/engine_serialization_decorator.js";
6
6
  import { getParam } from "../../engine/engine_utils.js";
@@ -34,8 +34,8 @@
34
34
  console.log("Press P to toggle post processing");
35
35
  window.addEventListener("keydown", (e) => {
36
36
  if (e.key === "p") {
37
- console.log("Toggle volume: " + this.name, !this.enabled);
38
37
  this.enabled = !this.enabled;
38
+ showBalloonMessage("Toggle PostProcessing " + this.name + ": Enabled=" + this.enabled);
39
39
  this.markDirty();
40
40
  }
41
41
  });
src/engine-components/webxr/WebXR.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { XRControllerModel } from "./controllers/XRControllerModel.js";
14
14
  import { XRControllerMovement } from "./controllers/XRControllerMovement.js";
15
15
  import { WebARSessionRoot } from "./WebARSessionRoot.js";
16
- import { NeedleWebXRHtmlElement } from "./WebXRButtons.js";
16
+ import { WebXRButtonFactory } from "./WebXRButtons.js";
17
17
  import { XRState, XRStateFlag } from "./XRFlag.js";
18
18
 
19
19
  const debug = getParam("debugwebxr");
@@ -71,7 +71,6 @@
71
71
 
72
72
  awake() {
73
73
  NeedleXRSession.getXRSync(this.context);
74
- if (debug) window.addEventListener("keydown", evt => { if (evt.key === "x") this.exitXR(); });
75
74
  }
76
75
 
77
76
  onEnable(): void {
@@ -102,17 +101,11 @@
102
101
  this._playerSync.onPlayerSpawned?.removeEventListener(this.onAvatarSpawned);
103
102
  this._playerSync.onPlayerSpawned?.addEventListener(this.onAvatarSpawned);
104
103
  }
105
-
106
- // if a button container exists and is not attached to any parent element we attach it to the shadow root (assuming it was removed during onDisable)
107
- if (this._container && !this._container.parentNode) {
108
- this.context.domElement.shadowRoot?.appendChild(this._container);
109
- }
110
104
  }
111
105
 
112
106
  onDisable(): void {
113
- // remove the container automatically if it was added to the shadow root
114
- this._container?.remove();
115
107
  this._usdzExporter?.destroy();
108
+ this.removeButtons();
116
109
  }
117
110
 
118
111
  private async handleOfferSession() {
@@ -183,7 +176,7 @@
183
176
  sessionroot.arTouchTransform = this.usePlacementAdjustment;
184
177
  sessionroot.useXRAnchor = this.useXRAnchor;
185
178
  }
186
- else if(debug) console.log("WebXR: WebARSessionRoot already exists, not creating a new one")
179
+ else if (debug) console.log("WebXR: WebARSessionRoot already exists, not creating a new one")
187
180
  }
188
181
 
189
182
  // handle VR controls
@@ -260,42 +253,62 @@
260
253
 
261
254
 
262
255
  // HTML UI
263
- /** Calling this function will get the Needle WebXR button container (it will be created if it doesnt exist yet)
264
- * @returns the Needle WebXR button container */
265
- getButtonsContainer(): NeedleWebXRHtmlElement {
266
- if (!this._container) {
267
- this._container = NeedleWebXRHtmlElement.getOrCreate(this.context);
256
+
257
+ /** @deprecated use `getButtonsFactory()` or access `WebXRButtonFactory.getOrCreate()` directory */
258
+ getButtonsContainer(): WebXRButtonFactory {
259
+ return this.getButtonsFactory();
260
+ }
261
+
262
+ /** Calling this function will get the Needle WebXR button factory (it will be created if it doesnt exist yet)
263
+ * @returns the Needle WebXR button factory */
264
+ getButtonsFactory(): WebXRButtonFactory {
265
+ if (!this._buttonFactory) {
266
+ this._buttonFactory = WebXRButtonFactory.getOrCreate();
268
267
  }
269
- return this._container;
268
+ return this._buttonFactory;
270
269
  }
271
270
 
272
- private _container?: NeedleWebXRHtmlElement;
271
+ private _buttonFactory?: WebXRButtonFactory;
272
+
273
273
  private handleCreatingHTML() {
274
274
 
275
275
  if (this.createARButton || this.createVRButton || this.useQuicklookExport) {
276
276
  // Quicklook / iOS
277
277
  if ((isiOS() && isSafari()) || debugQuicklook) {
278
278
  if (this.useQuicklookExport) {
279
- this.getButtonsContainer().createQuicklookButton();
279
+ this.addButton(this.getButtonsFactory().createQuicklookButton());
280
280
  }
281
281
  }
282
282
  // WebXR
283
- if (this.createARButton) this.getButtonsContainer().createARButton();
284
- if (this.createVRButton) this.getButtonsContainer().createVRButton();
283
+ if (this.createARButton) this.addButton(this.getButtonsFactory().createARButton());
284
+ if (this.createVRButton) this.addButton(this.getButtonsFactory().createVRButton());
285
285
  }
286
286
 
287
287
  if (this.createSendToQuestButton && !isQuest()) {
288
288
  NeedleXRSession.isVRSupported().then(supported => {
289
- if (!supported) this.getButtonsContainer().createSendToQuestButton();
289
+ if (!supported) this.addButton(this.getButtonsFactory().createSendToQuestButton());
290
290
  });
291
291
  }
292
292
 
293
293
  if (this.createQRCode && !isMobileDevice()) {
294
294
  NeedleXRSession.isXRSupported().then(supported => {
295
- if (isDesktop() || !supported) this.getButtonsContainer().createQRCode();
295
+ if (isDesktop() || !supported) this.addButton(this.getButtonsFactory().createQRCode());
296
296
  });
297
297
  }
298
298
  }
299
299
 
300
+ private readonly _buttons: HTMLElement[] = [];
300
301
 
302
+ private addButton(button: HTMLElement) {
303
+ this._buttons.push(button);
304
+ this.context.menu.appendChild(button);
305
+ }
306
+
307
+ private removeButtons() {
308
+ for (const button of this._buttons) {
309
+ button.remove();
310
+ }
311
+ this._buttons.length = 0;
312
+ }
313
+
301
314
  }
src/engine-components/webxr/WebXRButtons.ts CHANGED
@@ -1,162 +1,75 @@
1
1
  import { isDevEnvironment } from "../../engine/debug/index.js";
2
- import { Context } from "../../engine/engine_context.js";
3
2
  import { generateQRCode } from "../../engine/engine_utils.js";
4
3
  import { isMozillaXR } from "../../engine/engine_utils.js";
5
4
  import { NeedleXRSession } from "../../engine/engine_xr.js";
6
5
  import { GameObject } from "../Component.js";
7
6
  import { USDZExporter } from "../export/usdz/USDZExporter.js";
8
7
 
9
- const webXRElementName = "needle-webxr-buttons";
8
+ // TODO: move these buttons into their own web components so their logic is encapsulated (e.g. the CSS animation when a xr session is requested)
10
9
 
11
- // TODO: add feedback process to the buttons when XR session is requested/pending... perhaps we need to expose an event on the NeedleXRSession class for this
10
+ export class WebXRButtonFactory {
12
11
 
13
- export class NeedleWebXRHtmlElement extends HTMLElement {
14
-
15
- static create() {
16
- return document.createElement(webXRElementName) as NeedleWebXRHtmlElement;
12
+ private static _instance: WebXRButtonFactory;
13
+ private static create() {
14
+ return new WebXRButtonFactory();
17
15
  }
18
16
 
19
- static getOrCreate(context: Context) {
20
- const domElement = context.domElement;
21
- let el = domElement.querySelector(webXRElementName);
22
- if (!el) {
23
- el = NeedleWebXRHtmlElement.create();
24
- domElement.appendChild(el);
25
- };
26
- return el as NeedleWebXRHtmlElement;
17
+ static getOrCreate() {
18
+ if (!this._instance) {
19
+ this._instance = this.create();
20
+ }
21
+ return this._instance;
27
22
  }
28
23
 
29
- private readonly root: HTMLElement;
24
+ private get isSecureConnection() { return window.location.protocol === "https:"; }
30
25
 
31
- constructor() {
32
- super();
33
- this.attachShadow({ mode: 'open' });
34
- const template = document.createElement('template');
35
- template.innerHTML = `<style>
36
- :host {
37
- position: absolute;
38
- display: flex;
39
- flex-wrap: wrap;
40
- justify-content: center;
41
- /** increase z-index (nipplejs has 999 as default) */
42
- z-index: 5000;
43
- width: 100%;
44
- bottom: 100px;
45
- left: 50%;
46
- transform: translateX(-50%);
47
- pointer-events: none;
48
- }
49
- :host button {
50
- font-family: Roboto, sans-serif, Arial;
51
- border: none;
52
- color: black;
53
- background: rgba(255, 255, 255, 1);
54
- margin: 5px 5px;
55
- padding: 0.5rem .7rem;
56
- font-size: 1rem;
57
- white-space: nowrap;
58
- transition: all 0.2s ease-in-out;
59
- border-radius: .2rem;
60
- border: rgba(255, 255, 255, 0.2) solid 1px;
61
- box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
62
- font-weight: normal;
63
- pointer-events: all;
64
- }
65
- :host button:hover {
66
- cursor: pointer;
67
- background: rgba(255, 255, 255, 1);
68
- box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1), 0 0 15px rgba(255, 255, 255, 0.2);
69
- transition: all 0.1s ease-in-out;
70
- }
71
- :host button:disabled {
72
- background: rgba(200, 200, 200, 1);
73
- color: rgba(100, 100, 100, 1);
74
- border: rgba(0,0,0,0) 1px solid;
75
- box-shadow: none;
76
- cursor: initial;
77
- }
78
- :host button.this-mode-is-requested {
79
- font-weight: bold;
80
- background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
81
- background-size: 200% auto;
82
- background-position: 0 100%;
83
- animation: AnimationName .7s ease infinite forwards;
84
- }
85
- :host button.other-mode-is-requested {
86
- }
87
-
88
- @keyframes AnimationName {
89
- 0% { background-position: 0% 0 }
90
- 100% { background-position: -200% 0 }
91
- }
92
26
 
93
- :host .qr-code-container {
94
- position: absolute;
95
- display: initial;
96
- bottom: 100%;
97
- left: 50%;
98
- transform: translateX(-50%) translateY(-10px);
99
- background-color: white;
100
- padding: 1.2rem;
101
- border-radius: 0.4rem;
102
- pointer-events: all;
103
- opacity: 1;
104
- transition: opacity 0.2s ease-in-out;
105
- box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
106
- }
27
+ get quicklookButton() { return this._quicklookButton }
28
+ private _quicklookButton?: HTMLButtonElement;
107
29
 
108
- :host .qr-code-container img {
109
- max-width: calc(min(100vw, 300px) - 20px);
110
- }
30
+ get arButton() { return this._arButton; }
31
+ private _arButton?: HTMLButtonElement;
111
32
 
112
- :host .qr-code-container.hidden {
113
- opacity: 0;
114
- display: none; /* prevents the QR code from overflowing the body when it's actually disabled but breaks animation */
115
- pointer-events: none;
116
- }
117
- </style>
118
- `;
33
+ get vrButton() { return this._vrButton }
34
+ private _vrButton?: HTMLButtonElement;
119
35
 
120
- this.root = document.createElement("div");
121
- if (window.location.protocol !== "https:") {
122
- this.root.classList.add("needs-https");
123
- }
124
- if (this.shadowRoot) {
125
- this.shadowRoot.appendChild(template.content.cloneNode(true));
126
- this.shadowRoot.appendChild(this.root);
127
- }
128
- }
36
+ get sendToQuestButton() { return this._sendToQuestButton; }
37
+ private _sendToQuestButton?: HTMLButtonElement;
129
38
 
130
- private get isSecureConnection() { return window.location.protocol === "https:"; }
39
+ get qrButton() { return this._qrButton; }
40
+ private _qrButton?: HTMLButtonElement;
131
41
 
132
- /** @returns the quicklook button if it was created */
133
- get quicklookButton() { return this.shadowRoot?.querySelector("[data-needle='quicklook-button']") as HTMLButtonElement | null; }
42
+
134
43
  /** get or create the quicklook button
135
44
  * Behaviour of the button:
136
45
  * - if the button is clicked a USDZExporter component will be searched for in the scene and if found, it will be used to export the scene to USDZ / Quicklook
137
46
  */
138
47
  createQuicklookButton(): HTMLButtonElement {
139
- const existingButton = this.shadowRoot?.querySelector("[data-needle='quicklook-button']") as HTMLButtonElement | null;
140
- if (existingButton) return existingButton;
48
+ if (this._quicklookButton) return this._quicklookButton;
49
+
141
50
  const button = document.createElement("button");
51
+ this._quicklookButton = button;
52
+
142
53
  button.dataset["needle"] = "quicklook-button";
143
54
  button.innerText = "Open in Quicklook";
144
55
  button.addEventListener("click", () => {
145
56
  const usdzExporter = GameObject.findObjectOfType(USDZExporter);
146
57
  if (usdzExporter) {
147
- usdzExporter.exportAsync();
58
+ button.classList.add("this-mode-is-requested");
59
+ usdzExporter.exportAsync().then(() => {
60
+ button.classList.remove("this-mode-is-requested");
61
+ }).catch(err => {
62
+ button.classList.remove("this-mode-is-requested");
63
+ console.error(err);
64
+ });
148
65
  }
149
66
  else {
150
67
  console.warn("No USDZExporter component found in the scene");
151
68
  }
152
69
  });
153
70
  this.hideElementDuringXRSession(button);
154
- this.root?.appendChild(button);
155
71
  return button;
156
72
  }
157
-
158
- /** @returns the WebXR AR button if it was created */
159
- get arButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-ar-button']") as HTMLButtonElement | null; }
160
73
  /** get or create the WebXR AR button
161
74
  * @param init optional session init options
162
75
  * Behaviour of the button:
@@ -165,10 +78,12 @@
165
78
  * - if the device changes and now supports AR, the button will be visible
166
79
  */
167
80
  createARButton(init?: XRSessionInit): HTMLButtonElement {
168
- const existingButton = this.shadowRoot?.querySelector("[data-needle='webxr-ar-button']") as HTMLButtonElement | null;
169
- if (existingButton) return existingButton;
81
+ if (this._arButton) return this._arButton;
82
+
170
83
  const mode: XRSessionMode = "immersive-ar";
171
84
  const button = document.createElement("button");
85
+ this._arButton = button;
86
+
172
87
  button.classList.add("webxr-button");
173
88
  button.dataset["needle"] = "webxr-ar-button";
174
89
  button.innerText = "Enter AR";
@@ -177,7 +92,6 @@
177
92
  this.updateSessionSupported(button, mode);
178
93
  this.listenToXRSessionState(button, mode);
179
94
  this.hideElementDuringXRSession(button);
180
- this.root?.appendChild(button);
181
95
 
182
96
  if (!this.isSecureConnection) {
183
97
  button.disabled = true;
@@ -190,8 +104,6 @@
190
104
  return button;
191
105
  }
192
106
 
193
- /** @returns the WebXR VR button if it was created */
194
- get vrButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-vr-button']") as HTMLButtonElement | null; }
195
107
  /** get or create the WebXR VR button
196
108
  * @param init optional session init options
197
109
  * Behaviour of the button:
@@ -200,10 +112,11 @@
200
112
  * - if the device changes and now supports VR, the button will be visible
201
113
  */
202
114
  createVRButton(init?: XRSessionInit): HTMLButtonElement {
203
- const hasButton = this.shadowRoot?.querySelector("[data-needle='webxr-vr-button']");
204
- if (hasButton) return hasButton as HTMLButtonElement;
115
+ if (this._vrButton) return this._vrButton;
116
+
205
117
  const mode: XRSessionMode = "immersive-vr";
206
118
  const button = document.createElement("button");
119
+ this._vrButton = button;
207
120
  button.classList.add("webxr-button");
208
121
  button.dataset["needle"] = "webxr-vr-button";
209
122
  button.innerText = "Enter VR";
@@ -212,7 +125,6 @@
212
125
  this.updateSessionSupported(button, mode);
213
126
  this.listenToXRSessionState(button, mode);
214
127
  this.hideElementDuringXRSession(button);
215
- this.root?.appendChild(button);
216
128
 
217
129
  if (!this.isSecureConnection) {
218
130
  button.disabled = true;
@@ -225,17 +137,15 @@
225
137
  return button;
226
138
  }
227
139
 
228
- /** @returns the Send to Quest button */
229
- get sendToQuestButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-sendtoquest-button']") as HTMLButtonElement | null; }
230
140
  /** get or create the Send To Quest button
231
141
  * Behaviour of the button:
232
142
  * - if the button is clicked, the current URL will be sent to the Oculus Browser on the Quest
233
143
  */
234
144
  createSendToQuestButton(): HTMLButtonElement {
235
- const hasButton = this.shadowRoot?.querySelector("[data-needle='webxr-sendtoquest-button']");
236
- if (hasButton) return hasButton as HTMLButtonElement;
145
+ if (this._sendToQuestButton) return this._sendToQuestButton;
237
146
  const baseUrl = `https://oculus.com/open_url/?url=`
238
147
  const button = document.createElement("button");
148
+ this._sendToQuestButton = button;
239
149
  button.dataset["needle"] = "webxr-sendtoquest-button";
240
150
  button.innerText = "Open on Quest";
241
151
  button.title = "Click to send this page to the Oculus Browser on your Quest";
@@ -256,31 +166,72 @@
256
166
  }
257
167
  });
258
168
  }
259
- this.root?.appendChild(button);
260
169
  return button;
261
170
  }
262
171
 
263
- async createQRCode() {
264
- const wrapper = document.createElement("div");
265
- wrapper.style.position = "relative";
266
- wrapper.style.display = "inline-block";
267
- this.hideElementDuringXRSession(wrapper);
172
+ createQRCode(): HTMLButtonElement {
173
+ if (this._qrButton) return this._qrButton;
268
174
 
269
- const qrCodeContainer = document.createElement("div");
270
- qrCodeContainer.classList.add("qr-code-container");
271
- qrCodeContainer.classList.add("hidden");
272
- generateAndInsertQRCode();
273
-
274
175
  const qrCodeButton = document.createElement("button");
176
+ this._qrButton = qrCodeButton;
275
177
  qrCodeButton.innerText = "QR Code";
276
178
  qrCodeButton.title = "Scan this QR code with your phone to open this page";
179
+ this.hideElementDuringXRSession(qrCodeButton);
277
180
 
181
+
182
+ const qrCodeContainer = document.createElement("div");
183
+ qrCodeContainer.style.position = "fixed";
184
+ qrCodeContainer.style.display = "inline-block";
185
+ qrCodeContainer.style.padding = "1rem";
186
+ qrCodeContainer.style.backgroundColor = "white";
187
+ qrCodeContainer.style.borderRadius = "0.4rem";
188
+ qrCodeContainer.style.cursor = "pointer";
189
+ qrCodeContainer.style.zIndex = "1000";
190
+ qrCodeContainer.style.boxShadow = "0 0 12px rgba(0, 0, 0, 0.2)";
191
+
192
+ const qrCodeElement = document.createElement("div");
193
+ qrCodeElement.classList.add("qr-code-container");
194
+ qrCodeContainer.appendChild(qrCodeElement);
195
+
278
196
  qrCodeButton.addEventListener("click", () => {
279
- qrCodeContainer.classList.toggle("hidden");
280
- if (qrCodeContainer.classList.contains("hidden")) return;
281
- // generate the qr code when the button is clicked - this ensures that we get the QRcode with the latest URL
282
- generateAndInsertQRCode();
197
+ if (qrCodeContainer.parentNode) return hideQRCode();
198
+ showQRCode();
283
199
  });
200
+
201
+ /** shows the QRCode near the button */
202
+ async function showQRCode() {
203
+ // generate the qr code when the button is clicked
204
+ // this ensures that we get the QRcode with the latest URL
205
+ await generateAndInsertQRCode();
206
+ // TODO: make sure it doesnt overflow the screen
207
+ // we need to add the qrCodeContainer to the body to get the correct size
208
+ document.body.appendChild(qrCodeContainer);
209
+ const containerRect = qrCodeElement.getBoundingClientRect();
210
+ const buttonRect = qrCodeButton.getBoundingClientRect();
211
+ qrCodeContainer.style.left = (buttonRect.left + buttonRect.width * .5 - containerRect.width * .5) + "px";
212
+ const isButtonInTopHalf = buttonRect.top < containerRect.height;
213
+ if (isButtonInTopHalf)
214
+ qrCodeContainer.style.top = `calc(${buttonRect.bottom}px + ${qrCodeContainer.style.padding} * .6)`;
215
+ else
216
+ qrCodeContainer.style.top = `calc(${buttonRect.top - containerRect.height}px - ${qrCodeContainer.style.padding} * 2.5)`;
217
+
218
+ // context click to hide the QR code again, if we dont timeout the event will be triggered immediately
219
+ setTimeout(() => window.addEventListener("click", hideQRCode, { once: true }));
220
+ window.addEventListener("resize", hideQRCode);
221
+ window.addEventListener("scroll", hideQRCode);
222
+
223
+ document.body.appendChild(qrCodeContainer);
224
+ }
225
+
226
+ /** hides to QRCode overlay and unsubscribes from events */
227
+ function hideQRCode() {
228
+ qrCodeContainer.parentNode?.removeChild(qrCodeContainer);
229
+ window.removeEventListener("click", hideQRCode);
230
+ window.removeEventListener("resize", hideQRCode);
231
+ window.removeEventListener("scroll", hideQRCode);
232
+ };
233
+
234
+ /** generates a QR code and inserts it into the qrCodeElement */
284
235
  async function generateAndInsertQRCode() {
285
236
  const size = 200;
286
237
  const code = await generateQRCode({
@@ -288,14 +239,16 @@
288
239
  width: size,
289
240
  height: size,
290
241
  });
291
- qrCodeContainer.innerHTML = "";
292
- qrCodeContainer.appendChild(code);
242
+ qrCodeElement.innerHTML = "";
243
+ qrCodeElement.appendChild(code);
293
244
  }
294
245
 
295
- wrapper.appendChild(qrCodeButton);
296
- wrapper.appendChild(qrCodeContainer);
246
+ // lazily create the qr button
247
+ qrCodeButton.addEventListener("pointerenter", () => {
248
+ generateAndInsertQRCode();
249
+ }, { once: true });
297
250
 
298
- this.root?.appendChild(wrapper);
251
+ return qrCodeButton;
299
252
  }
300
253
 
301
254
  private updateSessionSupported(button: HTMLButtonElement, mode: XRSessionMode) {
@@ -346,5 +299,6 @@
346
299
  }
347
300
  }
348
301
 
349
- if (!customElements.get(webXRElementName))
350
- customElements.define(webXRElementName, NeedleWebXRHtmlElement);
302
+
303
+ /** @deprecated please use WebXRButtonFactory. This type will be removed in a future update */
304
+ export type NeedleWebXRHtmlElement = WebXRButtonFactory;
src/engine/webcomponents/index.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { NeedleMenu } from "./needle-menu.js";
src/engine/webcomponents/license-banner.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { NeedleLogoElement } from "./logo-element.js";
2
+
3
+ const elementName = "needle-license-banner";
4
+
5
+ export class LicenseBanner extends HTMLElement {
6
+
7
+ static create() {
8
+ return document.createElement(elementName);
9
+ }
10
+
11
+ constructor() {
12
+
13
+ super();
14
+ this.attachShadow({ mode: 'open' });
15
+ const template = document.createElement('template');
16
+ template.innerHTML = `<style>
17
+ :host {
18
+ position: relative;
19
+ width: fit-content;
20
+ height: fit-content;
21
+ min-width: 100px;
22
+ min-height: 30px;
23
+ background: white;
24
+ border-radius: .3rem;
25
+ display: flex;
26
+ flex-direction: column;
27
+ justify-content: center;
28
+ align-items: start;
29
+ padding: .2rem 1rem .25rem .5rem;
30
+ box-shadow: 0 0 1rem 0 rgba(0, 0, 0, .1);
31
+ }
32
+ `;
33
+ const content = template.content.cloneNode(true) as HTMLElement;
34
+ content.title = "Made with Needle Engine";
35
+
36
+ this.shadowRoot?.appendChild(content);
37
+
38
+ const logo = NeedleLogoElement.create();
39
+ this.shadowRoot?.appendChild(logo);
40
+
41
+ this.addEventListener("click", () => {
42
+ globalThis.open("https://needle.tools", "_blank");
43
+ });
44
+ }
45
+
46
+ }
47
+ if (!customElements.get(elementName))
48
+ customElements.define(elementName, LicenseBanner);
src/engine/webcomponents/logo-element.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { madeWithNeedleSVG, needleLogoOnlySVG, needleLogoSVG } from "../assets/index.js";
2
+
3
+ const elementName = "needle-logo-element";
4
+
5
+ export class NeedleLogoElement extends HTMLElement {
6
+
7
+ static get elementName() { return elementName; }
8
+
9
+ static create(): NeedleLogoElement {
10
+ return document.createElement(elementName) as NeedleLogoElement;
11
+ }
12
+
13
+ constructor() {
14
+ super();
15
+ this._root = this.attachShadow({ mode: 'closed' });
16
+ const template = document.createElement('template');
17
+ template.innerHTML = `<style>
18
+ :host {
19
+ position: relative;
20
+ min-width: fit-content;
21
+ /* height: 100%; can not have height 100% because of align-items: stretch; in the parent */
22
+ display: flex;
23
+ margin: 0 0.3rem;
24
+ }
25
+
26
+ .wrapper {
27
+ position: relative;
28
+ display: grid;
29
+ grid-template-columns: auto auto;
30
+ padding: .1rem;
31
+ }
32
+ .wrapper:hover {
33
+ cursor: pointer;
34
+ }
35
+ img {
36
+ width: 95px;
37
+ height: 100%;
38
+ align-self: end;
39
+ }
40
+ span {
41
+ font-size: 1rem;
42
+ white-space: nowrap;
43
+ }
44
+ </style>
45
+ <div class="wrapper">
46
+ <img class="logo" src=${needleLogoSVG} />
47
+ </div>
48
+ `;
49
+ this._root.appendChild(template.content.cloneNode(true));
50
+ this.wrapper = this._root.querySelector(".wrapper") as HTMLDivElement;
51
+ this._root.appendChild(this.wrapper);
52
+ // this.wrapper.classList.add("wrapper");
53
+
54
+ // this.wrapper.appendChild(this.logoElement);
55
+ // this.logoElement.src = logoSVG;
56
+
57
+ // this.textElement.textContent = "Needle Engine";
58
+ // this.wrapper.appendChild(this.textElement);
59
+
60
+ this.addEventListener("click", () => {
61
+ globalThis.open("https://needle.tools", "_blank");
62
+ });
63
+
64
+ // set title
65
+ this.wrapper.setAttribute("title", "Made with Needle Engine");
66
+ }
67
+
68
+ private readonly _root: ShadowRoot;
69
+ private readonly wrapper: HTMLDivElement;
70
+ private readonly logoElement: HTMLImageElement = document.createElement("img");
71
+ private readonly textElement: HTMLSpanElement = document.createElement("span");
72
+
73
+ setLogoVisible(val: boolean) {
74
+ this.logoElement.style.display = val ? "block" : "none";
75
+ }
76
+
77
+ }
78
+ if (!customElements.get(elementName))
79
+ customElements.define(elementName, NeedleLogoElement);
src/engine/webcomponents/needle-menu.ts ADDED
@@ -0,0 +1,470 @@
1
+ import { isDevEnvironment } from "../debug/index.js";
2
+ import type { Context } from "../engine_context.js";
3
+ import { hasProLicense, onLicenseCheckResultChanged } from "../engine_license.js";
4
+ import { getParam } from "../engine_utils.js";
5
+ import { NeedleLogoElement } from "./logo-element.js";
6
+
7
+ const elementName = "needle-menu";
8
+ const debug = getParam("debugmenu");
9
+
10
+ declare type MenuButtonOption = {
11
+ text: string,
12
+ onClick: () => void,
13
+ }
14
+
15
+ export class NeedleMenu {
16
+ private readonly _context: Context;
17
+ private readonly _menu: NeedleMenuElement;
18
+
19
+ constructor(context: Context) {
20
+ this._menu = NeedleMenuElement.getOrCreate(context.domElement);
21
+ this._context = context;
22
+ }
23
+
24
+ /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent
25
+ * @param position "top" or "bottom"
26
+ */
27
+ setPosition(position: "top" | "bottom") {
28
+ this._menu.setPosition(position);
29
+ }
30
+
31
+ /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a needle engine license */
32
+ showNeedleLogo(visible: boolean) {
33
+ this._menu.showNeedleLogo(visible);
34
+ }
35
+
36
+ appendChild(child: HTMLElement) {
37
+ this._menu.appendChild(child);
38
+ }
39
+
40
+ }
41
+
42
+ export class NeedleMenuElement extends HTMLElement {
43
+
44
+ static create() {
45
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement#is
46
+ return document.createElement(elementName, { is: elementName });
47
+ }
48
+
49
+ static getOrCreate(domElement: HTMLElement) {
50
+ let element = domElement.querySelector(elementName) as NeedleMenuElement | null;
51
+ if (!element && domElement.shadowRoot) {
52
+ element = domElement.shadowRoot.querySelector(elementName);
53
+ }
54
+ if (!element) {
55
+ element = NeedleMenuElement.create() as NeedleMenuElement;
56
+ element._domElement = domElement;
57
+ if (domElement.shadowRoot)
58
+ domElement.shadowRoot.appendChild(element);
59
+ else
60
+ domElement.appendChild(element);
61
+ }
62
+ return element as NeedleMenuElement;
63
+ }
64
+
65
+ private _domElement: HTMLElement | null = null;
66
+
67
+ constructor() {
68
+ super();
69
+ const template = document.createElement('template');
70
+ // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space
71
+ template.innerHTML = `<style>
72
+
73
+ #root {
74
+ position: absolute;
75
+ width: auto;
76
+ max-width: 95%;
77
+ left: 50%;
78
+ transform: translateX(-50%);
79
+ top: 20px;
80
+ padding: 0.3rem;
81
+ background: #ffffff5c;
82
+ display: flex;
83
+ flex-direction: row-reverse; /* if we overflow this should be right aligned so the logo is always visible */
84
+ outline: rgb(0 0 0 / 5%) 1px solid;
85
+ border: 1px solid rgba(255, 255, 255, .1);
86
+ border-radius: 1.1999999999999993rem;
87
+ overflow: clip;
88
+ box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05);
89
+ backdrop-filter: blur(16px);
90
+ }
91
+
92
+ /** using a div here because then we can change the class for placement **/
93
+ #root.bottom {
94
+ top: auto;
95
+ bottom: 30px;
96
+ }
97
+
98
+ .wrapper {
99
+ position: relative;
100
+ display: flex;
101
+ flex-direction: row;
102
+ justify-content: center;
103
+ align-items: stretch;
104
+ gap: 0px;
105
+ padding: 0 .3rem;
106
+ }
107
+
108
+ .wrapper > *, .options > * {
109
+ position: relative;
110
+ border: none;
111
+ border-radius: 0;
112
+ outline: 1px solid rgba(0,0,0,0);
113
+ display: flex;
114
+ justify-content: center;
115
+ align-items: center;
116
+
117
+ /** basic font settings for all entries **/
118
+ font-size: 1rem;
119
+ font-family: 'Roboto Flex', sans-serif;
120
+ font-optical-sizing: auto;
121
+ font-weight: normal;
122
+ font-variation-settings: "wdth" 100;
123
+ color: rgb(40,40,40);
124
+ }
125
+
126
+ .options > * {
127
+ padding: .4rem .5rem;
128
+ }
129
+
130
+ :host .options > * {
131
+ background: transparent;
132
+ border: none;
133
+ white-space: nowrap;
134
+ transition: all 0.1s linear .02s;
135
+ border-radius: 0.8rem;
136
+ }
137
+ :host .options > *:hover {
138
+ cursor: pointer;
139
+ color: black;
140
+ background: rgba(245, 245, 245, .8);
141
+ outline: rgba(0,0,0,.05) 1px solid;
142
+ }
143
+
144
+
145
+ /** XR button animation **/
146
+ :host button.this-mode-is-requested {
147
+ background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
148
+ background-size: 200% auto;
149
+ background-position: 0 100%;
150
+ animation: AnimationName .7s ease infinite forwards;
151
+ }
152
+ :host button.other-mode-is-requested {
153
+ opacity: .5;
154
+ }
155
+
156
+ @keyframes AnimationName {
157
+ 0% { background-position: 0% 0 }
158
+ 100% { background-position: -200% 0 }
159
+ }
160
+
161
+
162
+
163
+
164
+ .logo {
165
+ cursor: pointer;
166
+ padding-left: 0.6rem;
167
+ }
168
+ :host .logo.any-options {
169
+ border-left: 1px solid rgba(40,40,40,.4);
170
+ margin-left: 0.3rem;
171
+ }
172
+
173
+ .logo > span {
174
+ white-space: nowrap;
175
+ }
176
+
177
+
178
+
179
+ /** COMPACT */
180
+ .compact .wrapper, .compact .options {
181
+ height: auto;
182
+ max-height: initial;
183
+ flex-direction: column-reverse;
184
+ }
185
+
186
+ .compact .options > * {
187
+ font-size: 1.2rem;
188
+ padding: .6rem .5rem;
189
+ }
190
+ .compact .top .options {
191
+ height: auto;
192
+ flex-direction: column-reverse;
193
+ }
194
+ .compact .bottom .wrapper {
195
+ height: auto;
196
+ flex-direction: column;
197
+ }
198
+ .compact .logo {
199
+ padding-left: 0;
200
+ margin-left: 0.3rem;
201
+ }
202
+ .compact.bottom .logo.any-options {
203
+ border: none;
204
+ border-bottom: 1px solid rgba(40,40,40,.4);
205
+ padding-bottom: .4rem;
206
+ margin-bottom: .5rem;
207
+ }
208
+ .compact.top .logo.any-options {
209
+ border: none;
210
+ border-top: 1px solid rgba(40,40,40,.4);
211
+ padding-top: .4rem;
212
+ margin-top: .5rem;
213
+ }
214
+ .compact .options > button {
215
+ width: 100%;
216
+ }
217
+
218
+
219
+
220
+ /* dark mode */
221
+ /*
222
+ @media (prefers-color-scheme: dark) {
223
+ :host {
224
+ background: rgba(0,0,0, .6);
225
+ }
226
+ :host button {
227
+ color: rgba(200,200,200);
228
+ }
229
+ :host button:hover {
230
+ background: rgba(100,100,100, .8);
231
+ }
232
+ }
233
+ */
234
+
235
+
236
+
237
+ </style>
238
+
239
+ <div id="root" class="bottom">
240
+ <div class="wrapper">
241
+ <div class="options"></div>
242
+ <div class="logo">
243
+ <span class="madewith">powered by</span>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ `;
248
+
249
+ // we dont need to expose the shadow root
250
+ const shadow = this.attachShadow({ mode: 'closed' });
251
+ const content = template.content.cloneNode(true) as DocumentFragment;
252
+ shadow?.appendChild(content);
253
+ this.root = shadow.querySelector("#root") as HTMLDivElement;
254
+
255
+ this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement;
256
+ this.options = this.root?.querySelector(".options") as HTMLDivElement;
257
+ this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement;
258
+
259
+ this.root?.appendChild(this.wrapper);
260
+ this.wrapper.classList.add("wrapper");
261
+
262
+ const logo = NeedleLogoElement.create();
263
+ logo.style.minHeight = "1rem";
264
+ this.logoContainer.append(logo);
265
+ this.logoContainer.addEventListener("click", () => {
266
+ globalThis.open("https://needle.tools", "_blank");
267
+ });
268
+
269
+ // if the user has a license then we CAN hide the needle logo
270
+ onLicenseCheckResultChanged(res => {
271
+ if (res == true && hasProLicense()) {
272
+ this.logoContainer.style.display = this._userRequestedLogoVisible ? "" : "none";
273
+ }
274
+ });
275
+
276
+
277
+ // watch changes
278
+ const observer = new MutationObserver(mutations => {
279
+ for (const mutation of mutations) {
280
+ if (mutation.type === 'childList') {
281
+ this.onOptionsChildrenChanged(mutation);
282
+ }
283
+ }
284
+ this.onChangeDetected(mutations);
285
+ });
286
+ observer.observe(this.root, { childList: true, subtree: true, attributes: true });
287
+
288
+
289
+
290
+ if (debug) {
291
+ this.___insertDebugOptions();
292
+ }
293
+ }
294
+
295
+ connectedCallback() {
296
+ window.addEventListener("resize", this.handleSizeChange);
297
+ this._domElement?.addEventListener("resize", this.handleSizeChange);
298
+ this.handleMenuVisible();
299
+ }
300
+ disconnectedCallback() {
301
+ window.removeEventListener("resize", this.handleSizeChange);
302
+ this._domElement?.removeEventListener("resize", this.handleSizeChange);
303
+ }
304
+
305
+ private _userRequestedLogoVisible?: boolean = undefined;
306
+ showNeedleLogo(visible: boolean) {
307
+ this._userRequestedLogoVisible = visible;
308
+ if (!hasProLicense()) {
309
+ if (isDevEnvironment()) console.warn("Needle Menu: You need a PRO license to hide the Needle Engine logo.");
310
+ return;
311
+ }
312
+ this.logoContainer.style.display = visible ? "" : "none";
313
+ }
314
+
315
+ setPosition(position: "top" | "bottom") {
316
+ // ensure the position is of a known type:
317
+ if (position !== "top" && position !== "bottom") {
318
+ return console.error("NeedleMenu.setPosition: invalid position", position);
319
+ }
320
+ this.root.classList.remove("top", "bottom");
321
+ this.root.classList.add(position);
322
+ }
323
+
324
+ // private _root: ShadowRoot | null = null;
325
+ private readonly root: HTMLDivElement;
326
+ /** wraps the whole content */
327
+ private readonly wrapper: HTMLDivElement;
328
+ /** contains the buttons and dynamic elements */
329
+ private readonly options: HTMLDivElement;
330
+ /** contains the needle-logo html element */
331
+ private readonly logoContainer: HTMLDivElement;
332
+
333
+ append(...nodes: (string | Node)[]): void {
334
+ for (const node of nodes) {
335
+ if (typeof node === "string") {
336
+ const element = document.createTextNode(node);
337
+ this.options.appendChild(element);
338
+ } else {
339
+ this.options.appendChild(node);
340
+ }
341
+ }
342
+ }
343
+ appendChild<T extends Node>(node: T): T {
344
+ return this.options.appendChild(node);
345
+ }
346
+ prepend(...nodes: (string | Node)[]): void {
347
+ for (const node of nodes) {
348
+ if (typeof node === "string") {
349
+ const element = document.createTextNode(node);
350
+ this.options.prepend(element);
351
+ } else {
352
+ this.options.prepend(node);
353
+ }
354
+ }
355
+ }
356
+
357
+
358
+
359
+ /** Called when any change in the web component is detected (including in children and child attributes) */
360
+ private onChangeDetected(_mut: MutationRecord[]) {
361
+ // if (debug) console.log("NeedleMenu.onChangeDetected", _mut);
362
+ this.handleMenuVisible();
363
+ }
364
+
365
+ private onOptionsChildrenChanged(_mut: MutationRecord) {
366
+ let anyVisibleOptions = false;
367
+ for (let i = 0; i < this.options.children.length; i++) {
368
+ const child = this.options.children[i] as HTMLElement;
369
+ if (child.style.display != "none") {
370
+ anyVisibleOptions = true;
371
+ break;
372
+ }
373
+ }
374
+ this.logoContainer.classList.toggle("any-options", anyVisibleOptions);
375
+ this.handleSizeChange();
376
+ }
377
+
378
+
379
+
380
+
381
+ /** checks if the menu has any content and should be rendered at all
382
+ * if we dont have any content and logo then we hide the menu
383
+ */
384
+ private handleMenuVisible() {
385
+ if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent);
386
+ if (this.hasAnyContent) {
387
+ this.root.style.display = "";
388
+ } else {
389
+ this.root.style.display = "none";
390
+ }
391
+ }
392
+
393
+ /** @returns true if we have any content OR a logo */
394
+ get hasAnyContent() {
395
+ return this.options.children.length > 0 || this.logoContainer.style.display != "none";
396
+ }
397
+
398
+
399
+ private _lastAvailableWidthChange = 0;
400
+ private _timeoutHandle: number = 0;
401
+
402
+ private handleSizeChange = () => {
403
+ if (!this._domElement) return;
404
+
405
+ const width = this._domElement.clientWidth;
406
+ if (width < 500) {
407
+ this.root.classList.add("compact");
408
+ return;
409
+ }
410
+
411
+ const padding = 50;
412
+ const availableWidth = width - padding * 2;
413
+
414
+ // if the available width has not changed significantly then we can skip the rest
415
+ if (Math.abs(availableWidth - this._lastAvailableWidthChange) < 5) return;
416
+ this._lastAvailableWidthChange = availableWidth;
417
+
418
+ clearTimeout(this._timeoutHandle!);
419
+
420
+ this._timeoutHandle = setTimeout(() => {
421
+ const currentWidth = this.root.clientWidth;
422
+ const spaceLeft = availableWidth - currentWidth;
423
+ if (spaceLeft <= 0) {
424
+ this.root.classList.add("compact")
425
+ }
426
+ else if (spaceLeft > 5) {
427
+ this.root.classList.remove("compact")
428
+ }
429
+ }, 200) as unknown as number;
430
+
431
+ }
432
+
433
+
434
+
435
+ private ___insertDebugOptions() {
436
+ window.addEventListener("keydown", (e) => {
437
+ if (e.key === "p") {
438
+ this.setPosition(this.root.classList.contains("top") ? "bottom" : "top");
439
+ }
440
+ });
441
+ const removeOptionsButton = document.createElement("button");
442
+ removeOptionsButton.textContent = "Hide Buttons";
443
+ removeOptionsButton.onclick = () => {
444
+ const optionsChildren = new Array(this.options.children.length);
445
+ for (let i = 0; i < this.options.children.length; i++) {
446
+ optionsChildren[i] = this.options.children[i];
447
+ }
448
+ for (const child of optionsChildren) {
449
+ this.options.removeChild(child);
450
+ }
451
+ setTimeout(() => {
452
+ for (const child of optionsChildren) {
453
+ this.options.appendChild(child);
454
+ }
455
+
456
+ }, 1000)
457
+ };
458
+ this.appendChild(removeOptionsButton);
459
+ const anotherButton = document.createElement("button");
460
+ anotherButton.textContent = "Toggle Logo";
461
+ anotherButton.addEventListener("click", () => {
462
+ this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none";
463
+ });
464
+ this.appendChild(anotherButton);
465
+ }
466
+ }
467
+
468
+
469
+ if (!customElements.get(elementName))
470
+ customElements.define(elementName, NeedleMenuElement);
src/engine-components/NeedleMenu.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { serializable } from '../engine/engine_serialization.js';
2
+ import { Behaviour } from './Component.js';
3
+
4
+ /** Exposes options to editors to customize the Needle menu. */
5
+ export class NeedleMenu extends Behaviour {
6
+
7
+ @serializable()
8
+ position: "top" | "bottom" = "top";
9
+
10
+ /** Show the Needle logo in the menu (requires PRO license) */
11
+ @serializable()
12
+ showNeedleLogo: boolean = true;
13
+
14
+ onEnable() {
15
+ this.context.menu.setPosition(this.position);
16
+ this.context.menu.showNeedleLogo(this.showNeedleLogo);
17
+ }
18
+
19
+ }