Needle Engine

Changes between version 3.41.1-alpha.1 and 3.41.2-beta
Files changed (10) hide show
  1. src/engine-components/DragControls.ts +16 -9
  2. src/engine-components/DropListener.ts +220 -41
  3. src/engine/engine_element.ts +4 -0
  4. src/engine/engine_input.ts +2 -1
  5. src/engine/engine_networking_files_default_components.ts +1 -1
  6. src/engine/engine_physics.ts +4 -2
  7. src/engine/engine_three_utils.ts +90 -0
  8. src/engine-components/OrbitControls.ts +2 -2
  9. src/engine-components/Skybox.ts +51 -26
  10. src/engine-components/SyncedRoom.ts +65 -1
src/engine-components/DragControls.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { RaycastOptions } from "../engine/engine_physics.js";
7
7
  import { serializable } from "../engine/engine_serialization_decorator.js";
8
8
  import { Context } from "../engine/engine_setup.js";
9
- import { getWorldPosition, setWorldPosition } from "../engine/engine_three_utils.js";
9
+ import { getBoundingBox, getWorldPosition, setWorldPosition } from "../engine/engine_three_utils.js";
10
10
  import { type IGameObject } from "../engine/engine_types.js";
11
11
  import { getParam } from "../engine/engine_utils.js";
12
12
  import { NeedleXRSession } from "../engine/engine_xr.js";
@@ -768,7 +768,7 @@
768
768
  }
769
769
 
770
770
  // calculate bounding box and snapping points. We want to either snap the "back" point or the "bottom" point.
771
- const bbox = new Box3();
771
+ // const bbox = new Box3();
772
772
  const p = this.gameObject.parent;
773
773
  const localP = this.gameObject.position.clone();
774
774
  const localQ = this.gameObject.quaternion.clone();
@@ -780,8 +780,13 @@
780
780
  this.gameObject.position.set(0, 0, 0);
781
781
  this.gameObject.quaternion.set(0, 0, 0, 1);
782
782
  this.gameObject.scale.set(1, 1, 1);
783
- bbox.setFromObject(this.gameObject);
784
-
783
+ const bbox = getBoundingBox([this.gameObject]);
784
+ // we force the bbox to include our own point *because* the DragControls might be attached to an empty object (which isnt included in the bounding box call above)
785
+ bbox.expandByPoint(this.gameObject.worldPosition);
786
+
787
+ // console.log(this.gameObject.position.y - bbox.min.y)
788
+ // bbox.min.y += (this.gameObject.position.y - bbox.min.y);
789
+
785
790
  // get front center point of the bbox. basically (0, 0, 1) in local space
786
791
  const bboxCenter = new Vector3();
787
792
  bbox.getCenter(bboxCenter);
@@ -934,9 +939,9 @@
934
939
  // This would allow dragging slightly out of the object's bounds and still continue snapping to it.
935
940
  // Do a regular raycast (without the dragged object) to determine if we should change what is dragged onto.
936
941
  const opts = new RaycastOptions();
937
- opts.ignore = [draggedObject];
942
+ opts.testObject = o => o !== this.followObject && o !== dragSource && o !== draggedObject;
938
943
  const hits = this.context.physics.raycastFromRay(ray, opts);
939
-
944
+
940
945
  if (hits.length > 0) {
941
946
  const hit = hits[0];
942
947
  // if we're above the same surface for a specified time, adjust drag options:
@@ -980,9 +985,11 @@
980
985
 
981
986
  const offset = this._hitPointInLocalSpace.clone().add(center);
982
987
  this._followObject.localToWorld(offset);
983
- const offsetWP = this._followObject.worldPosition.sub(offset);
984
988
 
985
- this._dragPlane.setFromNormalAndCoplanarPoint(hit.face.normal, hit.point.sub(offsetWP));
989
+ // See https://linear.app/needle/issue/NE-5004
990
+ // const offsetWP = this._followObject.worldPosition.sub(offset);
991
+ const point = hit.point;//.sub(offsetWP);
992
+ this._dragPlane.setFromNormalAndCoplanarPoint(hit.face.normal, point);
986
993
  }
987
994
  }
988
995
  }
@@ -1337,7 +1344,7 @@
1337
1344
  const wp = getWorldPosition(this._selected);
1338
1345
  const ray = new Ray(new Vector3(0, .1, 0).add(wp), new Vector3(0, -1, 0));
1339
1346
  const opts = new RaycastOptions();
