Needle Engine

Changes between version 3.36.4-beta and 3.36.5-beta
Files changed (10) hide show
  1. plugins/common/buildinfo.js +8 -5
  2. src/engine-components/CameraUtils.ts +8 -3
  3. src/engine/engine_create_objects.ts +49 -12
  4. src/engine/engine_element.ts +16 -1
  5. src/engine/engine_lifecycle_api.ts +42 -6
  6. src/engine/codegen/register_types.ts +2 -2
  7. src/engine-components/ShadowCatcher.ts +11 -1
  8. src/engine-components/Skybox.ts +152 -55
  9. src/engine-components/SmoothFollow.ts +23 -5
  10. src/engine-components/SpriteRenderer.ts +3 -0
plugins/common/buildinfo.js CHANGED
@@ -1,4 +1,4 @@
1
- import fs from 'fs';
1
+ import fs, { statSync } from 'fs';
2
2
  import crypto from 'crypto';
3
3
 
4
4
 
@@ -26,7 +26,7 @@
26
26
 
27
27
  /** Recursively collect all files in a directory
28
28
  * @param {String} directory to search
29
- * @param {{ files: Array<{path:string, hash:string}>, totalsize:number }} info
29
+ * @param {{ files: Array<{path:string, hash:string, size:number}>, totalsize:number }} info
30
30
  */
31
31
  function recursivelyCollectFiles(directory, path, info) {
32
32
  const entries = fs.readdirSync(directory, { withFileTypes: true });
@@ -44,13 +44,16 @@
44
44
  } else {
45
45
  const relpath = `${path}/${entry.name}`;
46
46
  const filehash = crypto.createHash('sha256');
47
- filehash.update(fs.readFileSync(`${directory}/${entry.name}`));
47
+ const fullpath = `${directory}/${entry.name}`;
48
+ filehash.update(fs.readFileSync(fullpath));
49
+ const stats = statSync(fullpath);
48
50
  info.files.push({
49
51
  path: relpath,
50
- hash: filehash.digest('hex')
52
+ hash: filehash.digest('hex'),
53
+ size: stats.size
51
54
  });
52
55
  try {
53
- const fullpath = `${directory}/${entry.name}`;
56
+ const fullpath = `${ directory } / ${ entry.name }`;
54
57
  const stats = fs.statSync(fullpath);
55
58
  info.totalsize += stats.size;
56
59
  }
src/engine-components/CameraUtils.ts CHANGED
@@ -41,7 +41,12 @@
41
41
  cameraObject.position.y = 1;
42
42
  cameraObject.position.z = 2;
43
43
 
44
- createDefaultCameraControls(evt.context, cam);
44
+ const engineElement = evt.context.domElement as NeedleEngineHTMLElement
45
+ // If the camera is missing and the <needle-engine controls> is not set to false, create default camera controls
46
+ // That way we still create controls if the attribute is not added to <needle-engine> at all
47
+ if (engineElement?.cameraControls != false) {
48
+ createDefaultCameraControls(evt.context, cam);
49
+ }
45
50
 
46
51
  return cam;
47
52
  });
@@ -55,11 +60,11 @@
55
60
  // check if the <needle-engine controls> is not set to false
56
61
  const engineElement = evt.context.domElement as NeedleEngineHTMLElement
57
62
 
