Needle Engine

Changes between version 3.37.16-alpha and 3.38.0-alpha
Files changed (8) hide show
  1. plugins/common/license.cjs +11 -1
  2. plugins/vite/index.js +8 -0
  3. src/engine-components/OrbitControls.ts +59 -16
  4. src/engine-components/export/usdz/ThreeUSDZExporter.ts +6 -1
  5. src/engine-components/export/usdz/USDZExporter.ts +17 -18
  6. plugins/types/userconfig.d.ts +6 -0
  7. src/engine-components/webxr/WebXRImageTracking.ts +16 -9
  8. plugins/vite/server.js +65 -0
plugins/common/license.cjs CHANGED
@@ -58,7 +58,7 @@
58
58
  return null;
59
59
  }
60
60
 
61
- console.log("INFO: Resolve license for " + license.id + "::" + license.key);
61
+ console.log("INFO: Resolve license for " + obscure(license.id + "::" + license.key));
62
62
  const url = await fetch(LICENSE_ENDPOINT, { method: "GET" });
63
63
  if (!url.ok) {
64
64
  console.warn("WARN: Failed to fetch license URL from endpoint. " + url.statusText);
@@ -84,4 +84,14 @@
84
84
  }
85
85
  console.warn("WARN: Received invalid license.");
86
86
  return null;
87
+ }
88
+
89
+
90
+ /**
91
+ * @param {string} str
92
+ */
93
+ function obscure(str) {
94
+ const start = str.substring(0, 3);
95
+ const end = str.substring(str.length - 3);
96
+ return start + "***" + end;
87
97
  }
plugins/vite/index.js CHANGED
@@ -50,10 +50,17 @@
50
50
  export { needleFacebookInstantGames } from "./facebook-instant-games.js";
51
51
 
52
52
  import { vite_4_4_hack } from "./vite-4.4-hack.js";
53
+
53
54
  import { needleImportsLogger } from "./imports-logger.js";
55
+ export { needleImportsLogger } from "./imports-logger.js";
56
+
54
57
  import { needleBuildInfo } from "./buildinfo.js";
58
+ export { needleBuildInfo } from "./buildinfo.js";
55
59
 
60
+ import { needleServer } from "./server.js";
61
+ export { needleServer } from "./server.js";
56
62
 
63
+
57
64
  export * from "./gzip.js";
58
65
  export * from "./config.js";
59
66
 
@@ -112,6 +119,7 @@
112
119
  needleImportsLogger(command, config, userSettings),
113
120
  needleBuildPipeline(command, config, userSettings),
114
121
  needlePWA(command, config, userSettings),
122
+ needleServer(command, config, userSettings),
115
123
  ];
116
124
  array.push(await editorConnection(command, config, userSettings, array));
117
125
  return array;
src/engine-components/OrbitControls.ts CHANGED
@@ -1,15 +1,15 @@
1
- import { Box3, Box3Helper, GridHelper, Mesh, Object3D, PerspectiveCamera, Ray, ShadowMaterial, Vector2, Vector3 } from "three";
1
+ import { Box3Helper, Object3D, PerspectiveCamera, Ray, Vector2, Vector3 } from "three";
2
2
  import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
3
- import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
4
3
 
5
- import { setCameraController, useForAutoFit } from "../engine/engine_camera.js";
4
+ import { setCameraController } from "../engine/engine_camera.js";
6
5
  import { Gizmos } from "../engine/engine_gizmos.js";
6
+ import { NEPointerEvent } from "../engine/engine_input.js";
7
7
  import { Mathf } from "../engine/engine_math.js";
8
8
  import { RaycastOptions } from "../engine/engine_physics.js";
9
9
  import { serializable } from "../engine/engine_serialization_decorator.js";
10
10
  import { getBoundingBox, getWorldDirection, getWorldPosition, getWorldRotation, setWorldRotation } from "../engine/engine_three_utils.js";
11
11
  import type { ICameraController } from "../engine/engine_types.js";
12
- import { delayForFrames, getParam, isMobileDevice } from "../engine/engine_utils.js";
12
+ import { getParam, isMobileDevice } from "../engine/engine_utils.js";
13
13
  import { Camera } from "./Camera.js";
14
14
  import { Behaviour, GameObject } from "./Component.js";
15
15
  import { LookAtConstraint } from "./LookAtConstraint.js";