1340
- opts.ignore = [this._selected];
1347
+ opts.testObject = o => o !== this._selected;
1341
1348
  const hits = this._context.physics.raycastFromRay(ray, opts);
1342
1349
  for (let i = 0; i < hits.length; i++) {
1343
1350
  const hit = hits[i];
src/engine-components/DropListener.ts CHANGED
@@ -1,11 +1,19 @@
1
+ import { AxesHelper, Box3, Object3D, Vector2, Vector3 } from "three";
1
2
  import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
2
3
 
4
+ import { isDevEnvironment } from "../engine/debug/index.js";
5
+ import { AnimationUtils } from "../engine/engine_animation.js";
6
+ import { addComponent } from "../engine/engine_components.js";
7
+ import { destroy } from "../engine/engine_gameobject.js";
8
+ import { Gizmos } from "../engine/engine_gizmos.js";
3
9
  import * as files from "../engine/engine_networking_files.js";
4
- import { RaycastOptions } from "../engine/engine_physics.js";
5
10
  import { serializable } from "../engine/engine_serialization_decorator.js";
11
+ import { fitObjectIntoVolume, placeOnSurface } from "../engine/engine_three_utils.js";
12
+ import { Vec3 } from "../engine/engine_types.js";
6
13
  import { getParam } from "../engine/engine_utils.js";
7
- import { Networking } from "../engine-components/Networking.js";
8
- import { Behaviour, GameObject } from "./Component.js";
14
+ import { Animation } from "./Animation.js";
15
+ import { Behaviour } from "./Component.js";
16
+ import { EventList } from "./EventList.js";
9
17
 
10
18
  const debug = getParam("debugdroplistener");
11
19
 
@@ -20,6 +28,17 @@
20
28
  ObjectAdded = "object-added",
21
29
  }
22
30
 
31
+ declare type DropContext = {
32
+ screenposition: Vector2;
33
+ url?: string,
34
+ }
35
+
36
+ declare type DropListenerNetworkEvent = {
37
+ guid: string,
38
+ url: string | string[],
39
+ point: Vec3;
40
+ }
41
+
23
42
  /** The DropListener component is used to listen for drag and drop events in the browser and add the dropped files to the scene
24
43
  * It can be used to allow users to drag and drop glTF files into the scene to add new objects.
25
44
  *
@@ -47,36 +66,111 @@
47
66
  */