58
- if (engineElement?.cameraControls === true) {
63
+ if (engineElement?.cameraControls != false) {
59
64
 
60
65
  // Check if something else already acts as a camera controller
61
66
  const existing = getCameraController(evt.context.mainCamera);
62
- if (existing?.isCameraController === true) {
67
+ if (existing?.isCameraController == true) {
63
68
  if (debug) console.log("Will not auto-fit because a camera controller exists");
64
69
  return;
65
70
  }
src/engine/engine_create_objects.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxGeometry, Material, Mesh, MeshStandardMaterial, PlaneGeometry, SphereGeometry } from "three"
1
+ import { BoxGeometry, Material, Mesh, MeshStandardMaterial, Object3D, PlaneGeometry, SphereGeometry, Sprite, SpriteMaterial, Texture } from "three"
2
2
 
3
3
  import type { Vec3 } from "./engine_types.js";
4
4
 
@@ -9,9 +9,15 @@
9
9
  }
10
10
  export type PrimitiveTypeNames = keyof typeof PrimitiveType;
11
11
 
12
+ /**
13
+ * Options to create an object. Used by {@link ObjectUtils.createPrimitive}
14
+ */
12
15
  export type ObjectOptions = {
13
16
  name?: string,
17
+ /** Optional: The material to apply to the object */
14
18
  material?: Material,
19
+ /** Optional: The texture will applied to the material's main texture slot e.g. `material.map` if any is passed in */
20
+ texture?: Texture,
15
21
  position?: Vec3,
16
22
  /** euler */
17
23
  rotation?: Vec3,
@@ -27,30 +33,62 @@
27
33
  */
28
34
  export class ObjectUtils {
29
35
 
30
- /** Creates a primitive object like a Cube or Sphere */
36
+ /** Creates a primitive object like a Cube or Sphere
37
+ * @param type The type of primitive to create
38
+ * @param opts Options to create the object
39
+ * @returns The created object
40
+ */
31
41
  static createPrimitive(type: PrimitiveType | PrimitiveTypeNames, opts?: ObjectOptions): Mesh {
32
42
  let obj: Mesh;
33
43
  const color = 0xffffff;
34
44
  switch (type) {
35
45
  case "Quad":
36
46
  case PrimitiveType.Quad:
37
- const quadGeo = new PlaneGeometry(1, 1, 1, 1);
38
- const quadMat = opts?.material ?? new MeshStandardMaterial({ color: color });
39
- obj = new Mesh(quadGeo, quadMat);
47
+ {
48
+ const quadGeo = new PlaneGeometry(1, 1, 1, 1);
49
+ const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
50
+ if (opts?.texture && "map" in mat) mat.map = opts.texture;
51
+ obj = new Mesh(quadGeo, mat);
52
+ }
40
53
  break;
41
54
  case "Cube":
42
55
  case PrimitiveType.Cube:
43
- const boxGeo = new BoxGeometry(1, 1, 1);
44
- const boxMat = opts?.material ?? new MeshStandardMaterial({ color: color });
45
- obj = new Mesh(boxGeo, boxMat);
56
+ {
57
+ const boxGeo = new BoxGeometry(1, 1, 1);
58
+ const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
59
+ if (opts?.texture && "map" in mat) mat.map = opts.texture;
60
+ obj = new Mesh(boxGeo, mat);
61
+ }
46
62
  break;
47
63
  case "Sphere":
48
64
  case PrimitiveType.Sphere:
49
- const sphereGeo = new SphereGeometry(.5, 16, 16);
50
- const sphereMat = opts?.material ?? new MeshStandardMaterial({ color: color });
51
- obj = new Mesh(sphereGeo, sphereMat);
65
+ {
66
+ const sphereGeo = new SphereGeometry(.5, 16, 16);
67
+ const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
68
+ if (opts?.texture && "map" in mat) mat.map = opts.texture;
69
+ obj = new Mesh(sphereGeo, mat);
70
+ }
52
71
  break;
53
72
  }
73
+ this.applyDefaultObjectOptions(obj, opts);
74
+ return obj;
75
+ }
76
+
77
+ /**
78
+ * Creates a Sprite object
79
+ * @param opts Options to create the object
80
+ * @returns The created object
81
+ */
82
+ static createSprite(opts?: Omit<ObjectOptions, "material">): Sprite {
83
+ const color = 0xffffff;
84
+ const mat = new SpriteMaterial({ color: color });
85
+ if (opts?.texture && "map" in mat) mat.map = opts.texture;
86
+ const sprite = new Sprite(mat);
87
+ this.applyDefaultObjectOptions(sprite, opts);
88
+ return sprite;
89
+ }
90
+
91
+ private static applyDefaultObjectOptions(obj: Object3D, opts?: ObjectOptions) {
54
92
  if (opts?.name)
55
93
  obj.name = opts.name;
56
94
  if (opts?.position)
@@ -59,6 +97,5 @@
59
97
  obj.rotation.set(opts.rotation.x, opts.rotation.y, opts.rotation.z);
60
98
  if (opts?.scale)
61
99
  obj.scale.set(opts.scale.x, opts.scale.y, opts.scale.z);
62
- return obj;
63
100
  }
64
101
  }
src/engine/engine_element.ts CHANGED
@@ -51,8 +51,23 @@
51
51
  public get loadingProgress01(): number { return this._loadingProgress01; }
52
52
  public get loadingFinished(): boolean { return this.loadingProgress01 > .999; }
53
53
 
54
- public get cameraControls(): boolean {
54
+ /**
55
+ * If set to false the camera controls are disabled. Default is true.
56
+ * @type {boolean | null}
57
+ * @memberof NeedleEngineAttributes
58
+ * @example
59
+ * <needle-engine camera-controls="false"></needle-engine>
60
+ * @example
61
+ * <needle-engine camera-controls="true"></needle-engine>
62
+ * @example
63
+ * <needle-engine camera-controls></needle-engine>
64
+ * @example
65
+ * <needle-engine></needle-engine>
66
+ * @returns {boolean | null} if the attribute is not set it returns null
67
+ */
68
+ public get cameraControls(): boolean | null {
55
69
  const attr = this.getAttribute("camera-controls");
70
+ if (attr == null) return null;
56
71
  if (attr === null || attr === "False" || attr === "false" || attr === "0") return false;
57
72
  return true;
58
73
  }
src/engine/engine_lifecycle_api.ts CHANGED
@@ -1,54 +1,90 @@
1
1
  import { FrameEvent } from "./engine_context.js";
2
2
  import { ContextEvent } from "./engine_context_registry.js";
3
- import { type LifecycleMethod, registerFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
3
+ import { type LifecycleMethod, registerFrameEventCallback, unregisterFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
4
4
 
5
5
 
6
6
  /**
7
7
  * Register a callback in the engine context created event.
8
8
  * This happens once per context (after the context has been created and the first content has been loaded)
9
+ * @param cb The callback to be called
10
+ * @returns A function that can be called to unregister the callback
11
+ * @example
9
12
  * ```ts
10
13
  * onInitialized((ctx : Context) => {
11
14
  * // do something
12
15
  * }
13
16
  * ```
14
17
  * */
15
- export function onInitialized(cb: LifecycleMethod) {
18
+ export function onInitialized(cb: LifecycleMethod): () => void {
16
19
  registerFrameEventCallback(cb, ContextEvent.ContextCreated);
20
+ return () => unregisterFrameEventCallback(cb, ContextEvent.ContextCreated);
17
21
  }
18
22
 
19
23
  /** Register a callback in the engine start event.
20
24
  * This happens at the beginning of each frame
25
+ * @param cb The callback to be called
26
+ * @returns A function that can be called to unregister the callback
27
+ * @example
21
28
  * ```ts
22
29
  * onStart((ctx : Context) => {
23
30
  * // do something
24
31
  * }
25
32
  * ```
26
33
  * */
27
- export function onStart(cb: LifecycleMethod) {
34
+ export function onStart(cb: LifecycleMethod): () => void {
28
35
  registerFrameEventCallback(cb, FrameEvent.Start);
36
+ return () => unregisterFrameEventCallback(cb, FrameEvent.Start);
29
37
  }
30
38
 
31
39
 
32
40
  /** Register a callback in the engine update event
33
41
  * This is called every frame
42
+ * @param cb The callback to be called
43
+ * @returns A function that can be called to unregister the callback
44
+ * @example
34
45
  * ```ts
35
46
  * onUpdate((ctx : Context) => {
36
47
  * // do something
37
48
  * }
38
49
  * ```
39
50
  * */
40
- export function onUpdate(cb: LifecycleMethod) {
51
+ export function onUpdate(cb: LifecycleMethod): () => void {
41
52
  registerFrameEventCallback(cb, FrameEvent.Update);
53
+ return () => unregisterFrameEventCallback(cb, FrameEvent.Update);
42
54
  }
43
55
 
44
56
  /** Register a callback in the engine onBeforeRender event
45
- * This is called every frame
57
+ * This is called every frame before the main camera renders
58
+ * @param cb The callback to be called
59
+ * @returns A function that can be called to unregister the callback
60
+ * @example
46
61
  * ```ts
47
62
  * onBeforeRender((ctx : Context) => {
48
63
  * // do something
49
64
  * }
50
65
  * ```
51
66
  * */
52
- export function onBeforeRender(cb: LifecycleMethod) {
67
+ export function onBeforeRender(cb: LifecycleMethod): () => void {
53
68
  registerFrameEventCallback(cb, FrameEvent.OnBeforeRender);
69
+ return () => unregisterFrameEventCallback(cb, FrameEvent.OnBeforeRender);
70
+ }
71
+
72
+ /**
73
+ * Register a callback in the engine onAfterRender event
74
+ * This is called every frame after the main camera has rendered
75
+ * @param cb The callback to be called
76
+ * @returns A function that can be called to unregister the callback
77
+ * @example
78
+ * ```ts
79
+ * const unsubscribe = onAfterRender((ctx : Context) => {
80
+ * // do something...
81
+ * console.log("After render");
82
+ * // if you want to unsubscribe after the first call:
83
+ * unsubscribe();
84
+ * });
85
+ * ```
86
+ */
87
+ export function onAfterRender(cb: LifecycleMethod): () => void {
88
+ registerFrameEventCallback(cb, FrameEvent.OnAfterRender);
89
+ return () => unregisterFrameEventCallback(cb, FrameEvent.OnAfterRender);
54
90
  }
src/engine/codegen/register_types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
2
  import { TypeStore } from "./../engine_typestore.js"
3
-
3
+
4
4
  // Import types
5
5
  import { __Ignore } from "../../engine-components/codegen/components.js";
6
6
  import { ActionBuilder } from "../../engine-components/export/usdz/extensions/behavior/BehavioursBuilder.js";
@@ -219,7 +219,7 @@
219
219
  import { XRFlag } from "../../engine-components/webxr/XRFlag.js";
220
220
  import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
221
221
  import { XRState } from "../../engine-components/webxr/XRFlag.js";
222
-
222
+
223
223
  // Register types
224
224
  TypeStore.add("__Ignore", __Ignore);
225
225
  TypeStore.add("ActionBuilder", ActionBuilder);
src/engine-components/ShadowCatcher.ts CHANGED
@@ -4,14 +4,23 @@
4
4
  import { serializable } from "../engine/engine_serialization_decorator.js";
5
5
  import { Behaviour, GameObject } from "./Component.js";
6
6
  import { RGBAColor } from "./js-extensions/RGBAColor.js";
7
- import { Renderer } from "./Renderer.js";
8
7
 
8
+ /**
9
+ * The mode of the ShadowCatcher.
10
+ * - ShadowMask: only renders shadows.
11
+ * - Additive: renders shadows additively.
12
+ * - Occluder: occludes light.
13
+ */
9
14
  enum ShadowMode {
10
15
  ShadowMask = 0,
11
16
  Additive = 1,
12
17
  Occluder = 2,
13
18
  }
14
19
 
20
+ /**
21
+ * ShadowCatcher can be added to an Object3D to make it render shadows (or light) in the scene. It can also be used to create a shadow mask, or to occlude light.
22
+ * If the GameObject is a Mesh, it will apply a shadow-catching material to it - otherwise it will create a quad with the shadow-catching material.
23
+ */
15
24
  export class ShadowCatcher extends Behaviour {
16
25
 
17
26
  //@type Needle.Engine.ShadowCatcher.Mode
@@ -24,6 +33,7 @@
24
33
 
25
34
  private targetMesh?: Mesh;
26
35
 
36
+ /** @internal */
27
37
  start() {
28
38
  // if there's no geometry, make a basic quad
29
39
  if (!(this.gameObject instanceof Mesh)) {
src/engine-components/Skybox.ts CHANGED
@@ -49,7 +49,7 @@
49
49
  // if the user is loading a GLB without a camera then the CameraUtils (which creates the default camera)
50
50
  // checks if we have this attribute set and then sets the skybox clearflags accordingly
51
51
  // if the user has a GLB with a camera but set to solid color then the skybox image is not visible -> we will just warn then and not override the camera settings
52
- if(context.mainCameraComponent?.clearFlags !== ClearFlags.Skybox) console.warn("\"skybox-image\" attribute has no effect: camera clearflags are not set to \"Skybox\"");
52
+ if (context.mainCameraComponent?.clearFlags !== ClearFlags.Skybox) console.warn("\"skybox-image\" attribute has no effect: camera clearflags are not set to \"Skybox\"");
53
53
  const promise = createRemoteSkyboxComponent(context, skyboxImage, true, false, "skybox-image");
54
54
  promises.push(promise);
55
55
  }
@@ -97,6 +97,32 @@
97
97
  cache.push({ src, texture });
98
98
  }
99
99
 
100
+
101
+ /**
102
+ * RemoteSkybox is a component that allows you to set the skybox of a scene from a URL or a local file.
103
+ * It supports .hdr, .exr, .jpg, .png files.
104
+ *
105
+ * ### Events
106
+ * - `dropped-unknown-url`: Emitted when a file is dropped on the scene. The event detail contains the sender, the url and a function to apply the url.
107
+ *
108
+ * @example adding a skybox
109
+ * ```ts
110
+ * GameObject.addComponent(gameObject, Skybox, { url: "https://example.com/skybox.hdr", background: true, environment: true });
111
+ * ```
112
+ *
113
+ * @example handle custom url
114
+ * ```ts
115
+ * const skybox = GameObject.addComponent(gameObject, Skybox);
116
+ * skybox.addEventListener("dropped-unknown-url", (evt) => {
117
+ * let url = evt.detail.url;
118
+ * console.log("User dropped file", url);
119
+ * // change url or resolve it differently
120
+ * url = "https://example.com/skybox.hdr";
121
+ * // apply the url
122
+ * evt.detail.apply(url);
123
+ * });
124
+ * ```
125
+ */
100
126
  export class RemoteSkybox extends Behaviour {
101
127
 
102
128
  @syncField(RemoteSkybox.prototype.urlChangedSyncField)
@@ -113,6 +139,7 @@
113
139
  environment: boolean = true;
114
140
 
115
141
  /* set to false if you do not want to apply url change events via networking */
142
+ @serializable()
116
143
  allowNetworking: boolean = true;
117
144
 
118
145
  private _loader?: RGBELoader | EXRLoader | TextureLoader;
@@ -121,11 +148,13 @@
121
148
  private _prevEnvironment: Texture | null = null;
122
149
  private _prevBackground: any = null;
123
150
 
151
+ /** @internal */
124
152
  onEnable() {
125
153
  this.setSkybox(this.url);
126
154
  this.registerDropEvents();
127
155
  }
128
156
 
157
+ /** @internal */
129
158
  onDisable() {
130
159
  if (this.context.scene.environment === this._prevLoadedEnvironment) {
131
160
  this.context.scene.environment = this._prevEnvironment;
@@ -139,15 +168,27 @@
139
168
  }
140
169
 
141
170
  private urlChangedSyncField() {
142
- if (this.allowNetworking)
143
- this.setSkybox(this.url);
171
+ if (this.allowNetworking && this.url) {
172
+ // omit local dragged files from being handled
173
+ if (this.isRemoteTexture(this.url)) {
174
+ this.setSkybox(this.url);
175
+ }
176
+ }
144
177
  }
145
178
 
146
- async setSkybox(url: string | undefined | null) {
179
+ /**
180
+ * Set the skybox from a given url
181
+ * @param url The url of the skybox image
182
+ * @param name Define name of the file with extension if it isn't apart of the url
183
+ * @returns Whether the skybox was successfully set
184
+ */
185
+ async setSkybox(url: string | undefined | null, name?: string) {
147
186
  if (!this.activeAndEnabled) return false;
148
187
  if (!url) return false;
149
- if (!url?.endsWith(".hdr") && !url.endsWith(".exr") && !url.endsWith(".jpg") && !url.endsWith(".png") && !url.endsWith(".jpeg")) {
150
- console.warn("Potentially invalid skybox url", this.url, "on", this.name);
188
+ name ??= url;
189
+
190
+ if (!this.isValidTextureType(name)) {
191
+ console.warn("Potentially invalid skybox url", name, "on", this.name);
151
192
  }
152
193
 
153
194
  if (debug) console.log("Remote skybox url?: " + url);
@@ -162,7 +203,7 @@
162
203
  }
163
204
  this._prevUrl = url;
164
205
 
165
- const envMap = await this.loadTexture(url);
206
+ const envMap = await this.loadTexture(url, name);
166
207
  if (!envMap) return false;
167
208
  // Check if we're still enabled
168
209
  if (!this.enabled) return false;
@@ -178,15 +219,16 @@
178
219
  return true;
179
220
  }
180
221
 
181
- private async loadTexture(url: string) {
222
+ private async loadTexture(url: string, name?: string) {
182
223
  if (!url) return Promise.resolve(null);
183
- const cached = tryGetPreviouslyLoadedTexture(url);
224
+ name ??= url;
225
+ const cached = tryGetPreviouslyLoadedTexture(name);
184
226
  if (cached) {
185
227
  const res = await cached;
186
228
  if (res.source?.data?.length > 0 || res.source?.data?.data?.length) return res;
187
229
  }
188
- const isEXR = url.endsWith(".exr");
189
- const isHdr = url.endsWith(".hdr");
230
+ const isEXR = name.endsWith(".exr");
231
+ const isHdr = name.endsWith(".hdr");
190
232
  if (isEXR) {
191
233
  if (!(this._loader instanceof EXRLoader))
192
234
  this._loader = new EXRLoader();
@@ -202,7 +244,7 @@
202
244
 
203
245
  if (debug) console.log("Loading skybox: " + url);
204
246
  const loadingTask = this._loader.loadAsync(url);
205
- registerLoadedTexture(url, loadingTask);
247
+ registerLoadedTexture(name, loadingTask);
206
248
  const envMap = await loadingTask;
207
249
  return envMap;
208
250
  }
@@ -229,55 +271,110 @@
229
271
  }
230
272
 
231
273
 
232
- private dragOverEvent?: any;
233
- private dropEvent?: any;
274
+ private readonly validTextureTypes = [".hdr", ".exr", ".jpg", ".jpeg", ".png"];
234
275
 
276
+ private isRemoteTexture(url: string): boolean {
277
+ return url.startsWith("http://") || url.startsWith("https://");
278
+ }
279
+
280
+ private isValidTextureType(url: string): boolean {
281
+ for (const type of this.validTextureTypes) {
282
+ if (url.endsWith(type)) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+
288
+
235
289
  private registerDropEvents() {
236
- if (this.dragOverEvent) return;
290
+ this.unregisterDropEvents();
291
+ this.context.domElement.addEventListener("dragover", this.onDragOverEvent);
292
+ this.context.domElement.addEventListener("drop", this.onDrop);
293
+ }
237
294
 
238
- this.dragOverEvent = (e: DragEvent) => {
239
- if (!this.allowDrop) return;
240
- if (!e.dataTransfer) return;
241
- for (const type of e.dataTransfer.types) {
242
- // in ondragover we dont get access to the content
243
- // but if we have a uri list we can assume
244
- // someone is maybe dragging a image file
245
- // so we want to capture this
246
- if (type === "text/uri-list") {
295
+ private unregisterDropEvents() {
296
+ this.context.domElement.removeEventListener("dragover", this.onDragOverEvent);
297
+ this.context.domElement.removeEventListener("drop", this.onDrop);
298
+ }
299
+
300
+ private onDragOverEvent = (e: DragEvent) => {
301
+ if (!this.allowDrop) return;
302
+ if (!e.dataTransfer) return;
303
+ for (const type of e.dataTransfer.types) {
304
+ // in ondragover we dont get access to the content
305
+ // but if we have a uri list we can assume
306
+ // someone is maybe dragging a image file
307
+ // so we want to capture this
308
+ if (type === "text/uri-list" || type === "Files") {
309
+ e.preventDefault();
310
+ }
311
+ }
312
+ };
313
+
314
+ private onDrop = (e: DragEvent) => {
315
+ if (!this.allowDrop) return;
316
+ if (!e.dataTransfer) return;
317
+ for (const type of e.dataTransfer.types) {
318
+ if (debug) console.log(type);
319
+ if (type === "text/uri-list") {
320
+ const url = e.dataTransfer.getData(type);
321
+ if (debug) console.log(type, url);
322
+ let name = new RegExp(/polyhaven.com\/asset_img\/.+?\/(?<name>.+)\.png/).exec(url)?.groups?.name;
323
+ if (!name) {
324
+ name = new RegExp(/polyhaven\.com\/a\/(?<name>.+)/).exec(url)?.groups?.name;
325
+ }
326
+ if (debug) console.log(name);
327
+ if (name) {
328
+ const skyboxurl = "https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/" + name + "_1k.exr";
329
+ console.log(`[Remote Skybox] Setting skybox from url: ${skyboxurl}`);
247
330
  e.preventDefault();
331
+ this.setSkybox(skyboxurl);
332
+ break;
248
333
  }
249
- }
250
- };
334
+ else if (this.isValidTextureType(url)) {
335
+ console.log("[Remote Skybox] Setting skybox from url: " + url);
336
+ e.preventDefault();
337
+ this.setSkybox(url);
338
+ break;
339
+ }
340
+ else {
251
341
 
252
- this.dropEvent = (e: DragEvent) => {
253
- if (!this.allowDrop) return;
254
- e.preventDefault();
255
- if (!e.dataTransfer) return;
256
- for (const type of e.dataTransfer.types) {
257
- if (type === "text/uri-list") {
258
- const url = e.dataTransfer.getData(type);
259
- if (debug) console.log(type, url);
260
- let name = new RegExp(/polyhaven.com\/asset_img\/.+?\/(?<name>.+)\.png/).exec(url)?.groups?.name;
261
- if (!name) {
262
- name = new RegExp(/polyhaven\.com\/a\/(?<name>.+)/).exec(url)?.groups?.name;
263
- }
264
- if (debug) console.log(name);
265
- if (name) {
266
- const envurl = "https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/" + name + "_1k.exr";
267
- this.setSkybox(envurl);
268
- }
269
- else console.warn("Could not resolve skybox name from dropped url", url);
342
+ console.warn(`[RemoteSkybox] Unknown url ${url}. If you want to load a skybox from a url, make sure it is a valid image url. Url must end with${this.validTextureTypes.join(", ")}.`);
343
+ // emit custom event - users can listen to this event and handle the url themselves
344
+ const evt = new CustomEvent("dropped-unknown-url", {
345
+ detail: {
346
+ sender: this,
347
+ event: e,
348
+ url,
349
+ apply: (url: string) => {
350
+ e.preventDefault();
351
+ this.setSkybox(url);
352
+ }
353
+ }
354
+ });
355
+ this.dispatchEvent(evt);
270
356
  }
271
357
  }
272
- };
273
-
274
- this.context.domElement.addEventListener("dragover", this.dragOverEvent);
275
- this.context.domElement.addEventListener("drop", this.dropEvent);
276
- }
277
-
278
- private unregisterDropEvents() {
279
- if (!this.dragOverEvent) return;
280
- this.context.domElement.removeEventListener("dragover", this.dragOverEvent);
281
- this.context.domElement.removeEventListener("drop", this.dropEvent);
282
- }
358
+ else if (type == "Files") {
359
+ const file = e.dataTransfer.files.item(0);
360
+ if (debug) console.log(type, file);
361
+ if (!file) continue;
362
+ if (!this.isValidTextureType(file.name)) {
363
+ console.warn(`[RemoteSkybox]: File \"${file.name}\" is not supported. Supported files are ${this.validTextureTypes.join(", ")}`);
364
+ return;
365
+ }
366
+ if (tryGetPreviouslyLoadedTexture(file.name) === null) {
367
+ const blob = new Blob([file]);
368
+ const url = URL.createObjectURL(blob);
369
+ e.preventDefault();
370
+ this.setSkybox(url, file.name);
371
+ }
372
+ else {
373
+ e.preventDefault();
374
+ this.setSkybox(file.name);
375
+ }
376
+ break;
377
+ }
378
+ }
379
+ };
283
380
  }
src/engine-components/SmoothFollow.ts CHANGED
@@ -6,24 +6,42 @@
6
6
  import { getWorldPosition, getWorldQuaternion } from "../engine/engine_three_utils.js";
7
7
  import { Behaviour } from "./Component.js";
8
8
 
9
+ /**
10
+ * SmoothFollow makes the {@link Object3D} (`GameObject`) smoothly follow another target {@link Object3D}.
11
+ * It can follow the target's position, rotation, or both.
12
+ */
9
13
  export class SmoothFollow extends Behaviour {
10
14
 
15
+ /**
16
+ * The target to follow. If null, the GameObject will not move.
17
+ */
11
18
  @serializable(Object3D)
12
19
  target: Object3D | null = null;
13
20
 
21
+ /**
22
+ * The factor to smoothly follow the target's position.
23
+ * The value is clamped between 0 and 1. If 0, the GameObject will not follow the target's position.
24
+ */
14
25
  @serializable()
15
26
  followFactor = .1;
27
+ /**
28
+ * The factor to smoothly follow the target's rotation.
29
+ * The value is clamped between 0 and 1. If 0, the GameObject will not follow the target's rotation.
30
+ */
16
31
  @serializable()
17
32
  rotateFactor = .1;
18
33
 
19
34
  @serializable()
20
- positionAxes : Axes = Axes.All;
35
+ positionAxes: Axes = Axes.All;
21
36
 
22
37
  flipForward: boolean = false;
23
38
 
24
39
  private static _invertForward: Quaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
25
40
  private _firstUpdate = true;
26
41
 
42
+ /**
43
+ * Update the position and rotation of the GameObject to follow the target.
44
+ */
27
45
  onBeforeRender(): void {
28
46
  this.updateNow(false);
29
47
  }
@@ -35,10 +53,10 @@
35
53
  const fpos = this._firstUpdate || hard ? 1 : Mathf.clamp01(this.context.time.deltaTime * this.followFactor);
36
54
  const currentPosition = this.worldPosition;
37
55
 
38
- if(this.positionAxes & Axes.X) currentPosition.x = Mathf.lerp(currentPosition.x, wp.x, fpos);
39
- if(this.positionAxes & Axes.Y) currentPosition.y = Mathf.lerp(currentPosition.y, wp.y, fpos);
40
- if(this.positionAxes & Axes.Z) currentPosition.z = Mathf.lerp(currentPosition.z, wp.z, fpos);
41
-
56
+ if (this.positionAxes & Axes.X) currentPosition.x = Mathf.lerp(currentPosition.x, wp.x, fpos);
57
+ if (this.positionAxes & Axes.Y) currentPosition.y = Mathf.lerp(currentPosition.y, wp.y, fpos);
58
+ if (this.positionAxes & Axes.Z) currentPosition.z = Mathf.lerp(currentPosition.z, wp.z, fpos);
59
+
42
60
  // TODO lerp distance from target as well
43
61
 
44
62
  this.worldPosition = currentPosition;
src/engine-components/SpriteRenderer.ts CHANGED
@@ -132,6 +132,9 @@
132
132
  }
133
133
  }
134
134
 
135
+ /**
136
+ * The sprite renderer renders a sprite on a GameObject using an assigned spritesheet ({@link SpriteData})
137
+ */
135
138
  export class SpriteRenderer extends Behaviour {
136
139
 
137
140
  @serializable()