@@ -168,29 +168,41 @@
168
168
  */
169
169
  @serializable()
170
170
  enablePan: boolean = true;
171
- /** Assigning a {@link LookAtConstraint} will make the camera look at the constraint source */
171
+ /** Assigning a {@link LookAtConstraint} will make the camera look at the constraint source
172
+ * @default null
173
+ */
172
174
  @serializable(LookAtConstraint)
173
175
  lookAtConstraint: LookAtConstraint | null = null;
174
- /** The weight of the first lookAtConstraint source */
176
+ /** The weight of the first lookAtConstraint source
177
+ * @default 1
178
+ */
175
179
  @serializable()
176
180
  lookAtConstraint01: number = 1;
177
181
 
178
- /** If true user input interrupts the camera from animating to a target */
182
+ /** If true user input interrupts the camera from animating to a target
183
+ * @default true
184
+ */
179
185
  @serializable()
180
186
  allowInterrupt: boolean = true;
181
187
  /** If true the camera will focus on the target when the middle mouse button is clicked */
182
188
  @serializable()
183
189
  middleClickToFocus: boolean = true;
184
- /** If true the camera will focus on the target when the left mouse button is double clicked */
190
+ /** If true the camera will focus on the target when the left mouse button is double clicked
191
+ * @default true
192
+ */
185
193
  @serializable()
186
194
  doubleClickToFocus: boolean = true;
187
- // @serializable()
188
- // zoomToCursor: boolean = false;
195
+ /**
196
+ * When enabled the camera will fit the scene to the camera view when the background is clicked the specified number of times within a short time
197
+ * @default 2
198
+ */
199
+ @serializable()
200
+ clickBackgroundToFitScene: number = 2;
189
201
 
190
- // remove once slerp works correctly
191
- useSlerp: boolean = true;
192
-
193
- /** @internal If true debug information will be logged to the console */
202
+ /**
203
+ * @internal If true debug information will be logged to the console
204
+ * @default false
205
+ */
194
206
  debugLog: boolean = false;
195
207
 
196
208
  /**
@@ -200,7 +212,9 @@
200
212
  get targetLerpSpeed() { return 5 }
201
213
  set targetLerpSpeed(v) { this.targetLerpDuration = 1 / v; }
202
214
 
203
- /** The duration in seconds it takes for the camera look ad and position lerp to reach their destination (when set via `setCameraTargetPosition` and `setLookTargetPosition`) */
215
+ /** The duration in seconds it takes for the camera look ad and position lerp to reach their destination (when set via `setCameraTargetPosition` and `setLookTargetPosition`)
216
+ * @default 1
217
+ */
204
218
  @serializable()
205
219
  targetLerpDuration = 1;
206
220
 
@@ -315,6 +329,7 @@
315
329
  // if (this._didStart) {
316
330
  // if (this.autoFit) this.fitCamera()
317
331
  // }
332
+ this.context.input.addEventListener("pointerup", this._onPointerDown);
318
333
  }
319
334
 
320
335
  /** @internal */
@@ -328,8 +343,37 @@
328
343
  this._controls.removeEventListener("start", this.onControlsChangeStarted);
329
344
  // this._controls.reset();
330
345
  }
346
+ this.context.input.removeEventListener("pointerup", this._onPointerDown);
331
347
  }
332
348
 