48
67
  export class DropListener extends Behaviour {
49
68
 
50
- /** The URL to the files backend that handles networking the files */
69
+ /**
70
+ * When assigned the Droplistener will only accept files that are dropped on this object.
71
+ */
72
+ @serializable(Object3D)
73
+ dropArea?: Object3D;
74
+
75
+ /**
76
+ * When enabled the object will be fitted into a volume. Use {@link fitVolumeSize} to specify the volume size.
77
+ * @default false
78
+ */
51
79
  @serializable()
52
- filesBackendUrl?: string;
80
+ fitIntoVolume: boolean = false;
53
81
 
82
+ /**
83
+ * The volume size will be used to fit the object into the volume. Use {@link fitIntoVolume} to enable this feature.
84
+ */
85
+ @serializable(Vector3)
86
+ fitVolumeSize = new Vector3(1, 1, 1);
87
+
88
+ /** When enabled the object will be placed at the drop position (under the cursor)
89
+ * @default true
90
+ */
54
91
  @serializable()
55
- localhost?: string;
92
+ placeAtHitPosition: boolean = true;
56
93
 
94
+
95
+ @serializable(EventList)
96
+ onDropped: EventList = new EventList();
97
+
98
+
57
99
  /** @internal */
58
100
  onEnable(): void {
59
- this.filesBackendUrl = Networking.GetUrl(this.filesBackendUrl, this.localhost) ?? this.filesBackendUrl;
60
- if (debug) console.log(this, this.filesBackendUrl);
61
-
62
- this.context.domElement.addEventListener("dragover", this.onDrag);
63
- this.context.domElement.addEventListener("drop", this.onDrop);
101
+ this.context.renderer.domElement.addEventListener("dragover", this.onDrag);
102
+ this.context.renderer.domElement.addEventListener("drop", this.onDrop);
103
+ window.addEventListener("keyup", this.handlePaste);
104
+ this.context.connection.beginListen("droplistener", this.onNetworkEvent)
64
105
  }
65
106
  /** @internal */
66
107
  onDisable(): void {
67
- this.context.domElement.removeEventListener("dragover", this.onDrag);
68
- this.context.domElement.removeEventListener("drop", this.onDrop);
108
+ this.context.renderer.domElement.removeEventListener("dragover", this.onDrag);
109
+ this.context.renderer.domElement.removeEventListener("drop", this.onDrop);
110
+ window.removeEventListener("keyup", this.handlePaste);
111
+ this.context.connection.stopListen("droplistener", this.onNetworkEvent)
69
112
  }
70
113
 
114
+ private onNetworkEvent = (evt: DropListenerNetworkEvent) => {
115
+ if (evt.guid?.startsWith(this.guid)) {
116
+ const url = evt.url;
117
+ if (url) {
118
+ if (Array.isArray(url)) {
119
+ for (const _url of url) {
120
+ this.addFromUrl(_url, { screenposition: new Vector2() }, true).then(res => {
121
+ res?.position.set(evt.point.x, evt.point.y, evt.point.z);
122
+ })
123
+ }
124
+ }
125
+ else {
126
+ this.addFromUrl(url, { screenposition: new Vector2() }, true).then(res => {
127
+ res?.position.set(evt.point.x, evt.point.y, evt.point.z);
128
+ })
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ private handlePaste = async (evt: KeyboardEvent) => {
135
+ if (this.context.connection.allowEditing === false) return;
136
+ // detect paste
137
+ const isPasteCommand = (evt.ctrlKey || evt.metaKey) && evt.key === "v";
138
+ if (isPasteCommand) {
139
+ const clipboard = navigator.clipboard;
140
+ const value = await clipboard.readText().catch(console.warn);
141
+ if (value) {
142
+ const isUrl = value.startsWith("http") || value.startsWith("https") || value.startsWith("blob");
143
+ if (isUrl) {
144
+ const ctx = { screenposition: new Vector2(this.context.input.mousePosition.x, this.context.input.mousePosition.y) };
145
+ if (this.testIfIsInDropArea(ctx))
146
+ this.addFromUrl(value, ctx, false);
147
+ }
148
+ }
149
+ }
150
+ }
151
+
71
152
  private onDrag = (evt: DragEvent) => {
153
+ if (this.context.connection.allowEditing === false) return;
72
154
  // necessary to get drop event
73
155
  evt.preventDefault();
74
156
  }
75
157
 
76
158
  private onDrop = async (evt: DragEvent) => {
159
+ if (this.context.connection.allowEditing === false) return;
160
+
77
161
  if (debug) console.log(evt);
78
162
  if (!evt?.dataTransfer) return;
79
163
  evt.preventDefault();
164
+
165
+ const ctx: DropContext = { screenposition: new Vector2(evt.offsetX, evt.offsetY) };
166
+
167
+ if (this.dropArea) {
168
+ const res = this.testIfIsInDropArea(ctx);
169
+ if (res === false) return;
170
+ }
171
+
172
+ evt.stopImmediatePropagation();
173
+
80
174
  const items = evt.dataTransfer.items;
81
175
  if (!items) return;
82
176
 
@@ -85,62 +179,147 @@
85
179
  if (it.kind === "file") {
86
180
  const file = it.getAsFile();
87
181
  if (!file) continue;
88
- await this.addFiles(file);
182
+ await this.addFiles([file], ctx);
89
183
  }
90
184
  else if (it.kind === "string" && it.type == "text/plain") {
91
- it.getAsString(async str => {
92
- if (debug) console.log("dropped url", str);
93
- try {
94
- const url = new URL(str);
95
- if (!url) return;
96
- const res = await files.addFileFromUrl(url, this.context);
97
- if (res)
98
- this.addObject(evt, res);
99
- }
100
- catch (_) {
101
- console.log("dropped string is not a valid URL!", str);
102
- }
185
+ it.getAsString(str => {
186
+ this.addFromUrl(str, ctx, false);
103
187
  });
104
188
  }
105
189
  }
106
190
  }
107
191
 
108
- private async addFiles(...fileList: Array<File>) {
109
- if(debug) console.log("Add files", fileList)
192
+ private async addFromUrl(url: string, ctx: DropContext, isRemote: boolean) {
193
+ if (debug) console.log("dropped url", url);
194
+
195
+ try {
196
+ if (url.startsWith("https://github.com/")) {
197
+ // make raw.githubusercontent.com url
198
+ const parts = url.split("/");
199
+ const user = parts[3];
200
+ const repo = parts[4];
201
+ const branch = parts[6];
202
+ const path = parts.slice(7).join("/");
203
+ url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${path}`;
204
+ }
205
+ if (!url) return null;
206
+ const res = await files.addFileFromUrl(new URL(url), this.context);
207
+ if (res) {
208
+ ctx.url = url;
209
+ const obj = this.addObject(res, ctx, isRemote);
210
+ return obj;
211
+ }
212
+ }
213
+ catch (_) {
214
+ console.warn("String is not a valid URL", url);
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ private async addFiles(fileList: Array<File>, ctx: DropContext) {
221
+ if (debug) console.log("Add files", fileList)
110
222
  if (!Array.isArray(fileList)) return;
111
223
  if (!fileList.length) return;
112
224
 
113
225
  for (const file of fileList) {
114
226
  if (!file) continue;
115
- if (debug) console.log("Register file " + file.name + " to", this.filesBackendUrl, file);
116
- const res = await files.addFile(file, this.context, this.filesBackendUrl);
227
+ if (debug) console.log("Register file " + file.name, file);
228
+ const res = await files.addFile(file, this.context);
117
229
  this.dispatchEvent(new CustomEvent(DropListenerEvents.FileDropped, { detail: file }));
118
230
  if (res)
119
- this.addObject(undefined, res);
231
+ this.addObject(res, ctx, false);
120
232
  }
121
233
  }
122
234
 
123
- private async addObject(evt: DragEvent | undefined, gltf: GLTF) {
124
- if (debug) console.log("Dropped", gltf);
235
+ /** Previously added objects */
236
+ private readonly _addedObjects = new Array<Object3D>();
237
+
238
+ private addObject(gltf: GLTF, ctx: DropContext, isRemote: boolean) : Object3D | null {
239
+ if (debug) console.log(`Dropped ${this.gameObject.name}`, gltf);
125
240
  if (!gltf?.scene) {
126
241
  console.warn("No object specified to add to scene", gltf);
127
- return;
242
+ return null;
128
243
  }
129
244
 
245
+ for (const prev of this._addedObjects) {
246
+ if (prev.parent === this.gameObject) {
247
+ destroy(prev, true, true);
248
+ }
249
+ }
250
+ this._addedObjects.length = 0;
251
+
130
252
  const obj = gltf.scene;
131
- if (evt !== undefined) {
132
- const opts = new RaycastOptions();
133
- opts.setMask(0xffffff);
134
- opts.screenPointFromOffset(evt.offsetX, evt.offsetY);
135
- const rc = this.context.physics.raycast(opts);
253
+
254
+ // use attach to ignore the DropListener scale (e.g. if the parent object scale is not uniform)
255
+ this.gameObject.attach(obj);
256
+ obj.position.set(0, 0, 0);
257
+ obj.quaternion.identity();
258
+
259
+ this._addedObjects.push(obj);
260
+
261
+ if (debug) obj.add(new AxesHelper(1));
262
+
263
+
264
+ const volume = new Box3().setFromCenterAndSize(new Vector3(0, this.fitVolumeSize.y * .5, 0).add(this.gameObject.worldPosition), this.fitVolumeSize);
265
+ if (debug) Gizmos.DrawWireBox3(volume, 0x0000ff, 5);
266
+ if (this.fitIntoVolume) {
267
+ fitObjectIntoVolume(obj, volume, {
268
+ position: !this.placeAtHitPosition
269
+ });
270
+ }
271
+
272
+ if (this.placeAtHitPosition && ctx && ctx.screenposition) {
273
+ const rc = this.context.physics.raycast({ screenPoint: this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone()) });
136
274
  if (rc && rc.length > 0) {
137
275
  for (const hit of rc) {
138
- obj.position.copy(hit.point);
276
+ const pos = hit.point.clone();
277
+ placeOnSurface(obj, pos);
139
278
  break;
140
279
  }
141
280
  }
142
281
  }
143
- this.gameObject.add(obj);
282
+
283
+ AnimationUtils.assignAnimationsFromFile(gltf, {
284
+ createAnimationComponent: obj => addComponent(obj, Animation)
285
+ });
286
+
144
287
  this.dispatchEvent(new CustomEvent(DropListenerEvents.ObjectAdded, { detail: gltf }));
288
+ this.onDropped?.invoke({ sender: this, gltf })
289
+
290
+ // send network event
291
+ if (!isRemote && ctx.url?.startsWith("http") && this.context.connection.isConnected && obj) {
292
+ const evt: DropListenerNetworkEvent = {
293
+ guid: this.guid,
294
+ url: ctx.url,
295
+ point: obj.position.clone(),
296
+ };
297
+ this.context.connection.send("droplistener", evt);
298
+ }
299
+
300
+ return obj;
145
301
  }
302
+
303
+
304
+
305
+ private testIfIsInDropArea(ctx: DropContext): boolean {
306
+ if (this.dropArea) {
307
+ const screenPoint = this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone());
308
+ const hits = this.context.physics.raycast({
309
+ targets: [this.dropArea],
310
+ screenPoint,
311
+ recursive: true,
312
+ testObject: obj => {
313
+ if (this._addedObjects.includes(obj)) return false;
314
+ return true;
315
+ }
316
+ });
317
+ if (!hits.length) {
318
+ if (isDevEnvironment()) console.log(`Dropped outside of drop area for DropListener \"${this.name}\".`);
319
+ return false;
320
+ }
321
+ }
322
+ return true;
323
+ }
324
+
146
325
  }
src/engine/engine_element.ts CHANGED
@@ -482,6 +482,10 @@
482
482
  case "agx":
483
483
  this._context.renderer.toneMapping = AgXToneMapping;
484
484
  break;
485
+ default:
486
+ if (attribute !== null && attribute !== undefined) {
487
+ console.warn("Invalid tone-mapping attribute: " + attribute);
488
+ }
485
489
  }
486
490
  }
487
491
 
src/engine/engine_input.ts CHANGED
@@ -653,9 +653,10 @@
653
653
  }
654
654
  }
655
655
 
656
- convertScreenspaceToRaycastSpace(vec2: Vec2) {
656
+ convertScreenspaceToRaycastSpace<T extends Vec2 | Vector2>(vec2: T): T {
657
657
  vec2.x = (vec2.x - this.context.domX) / this.context.domWidth * 2 - 1;
658
658
  vec2.y = -((vec2.y - this.context.domY) / this.context.domHeight) * 2 + 1;
659
+ return vec2;
659
660
  }
660
661
 
661
662
  /** @internal */
src/engine/engine_networking_files_default_components.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  export function onDynamicObjectAdded(_obj: Object3D, _idProv: UIDProvider, _gltf?: GLTF) {
12
12
 
13
- console.warn("Adding components on object has been temporarily disabled");
13
+ // console.warn("Adding components on object has been temporarily disabled");
14
14
 
15
15
  // // this ensures we have a drag component
16
16
  // let drag = getComponentInChildren(obj as GameObject, DragControls);
src/engine/engine_physics.ts CHANGED
@@ -282,7 +282,7 @@
282
282
 
283
283
  if (debugPhysics) {
284
284
  console.timeEnd("raycast");
285
- console.log("hits:", (results?.length ? [...results] : "nothing"));
285
+ console.warn("#" + this.context.time.frame + ", hits:", (results?.length ? [...results] : "nothing"));
286
286
  }
287
287
 
288
288
  return results;
@@ -309,8 +309,10 @@
309
309
  if (geometry) {
310
310
  const raycastMesh = getRaycastMesh(obj);
311
311
  if (raycastMesh) mesh.geometry = raycastMesh;
312
+ const lastResultsCount = results.length;
312
313
  raycaster.intersectObject(obj, false, results);
313
- if (debugPhysics) Gizmos.DrawWireMesh({ mesh: obj as Mesh, depthTest: false, duration: .1 })
314
+ if (debugPhysics && results.length != lastResultsCount)
315
+ Gizmos.DrawWireMesh({ mesh: obj as Mesh, depthTest: false, duration: .2, color: raycastMesh ? 0x88dd55 : 0x770000 })
314
316
  mesh.geometry = geometry;
315
317
  }
316
318
  }
src/engine/engine_three_utils.ts CHANGED
@@ -543,8 +543,98 @@
543
543
  return box;
544
544
  }
545
545
 
546
+ /**
547
+ * Fits an object into a bounding volume. The volume is defined by a Box3 in world space.
548
+ * @param obj the object to fit
549
+ * @param volume the volume to fit the object into
550
+ * @param opts options for fitting
551
+ */
552
+ export function fitObjectIntoVolume(obj: Object3D, volume: Box3, opts?: {
553
+ /** Objects to ignore when calculating the obj's bounding box */
554
+ ignore?: Object3D[],
555
+ /** when `true` aligns the objects position to the volume ground
556
+ * @default true
557
+ */
558
+ position?: boolean
559
+ /** when `true` scales the object to fit the volume
560
+ * @default true
561
+ */
562
+ scale?: boolean,
563
+ }): {
564
+ /** The object's bounding box before fitting */
565
+ boundsBefore: Box3,
566
+ /** The scale that was applied to the object */
567
+ scale: Vector3,
568
+ } {
569
+ const box = getBoundingBox([obj], opts?.ignore);
546
570
 
571
+ const boundsSize = new Vector3();
572
+ box.getSize(boundsSize);
573
+ const boundsCenter = new Vector3();
574
+ box.getCenter(boundsCenter);
575
+
576
+ const targetSize = new Vector3();
577
+ volume.getSize(targetSize);
578
+ const targetCenter = new Vector3();
579
+ volume.getCenter(targetCenter);
580
+
581
+ const scale = new Vector3();
582
+ scale.set(targetSize.x / boundsSize.x, targetSize.y / boundsSize.y, targetSize.z / boundsSize.z);
583
+ const minScale = Math.min(scale.x, scale.y, scale.z);
584
+ const useScale = opts?.scale !== false;
585
+ if (useScale) {
586
+ setWorldScale(obj, getWorldScale(obj).multiplyScalar(minScale));
587
+ }
588
+
589
+ if (opts?.position !== false) {
590
+ const boundsBottomPosition = new Vector3();
591
+ box.getCenter(boundsBottomPosition);
592
+ boundsBottomPosition.y = box.min.y;
593
+ const targetBottomPosition = new Vector3();
594
+ volume.getCenter(targetBottomPosition);
595
+ targetBottomPosition.y = volume.min.y;
596
+ const offset = targetBottomPosition.clone().sub(boundsBottomPosition);
597
+ if (useScale) offset.multiplyScalar(minScale);
598
+ setWorldPosition(obj, getWorldPosition(obj).add(offset));
599
+ }
600
+
601
+ return {
602
+ boundsBefore: box,
603
+ scale,
604
+ }
605
+ }
606
+
607
+
608
+ declare type PlaceOnSurfaceResult = {
609
+ /** The offset from the object bounds to the pivot */
610
+ offset: Vector3,
611
+ /** The object's bounding box */
612
+ bounds: Box3
613
+ }
614
+
547
615
  /**
616
+ * Place an object on a surface. This will calculate the object bounds which might be an expensive operation for complex objects.
617
+ * The object will be visually placed on the surface (the object's pivot will be ignored)
618
+ * @param obj the object to place on the surface
619
+ * @param point the point to place the object on
620
+ * @returns the offset from the object bounds to the pivot
621
+ */
622
+ export function placeOnSurface(obj: Object3D, point: Vector3): PlaceOnSurfaceResult {
623
+ const bounds = getBoundingBox([obj]);
624
+ const center = new Vector3();
625
+ bounds.getCenter(center);
626
+ center.y = bounds.min.y;
627
+ const offset = point.clone().sub(center);
628
+ const worldPos = getWorldPosition(obj);
629
+ setWorldPosition(obj, worldPos.add(offset));
630
+ return {
631
+ offset,
632
+ bounds,
633
+ }
634
+ }
635
+
636
+
637
+ /**
548
638
  * Postprocesses the material of an object loaded by THREE.FBXLoader. It will apply some conversions to the material and will assign a MeshStandardMaterial to the object.
549
639
  */
550
640
  export function postprocessFBXMaterials(obj: Mesh, material: Material): boolean {
src/engine-components/OrbitControls.ts CHANGED
@@ -694,7 +694,7 @@
694
694
  centerCamera?: "none" | "y"
695
695
  } = {
696
696
  fitOffset: undefined,
697
- immediate: true
697
+ immediate: false
698
698
  }) {
699
699
 
700
700
  if (this.context.isInXR) {
@@ -702,7 +702,7 @@
702
702
  return;
703
703
  }
704
704
 
705
- const { immediate, centerCamera } = options;
705
+ const { immediate = false, centerCamera } = options;
706
706
  let { fitOffset } = options;
707
707
 
708
708
  if (fitOffset == undefined) {
src/engine-components/Skybox.ts CHANGED
@@ -39,13 +39,9 @@
39
39
 
40
40
  ContextRegistry.addContextCreatedCallback((args) => {
41
41
  const context = args.context;
42
- let skyboxImage = context.domElement.getAttribute("skybox-image");
43
- let environmentImage = context.domElement.getAttribute("environment-image");
42
+ const skyboxImage = context.domElement.getAttribute("skybox-image");
43
+ const environmentImage = context.domElement.getAttribute("environment-image");
44
44
  const promises = new Array<Promise<any>>();
45
-
46
- skyboxImage = tryParseMagicSkyboxName(skyboxImage);
47
- environmentImage = tryParseMagicSkyboxName(environmentImage);
48
-
49
45
  if (skyboxImage) {
50
46
  if (debug)
51
47
  console.log("Creating remote skybox to load " + skyboxImage);
@@ -68,25 +64,6 @@
68
64
  return Promise.resolve();
69
65
  });
70
66
 
71
- function tryParseMagicSkyboxName(str: string | null | undefined): string | null {
72
- switch (str?.toLowerCase()) {
73
- case "studio":
74
- str = "https://cdn.needle.tools/static/skybox/modelviewer-Neutral.hdr";
75
- break;
76
- case "blurred-skybox":
77
- str = "https://cdn.needle.tools/static/skybox/blurred-skybox.exr";
78
- break;
79
- case "quicklook-ar":
80
- str = "https://cdn.needle.tools/static/skybox/QuickLook-ARMode.exr";
81
- break;
82
- case "quicklook":
83
- str = "https://cdn.needle.tools/static/skybox/QuickLook-ObjectMode.exr";
84
- break;
85
- }
86
- if (str === undefined) return null;
87
- return str;
88
- }
89
-
90
67
  declare type SkyboxCacheEntry = { src: string, texture: Promise<Texture> };
91
68
  function ensureGlobalCache() {
92
69
  if (!globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"])
@@ -147,20 +124,42 @@
147
124
  */
148
125
  export class RemoteSkybox extends Behaviour {
149
126
 
127
+ /**
128
+ * URL to a remote skybox. This value can also use a magic skybox name. Options are "quicklook", "quicklook-ar", "studio", "blurred-skybox".
129
+ * @example
130
+ * ```ts
131
+ * skybox.url = "https://example.com/skybox.hdr";
132
+ * ```
133
+ */
150
134
  @syncField(RemoteSkybox.prototype.urlChangedSyncField)
151
135
  @serializable(URL)
152
136
  url?: string;
153
137
 
138
+ /**
139
+ * When enabled a user can drop a link to a skybox image on the scene to set the skybox.
140
+ * @default true
141
+ */
154
142
  @serializable()
155
143
  allowDrop: boolean = true;
156
144
 
145
+ /**
146
+ * When enabled the skybox will be set as the background of the scene.
147
+ * @default true
148
+ */
157
149
  @serializable()
158
150
  background: boolean = true;
159
151
 
152
+ /**
153
+ * When enabled the skybox will be set as the environment of the scene (to be used as environment map for reflections and lighting)
154
+ * @default true
155
+ */
160
156
  @serializable()
161
157
  environment: boolean = true;
162
158
 
163
- /* set to false if you do not want to apply url change events via networking */
159
+ /**
160
+ * When enabled dropped skybox urls (or assigned skybox urls) will be networked to other users in the same networked room.
161
+ * @default true
162
+ */
164
163
  @serializable()
165
164
  allowNetworking: boolean = true;
166
165
 
@@ -206,7 +205,11 @@
206
205
  */
207
206
  async setSkybox(url: string | undefined | null, name?: string) {
208
207
  if (!this.activeAndEnabled) return false;
208
+
209
+ url = tryParseMagicSkyboxName(url);
210
+
209
211
  if (!url) return false;
212
+
210
213
  name ??= url;
211
214
 
212
215
  if (!this.isValidTextureType(name)) {
@@ -399,4 +402,26 @@
399
402
  }
400
403
  }
401
404
  };
405
+ }
406
+
407
+
408
+
409
+
410
+ function tryParseMagicSkyboxName(str: string | null | undefined): string | null {
411
+ switch (str?.toLowerCase()) {
412
+ case "studio":
413
+ str = "https://cdn.needle.tools/static/skybox/modelviewer-Neutral.hdr";
414
+ break;
415
+ case "blurred-skybox":
416
+ str = "https://cdn.needle.tools/static/skybox/blurred-skybox.exr";
417
+ break;
418
+ case "quicklook-ar":
419
+ str = "https://cdn.needle.tools/static/skybox/QuickLook-ARMode.exr";
420
+ break;
421
+ case "quicklook":
422
+ str = "https://cdn.needle.tools/static/skybox/QuickLook-ObjectMode.exr";
423
+ break;
424
+ }
425
+ if (str === undefined) return null;
426
+ return str;
402
427
  }
src/engine-components/SyncedRoom.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isDevEnvironment, showBalloonWarning } from "../engine/debug/index.js";
1
+ import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../engine/debug/index.js";
2
2
  import { Mathf } from "../engine/engine_math.js";
3
3
  import { RoomEvents } from "../engine/engine_networking.js";
4
4
  import { serializable } from "../engine/engine_serialization_decorator.js";
@@ -19,36 +19,49 @@
19
19
 
20
20
  /**
21
21
  * The name of the room to join.
22
+ * @default ""
22
23
  */
23
24
  @serializable()
24
25
  public roomName!: string;
25
26
  /**
26
27
  * The URL parameter name to use for the room name. E.g. if set to "room" the URL will look like `?room=roomName`.
28
+ * @default "room"
27
29
  */
28
30
  @serializable()
29
31
  public urlParameterName: string = "room";
30
32
  /**
31
33
  * If true, the room will be joined automatically when this component becomes active
34
+ * @default true
32
35
  */
33
36
  @serializable()
34
37
  public joinRandomRoom: boolean = true;
35
38
  /**
36
39
  * If true and no room parameter is found in the URL then no room will be joined.
40
+ * @default false
37
41
  */
38
42
  @serializable()
39
43
  public requireRoomParameter: boolean = false;
40
44
  /**
41
45
  * If true, the room will be rejoined automatically when disconnected.
46
+ * @default true
42
47
  */
43
48
  @serializable()
44
49
  public autoRejoin: boolean = true;
45
50
 
46
51
  /**
47
52
  * If true, a join/leave room button will be created in the menu.
53
+ * @default true
48
54
  */
49
55
  @serializable()
50
56
  public createJoinButton: boolean = true;
51
57
 
58
+ /**
59
+ * If true, a join/leave room button for the view only URL will be created in the menu.
60
+ * @default false
61
+ */
62
+ @serializable()
63
+ public createViewOnlyButton: boolean = false;
64
+
52
65
  private _roomPrefix?: string;
53
66
 
54
67
  /**
@@ -88,11 +101,15 @@
88
101
  const button = this.createRoomButton();
89
102
  this.context.menu.appendChild(button);
90
103
  }
104
+ if (this.createViewOnlyButton) {
105
+ this.onEnableViewOnlyButton()
106
+ }
91
107
  }
92
108
 
93
109
  /** @internal */
94
110
  onDisable(): void {
95
111
  this._roomButton?.remove();
112
+ this.onDisableViewOnlyButton();
96
113
  if (this.roomName && this.roomName.length > 0)
97
114
  this.context.connection.leaveRoom(this.roomName);
98
115
  }
@@ -282,4 +299,51 @@
282
299
  this.context.connection.stopListen(RoomEvents.JoinedRoom, this.updateRoomButtonState);
283
300
  this.context.connection.stopListen(RoomEvents.LeftRoom, this.updateRoomButtonState);
284
301
  }
302
+
303
+
304
+ private _viewOnlyButton?: HTMLButtonElement;
305
+ private onEnableViewOnlyButton() {
306
+ if (this.context.connection.isConnected) {
307
+ this.onCreateViewOnlyButton();
308
+ }
309
+ else {
310
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton);
311
+ this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton);
312
+ }
313
+ }
314
+ private onDisableViewOnlyButton() {
315
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton);
316
+ this._viewOnlyButton?.remove();
317
+ }
318
+
319
+ private onCreateViewOnlyButton = () => {
320
+ if (!this._viewOnlyButton) {
321
+ const button = document.createElement("button");
322
+ this._viewOnlyButton = button;
323
+ button.classList.add("view-only-button");
324
+ button.setAttribute("priority", "90");
325
+ button.onclick = () => {
326
+ const viewUrl = this.getViewOnlyUrl();
327
+ if (viewUrl?.length) {
328
+ // share
329
+ if (navigator.canShare({ url: viewUrl })) {
330
+ navigator.share({ url: viewUrl })?.catch(err => {
331
+ console.warn(err);
332
+ });
333
+ }
334
+ else {
335
+ navigator.clipboard.writeText(viewUrl);
336
+ showBalloonMessage("View only URL copied to clipboard");
337
+ }
338
+ }
339
+ else {
340
+ showBalloonWarning("Could not create view only URL");
341
+ }
342
+ };
343
+ button.title = "Copy the view only URL: A page accessed by the view only URL can not be modified by visiting users.";
344
+ button.textContent = "Share View URL";
345
+ button.prepend(getIconElement("visibility"));
346
+ }
347
+ this.context.menu.appendChild(this._viewOnlyButton);
348
+ }
285
349
  }