@@ -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
|
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.
|
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
|
-
|
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.
|
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];
|
@@ -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 {
|
8
|
-
import { Behaviour
|
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
|
-
/**
|
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
|
-
|
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
|
-
|
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.
|
60
|
-
|
61
|
-
|
62
|
-
this.context.
|
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(
|
92
|
-
|
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
|
109
|
-
if(debug) console.log("
|
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
|
116
|
-
const res = await files.addFile(file, this.context
|
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(
|
231
|
+
this.addObject(res, ctx, false);
|
120
232
|
}
|
121
233
|
}
|
122
234
|
|
123
|
-
|
124
|
-
|
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
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
-
|
276
|
+
const pos = hit.point.clone();
|
277
|
+
placeOnSurface(obj, pos);
|
139
278
|
break;
|
140
279
|
}
|
141
280
|
}
|
142
281
|
}
|
143
|
-
|
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
|
}
|
@@ -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
|
|
@@ -653,9 +653,10 @@
|
|
653
653
|
}
|
654
654
|
}
|
655
655
|
|
656
|
-
convertScreenspaceToRaycastSpace(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 */
|
@@ -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);
|
@@ -282,7 +282,7 @@
|
|
282
282
|
|
283
283
|
if (debugPhysics) {
|
284
284
|
console.timeEnd("raycast");
|
285
|
-
console.
|
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
|
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
|
}
|
@@ -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 {
|
@@ -694,7 +694,7 @@
|
|
694
694
|
centerCamera?: "none" | "y"
|
695
695
|
} = {
|
696
696
|
fitOffset: undefined,
|
697
|
-
immediate:
|
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) {
|
@@ -39,13 +39,9 @@
|
|
39
39
|
|
40
40
|
ContextRegistry.addContextCreatedCallback((args) => {
|
41
41
|
const context = args.context;
|
42
|
-
|
43
|
-
|
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
|
-
|
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
|
}
|
@@ -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
|
}
|