349
+ private _lastTimeClickOnBackground: number = -1;
350
+ private _clickOnBackgroundCount: number = 0;
351
+ private _onPointerDown = (evt: NEPointerEvent) => {
352
+
353
+ if (this.clickBackgroundToFitScene > 0 && evt.isClick) {
354
+
355
+ // it's possible that we didnt raycast in this frame
356
+ if (!evt.hasRay) {
357
+ evt.intersections.push(...this.context.physics.raycast());
358
+ }
359
+
360
+ if (evt.intersections.length <= 0) {
361
+ const dt = this.context.time.time - this._lastTimeClickOnBackground;
362
+ this._lastTimeClickOnBackground = this.context.time.time;
363
+ if (this.clickBackgroundToFitScene <= 1 || dt < this.clickBackgroundToFitScene * .15) {
364
+ this._clickOnBackgroundCount += 1;
365
+ if (this._clickOnBackgroundCount >= this.clickBackgroundToFitScene - 1)
366
+ this.fitCamera(this.context.scene.children, undefined, false);
367
+ }
368
+ else {
369
+ this._clickOnBackgroundCount = 0;
370
+ }
371
+ }
372
+
373
+ if (debug) console.log(this.clickBackgroundToFitScene, evt.intersections.length, this._clickOnBackgroundCount)
374
+ }
375
+ };
376
+
333
377
  private onControlsChangeStarted = () => {
334
378
  if (this._syncedTransform) {
335
379
  this._syncedTransform.requestOwnership();
@@ -360,7 +404,6 @@
360
404
  }
361
405
  this._controls.enabled = true;
362
406
 
363
-
364
407
  this.__handleSetTargetWhenBecomingActiveTheFirstTime();
365
408
 
366
409
  if (this.context.input.getPointerDown(1) || this.context.input.getPointerDown(2) || this.context.input.mouseWheelChanged || (this.context.input.getPointerPressed(0) && this.context.input.getPointerPositionDelta(0)?.length() || 0 > .1)) {
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -585,7 +585,12 @@
585
585
  }
586
586
 
587
587
  Progress.report('export-usdz', "Traversing hierarchy");
588
- if(scene) traverse( scene, context.document, context, this.keepObject);
588
+ if (scene) traverse( scene, context.document, context, this.keepObject);
589
+
590
+ // Root object should have identity matrix
591
+ // so that root transformations don't end up in the resulting file.
592
+ // if (context.document.children?.length > 0)
593
+ // context.document.children[0]?.matrix.identity(); //.multiply(new Matrix4().makeRotationY(Math.PI));
589
594
 
590
595
  Progress.report('export-usdz', "Invoking onAfterBuildDocument");
591
596
  await invokeAll( context, 'onAfterBuildDocument' );
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NEEDLE_progressive } from "@needle-tools/gltf-progressive";
2
- import { Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three";
2
+ import { Euler, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three";
3
3
 
4
4
  import { showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
5
5
  import { hasProLicense } from "../../../engine/engine_license.js";
@@ -572,24 +572,24 @@
572
572
  }
573
573
 
574
574
  private static invertForwardMatrix = new Matrix4().makeRotationY(Math.PI);
575
+ private static invertForwardQuaternion = new Quaternion().setFromEuler(new Euler(0, Math.PI, 0));
575
576
 
576
577
  private _rootSessionRootWasAppliedTo: Object3D | null = null;
577
578
  private _rootPositionBeforeExport: Vector3 = new Vector3();
578
579
  private _rootRotationBeforeExport: Quaternion = new Quaternion();
579
580
  private _rootScaleBeforeExport: Vector3 = new Vector3();
580
581
 
581
- getARScaleAndTarget(): { scale: number, _invertForward: boolean, target: Object3D } {
582
- if (!this.objectToExport) return { scale: 1, _invertForward: false, target: this.gameObject };
582
+ getARScaleAndTarget(): { scale: number, _invertForward: boolean, target: Object3D, sessionRoot: Object3D | null} {
583
+ if (!this.objectToExport) return { scale: 1, _invertForward: false, target: this.gameObject, sessionRoot: null};
583
584
 
584
585
  let sessionRoot = GameObject.getComponentInParent(this.objectToExport, WebARSessionRoot);
585
- const hasSessionRootInParentHierarchy = sessionRoot !== null && sessionRoot !== undefined;
586
586
  if(!sessionRoot) sessionRoot = GameObject.getComponentInChildren(this.objectToExport, WebARSessionRoot);
587
587
 
588
588
  if (debug) console.log("applyWebARSessionRoot", sessionRoot, sessionRoot?.arScale);
589
589
 
590
590
  let arScale = 1;
591
591
  let _invertForward = false;
592
- const target = hasSessionRootInParentHierarchy || !sessionRoot ? this.objectToExport : sessionRoot.gameObject;
592
+ const target = this.objectToExport;
593
593
 
594
594
  if (!sessionRoot) {
595
595
  const xr = GameObject.findObjectOfType(WebXR);
@@ -601,14 +601,16 @@
601
601
  }
602
602
 
603
603
  const scale = 1 / arScale;
604
-
605
- return { scale, _invertForward, target };
604
+ const result = { scale, _invertForward, target, sessionRoot: sessionRoot?.gameObject ?? null };
605
+ console.log("getARScaleAndTarget", result);
606
+ return result;
606
607
  }
607
608
 
608
609
  private applyWebARSessionRoot() {
609
610
  if (!this.objectToExport) return;
610
611
 
611
- const { scale, _invertForward, target } = this.getARScaleAndTarget();
612
+ const { scale, _invertForward, target, sessionRoot } = this.getARScaleAndTarget();
613
+ const sessionRootMatrixWorld = sessionRoot?.matrixWorld.clone().invert();
612
614
 
613
615
  this._rootSessionRootWasAppliedTo = target;
614
616
  this._rootPositionBeforeExport.copy(target.position);
@@ -616,17 +618,14 @@
616
618
  this._rootScaleBeforeExport.copy(target.scale);
617
619
 
618
620
  target.scale.multiplyScalar(scale);
619
- // legacy, should likely be deleted
620
- if (_invertForward) {
621
- target.matrix.multiply(USDZExporter.invertForwardMatrix);
622
- }
621
+ if (_invertForward)
622
+ target.quaternion.multiply(USDZExporter.invertForwardQuaternion);
623
+
623
624
  // udate childs as well
625
+ target.updateMatrix();
624
626
  target.updateMatrixWorld(true);
625
-
626
- // TODO we should refactor this and use one common method in WebARSessionRoot to place an object –
627
- // basically the inverted effect of WebARSessionRoot.onApplyPose()
628
-
629
- // TODO why are we not reverting this transformation after the export?
627
+ if (sessionRoot && sessionRootMatrixWorld)
628
+ target.matrix.premultiply(sessionRootMatrixWorld);
630
629
  }
631
630
 
632
631
  private revertWebARSessionRoot() {
@@ -639,11 +638,11 @@
639
638
  target.scale.copy(this._rootScaleBeforeExport);
640
639
 
641
640
  // udate childs as well
641
+ target.updateMatrix();
642
642
  target.updateMatrixWorld(true);
643
643
  this._rootSessionRootWasAppliedTo = null;
644
644
  }
645
645
 
646
-
647
646
  private createQuicklookButton() {
648
647
  const buttoncontainer = WebXRButtonFactory.getOrCreate();
649
648
  const button = buttoncontainer.createQuicklookButton();
plugins/types/userconfig.d.ts CHANGED
@@ -62,4 +62,10 @@
62
62
  * Use to activate a needle engine license
63
63
  */
64
64
  license?: License;
65
+
66
+ /**
67
+ * When set to `true` a plugin will automatically attempt to open the browser using a network ip address when the local server has started
68
+ * @default undefined
69
+ */
70
+ openBrowser?: boolean;
65
71
  }
src/engine-components/webxr/WebXRImageTracking.ts CHANGED
@@ -39,7 +39,6 @@
39
39
  return quat;
40
40
  }
41
41
 
42
- private static y180 = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
43
42
  applyToObject(object: Object3D, t01: number | undefined = undefined) {
44
43
  this.ensureTransformData();
45
44
  // check if position/_position or rotation/_rotation changed more than just a little bit
@@ -55,7 +54,6 @@
55
54
  object.quaternion.slerp(this._rotation, t01);
56
55
  // InstancingUtil.markDirty(object);
57
56
  }
58
- object.quaternion.multiply(WebXRTrackedImage.y180);
59
57
  }
60
58
 
61
59
  private static _positionBuffer: CircularBuffer<Vector3> = new CircularBuffer(() => new Vector3(), 20);
@@ -186,20 +184,29 @@
186
184
  const imageTracking = GameObject.findObjectOfType(WebXRImageTracking);
187
185
  if (!imageTracking || !imageTracking.trackedImages) return;
188
186
 
187
+
189
188
  for (const trackedImage of imageTracking.trackedImages) {
190
189
  if (trackedImage.object?.asset === object) {
191
190
  const exporter = GameObject.findObjectOfType(USDZExporter);
192
191
  if (!exporter) continue;
193
192
 
194
- const { scale } = exporter.getARScaleAndTarget();
193
+ const { scale, target } = exporter.getARScaleAndTarget();
195
194
 
196
195
  // We have to reset the image tracking object's position and rotation, because QuickLook applies them.
197
- // On Android WebXR they're replaced by the tracked data.
198
- model.matrix = object.matrixWorld.clone()
196
+ // On Android WebXR they're replaced by the tracked data
197
+ let parent = object;
198
+ const relativeMatrix = new Matrix4();
199
+ if (object !== target) {
200
+ while (parent.parent && parent.parent !== target) {
201
+ parent = parent.parent;
202
+ relativeMatrix.premultiply(parent.matrix);
203
+ }
204
+ }
205
+ const mat = relativeMatrix
206
+ .clone()
199
207
  .invert()
200
- .multiply(new Matrix4().makeRotationY(Math.PI))
208
+ model.matrix = mat
201
209
  // apply session root scale again after undoing the world transformation
202
- // TODO check if we actually do that in WebXR image tracking
203
210
  .scale(new Vector3(scale, scale, scale));
204
211
 
205
212
  // Unfortunately looks like Apple's docs are incomplete:
@@ -207,10 +214,10 @@
207
214
  // In practice, it seems that nesting is not allowed – no image tracking will be applied to nested objects.
208
215
  // Thus, we can't have separate transforms for "regularly placing content" and "placing content with an image marker".
209
216
  // model.extraSchemas.push("Preliminary_AnchoringAPI");
210
- model.addEventListener("serialize", (_writer: USDWriter, _context: USDZExporterContext) => {
217
+ // model.addEventListener("serialize", (_writer: USDWriter, _context: USDZExporterContext) => {
211
218
  // writer.appendLine( `token preliminary:anchoring:type = "image"` );
212
219
  // writer.appendLine( `rel preliminary:imageAnchoring:referenceImage = </${context.document.name}/Scenes/Scene/AnchoringReferenceImage>` );
213
- });
220
+ // });
214
221
 
215
222
  // We can only apply this to the first tracked image, more are not supported by QuickLook.
216
223
  break;
plugins/vite/server.js ADDED
@@ -0,0 +1,65 @@
1
+ import open from 'open'
2
+ import { resolveConfig } from 'vite';
3
+
4
+ /**
5
+ * Can open the network server in the browser
6
+ * @param {string} command
7
+ * @param {import('../types').needleMeta | null} config
8
+ * @param {import('../types').userSettings} userSettings
9
+ */
10
+ export const needleServer = (command, config, userSettings) => {
11
+
12
+ const shouldOpenBrowser = userSettings.openBrowser === true;
13
+
14
+ /**
15
+ * @type {import("vite").UserConfig}
16
+ */
17
+ return {
18
+ name: 'needle:server',
19
+ config(config) {
20
+ // if this plugin is used to open the browser we want to make sure "open" in the vite config is set to false
21
+ if (shouldOpenBrowser) {
22
+ console.log("[needle:server] Setting 'open: false' in vite server config because 'openBrowser' is enabled in needlePlugins")
23
+ return {
24
+ ...config,
25
+ server: {
26
+ ...config.server,
27
+ open: false
28
+ }
29
+ }
30
+ }
31
+ },
32
+ /**
33
+ * @param {import("vite").ViteDevServer} server
34
+ */
35
+ configureServer(server) {
36
+ if (shouldOpenBrowser) {
37
+ console.log('[needle:server] Waiting for server...')
38
+ const i = setInterval(() => {
39
+ // https://github.com/vitejs/vite/blob/e861168f476b8cb278f599a0341076b0511c5264/packages/vite/src/node/preview.ts#L231
40
+ const resolvedUrls = server.resolvedUrls;
41
+ if (resolvedUrls) {
42
+ // stop trying when the urls are resolved
43
+ clearInterval(i);
44
+ // check if the network urls are available and attempt to open the first one
45
+ const networkUrls = resolvedUrls.network;
46
+ if (Array.isArray(networkUrls) && networkUrls.length > 0) {
47
+ const networkUrl = networkUrls[0];
48
+ // perhaps we can just use the vite code here: https://github.com/vitejs/vite/blob/e861168f476b8cb278f599a0341076b0511c5264/packages/vite/src/node/server/openBrowser.ts#L72
49
+ console.log('[needle:server] Opening network URL: ' + networkUrl)
50
+ open(networkUrl);
51
+ }
52
+ else {
53
+ const local = resolvedUrls.local;
54
+ if (local?.length) {
55
+ console.log('[needle:server] Opening local URL: ' + local)
56
+ open(local);
57
+ }
58
+ }
59
+
60
+ }
61
+ }, 100)
62
+ }
63
+ },
64
+ }
65
+ };