Needle Engine

Changes between version 3.37.9-alpha.1 and 3.37.10-alpha
Files changed (11) hide show
  1. plugins/vite/pwa.js +70 -51
  2. src/engine-components/ContactShadows.ts +84 -37
  3. src/engine/engine_context.ts +14 -3
  4. src/engine/engine_element_loading.ts +3 -3
  5. src/engine/engine_element.ts +31 -10
  6. src/engine/engine_three_utils.ts +66 -1
  7. plugins/types/index.d.ts +3 -3
  8. src/engine/js-extensions/Layers.ts +4 -0
  9. src/engine-components/OrbitControls.ts +2 -58
  10. plugins/types/userconfig.d.ts +4 -6
  11. plugins/types/webmanifest.d.ts +8 -1
plugins/vite/pwa.js CHANGED
@@ -13,36 +13,46 @@
13
13
  */
14
14
  export const needlePWA = (command, config, userSettings) => {
15
15
  // @ts-ignore // TODO correctly type the userSettings.pwaOptions object
16
- /** @type {import("vite-plugin-pwa").VitePWAOptions} */
17
- const pwaOptions = userSettings.pwaOptions;
16
+ /** @type {import("vite-plugin-pwa").VitePWAOptions | false} */
17
+ const pwaOptions = userSettings.pwa;
18
18
 
19
19
  /** The context contains files that are generated by the plugin and should be deleted after
20
20
  * @type {import('../types').NeedlePWAProcessContext} */
21
21
  const context = { generatedFiles: [] }
22
22
 
23
+ // early out, explicitly disabled
24
+ if (pwaOptions === false) return;
25
+
26
+ // no PWA options provided, but the vite-pwa plugin is enabled – inform that we can do better
23
27
  if (!pwaOptions) {
24
- log("WARN: No PWA options found in user settings. Please pass in the pwaOptions to the needlePlugin like so: \"needlePlugins(command, needleConfig, { pwaOptions: PWAOptions })\" and pass the same options to vitePWA plugin like so \"VitePWA(PWAOptions)\"");
25
- return cleanup(context);
28
+ return {
29
+ name: "needle-pwa",
30
+ apply: "build",
31
+ configResolved(config) {
32
+ if (findVitePWAPlugin(config)) {
33
+ errorThrow("It seems that you're trying to build a PWA using `vite-plugin-pwa`!\nNeedle can manage PWA settings for you – just pass the same `pwaOptions` to the `needlePlugin` and `VitePWA` plugins:\n\n import { VitePWA } from 'vite-plugin-pwa';\n ...\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n\nIf you want to manage PWA building yourself and skip this check, please pass `{ pwa: false }` to needlePlugins.");
34
+ }
35
+ },
36
+ }
26
37
  }
27
38
 
28
39
  // @ts-ignore // TODO need an extra type so we can add more options into the VitePWAOptions object
29
40
  /** @type {number | undefined} */
30
41
  const updateInterval = pwaOptions.updateInterval || undefined;
31
42
 
32
- if ((command !== "build" && !pwaOptions?.devOptions.enabled) || pwaOptions?.disable) return;
33
- if (userSettings?.noPWA === true) return;
43
+ if ((command !== "build" && !pwaOptions?.devOptions?.enabled) || pwaOptions?.disable) return;
34
44
 
35
45
  // The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
36
46
  // If gzip is wanted, the server should serve files with gzip compression on the fly.
37
47
  if (config) config.gzip = false;
38
48
 
39
49
  if (!pwaOptions.registerType) {
40
- log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
50
+ // log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
41
51
  pwaOptions.registerType = "autoUpdate";
42
52
  }
43
53
  if (!pwaOptions.outDir) {
44
54
  const outDir = getOutputDirectory();
45
- log("Set PWA outDir to " + outDir);
55
+ // log("Set PWA outDir to " + outDir);
46
56
  pwaOptions.outDir = outDir;
47
57
  }
48
58
 
@@ -77,17 +87,20 @@
77
87
  name: 'needle-pwa',
78
88
  apply: 'build',
79
89
  enforce: "post",
80
- config(config) {
90
+ configResolved(config) {
81
91
  try {
82
- checkIfVitePWAPluginIsPresent(config);
92
+ const plugin = findVitePWAPlugin(config);
93
+ if (!plugin) {
94
+ errorThrow("It seems that you're trying to build a PWA!.\nRun `npm install vite-plugin-pwa`, and add VitePWA to the vite config:\n\n import { VitePWA } from 'vite-plugin-pwa';\n ...\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n\nIf you don't intend to build a PWA, pass `{ pwa: false }` to needlePlugins or remove the `pwa` entry.");
95
+ }
83
96
 
84
97
  // check if the index header contains the webmanifest ALSO
85
- // if yes then we want to log a waring at the end
98
+ // if yes then we want to log a warning at the end
86
99
  const indexPath = currentDir + "/index.html";
87
100
  if (existsSync(indexPath)) {
88
101
  const indexContent = readFileSync(indexPath, 'utf8');
89
102
  if (indexContent.includes(".webmanifest")) {
90
- throw new Error("ERR: [needle-pwa] index.html contains a reference to a webmanifest. This is currently not supported. Please remove the reference from the index.html, or remove the needle-pwa plugin and manage PWA building yourself.");
103
+ errorThrow("index.html contains a reference to a webmanifest. This is currently not supported. Please remove the reference from the index.html, or pass `{ pwa: false }` to needlePlugins to manage PWA building yourself.");
91
104
  }
92
105
  }
93
106
  }
@@ -189,28 +202,21 @@
189
202
 
190
203
  /** Checks if the vite-plugin-pwa is present in the vite config
191
204
  * @param {import('vite').UserConfig} config
205
+ * @returns {import('vite-plugin-pwa').VitePWAOptions | null}
192
206
  */
193
- function checkIfVitePWAPluginIsPresent(config) {
207
+ function findVitePWAPlugin(config) {
194
208
  const plugins = config.plugins || [];
195
- let foundVitePWAPlugin = false;
209
+ function _findVitePWAPlugin(p) {
210
+ if (Array.isArray(p))
211
+ return p.find(_findVitePWAPlugin);
212
+ if (p?.name === "vite-plugin-pwa")
213
+ return p;
214
+ }
196
215
  for (const plugin of plugins) {
197
- if (isVitePWAPlugin(plugin)) {
198
- foundVitePWAPlugin = true;
199
- return;
200
- }
216
+ const foundVitePWAPlugin = _findVitePWAPlugin(plugin);
217
+ if (foundVitePWAPlugin) return foundVitePWAPlugin;
201
218
  }
202
- function isVitePWAPlugin(p) {
203
- if (Array.isArray(p)) {
204
- return p.some(isVitePWAPlugin);
205
- }
206
- return p.name === "vite-plugin-pwa";
207
- }
208
- if (!foundVitePWAPlugin) {
209
- // const { default: VitePWA } = await import('vite-plugin-pwa');
210
- // log("Add vite-plugin-pwa to the vite config");
211
- // config.plugins.push(VitePWA());
212
- throw new Error("ERR: [needle-pwa] vite-plugin-pwa is not present in the vite config. Please install vite-plugin-pwa and add it to the vite config like so: \"VitePWA(PWAOptions)\". \n\nIf you don't intent to build a PWA then don't pass the PWA options to the needlePlugin or set `noPWA: true` in the user settings (third argument).");
213
- }
219
+ return null;
214
220
  }
215
221
 
216
222
  function cleanup(context) {
@@ -225,6 +231,18 @@
225
231
  console.log("[needle-pwa]", ...args);
226
232
  }
227
233
 
234
+ /** Throws an error with defined stacktrace.
235
+ * @param {string} message
236
+ * @param {number} stackTraceLimit How many stack frames to show in the error message
237
+ */
238
+ function errorThrow(message, traceLimit = 0) {
239
+ const { stackTraceLimit } = Error;
240
+ Error.stackTraceLimit = traceLimit;
241
+ const e = new Error("[needle-pwa] " + message);
242
+ Error.stackTraceLimit = stackTraceLimit;
243
+ throw e;
244
+ }
245
+
228
246
  /**
229
247
  * @param {string | Partial<import("vite-plugin-pwa").ManifestOptions>} webmanifestPath Path to the webmanifest file, or partial manifest itself
230
248
  * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
@@ -249,7 +267,11 @@
249
267
  }
250
268
 
251
269
  processIcons(manifest, outDir, context);
252
-
270
+
271
+ // TODO include assets in the manifest instead of manually copying
272
+ // if (pwaOptions.includeAssets === undefined) pwaOptions.includeAssets = [];
273
+ // pwaOptions.includeAssets = [...pwaOptions.includeAssets, ...manifest.icons?.map(i => i.src)];
274
+
253
275
  const packageJsonPath = process.cwd() + "/package.json";
254
276
  const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
255
277
 
@@ -347,31 +369,28 @@
347
369
  function generateIcons(manifest, context) {
348
370
  if (!manifest.icons) manifest.icons = [];
349
371
  log("Generating PWA icons");
350
- const sizes = [192, 512];
372
+ const sizes = [48, 128, 144, 192, 512];
373
+ // Needle icon
351
374
  const defaultIconSVG = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 160 187.74"><defs><linearGradient id="a" x1="89.64" y1="184.81" x2="90.48" y2="21.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62d399"/><stop offset=".51" stop-color="#acd842"/><stop offset=".9" stop-color="#d7db0a"/></linearGradient><linearGradient id="b" x1="69.68" y1="178.9" x2="68.08" y2="16.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ba398"/><stop offset=".5" stop-color="#4ca352"/><stop offset="1" stop-color="#76a30a"/></linearGradient><linearGradient id="c" x1="36.6" y1="152.17" x2="34.7" y2="84.19" gradientUnits="userSpaceOnUse"><stop offset=".19" stop-color="#36a382"/><stop offset=".54" stop-color="#49a459"/><stop offset="1" stop-color="#76a30b"/></linearGradient><linearGradient id="d" x1="15.82" y1="153.24" x2="18" y2="90.86" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#267880"/><stop offset=".51" stop-color="#457a5c"/><stop offset="1" stop-color="#717516"/></linearGradient><linearGradient id="e" x1="135.08" y1="135.43" x2="148.93" y2="63.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0d939"/><stop offset="1" stop-color="#eadb04"/></linearGradient><linearGradient id="f" x1="-4163.25" y1="2285.12" x2="-4160.81" y2="2215.34" gradientTransform="rotate(20 4088.49 13316.712)" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#74af52"/><stop offset=".48" stop-color="#99be32"/><stop offset="1" stop-color="#c0c40a"/></linearGradient><symbol id="g" viewBox="0 0 160 187.74"><path style="fill:url(#a)" d="M79.32 36.98v150.76L95 174.54l6.59-156.31-22.27 18.75z"/><path style="fill:url(#b)" d="M79.32 36.98 57.05 18.23l6.59 156.31 15.68 13.2V36.98z"/><path style="fill:url(#c)" d="m25.19 104.83 8.63 49.04 12.5-14.95-2.46-56.42-18.67 22.33z"/><path style="fill:url(#d)" d="M25.19 104.83 0 90.24l16.97 53.86 16.85 9.77-8.63-49.04z"/><path style="fill:#9c3" d="M43.86 82.5 18.69 67.98 0 90.24l25.18 14.59L43.86 82.5z"/><path style="fill:url(#e)" d="m134.82 78.69-9.97 56.5 15.58-9.04L160 64.1l-25.18 14.59z"/><path style="fill:url(#f)" d="m134.82 78.69-18.68-22.33-2.86 65 11.57 13.83 9.97-56.5z"/><path style="fill:#ffe113" d="m160 64.1-18.69-22.26-25.17 14.52 18.67 22.33L160 64.1z"/><path style="fill:#f3e600" d="M101.59 18.23 79.32 0 57.05 18.23l22.27 18.75 22.27-18.75z"/></symbol></defs><use width="160" height="187.74" xlink:href="#g"/></svg>`;
352
375
  const iconDir = process.cwd();
353
- for (const size of sizes) {
354
- const iconName = "pwa-icon-" + size + ".svg";
355
- const iconPath = iconDir + "/" + iconName;
356
- writeFileSync(iconPath, defaultIconSVG, 'utf8');
357
- context.generatedFiles.push(iconPath);
358
- const iconSrc = "./" + iconName;
359
- log("Generated PWA icon", iconSrc);
360
- const iconInfo = {
361
- src: iconSrc,
362
- type: "image/svg+xml",
363
- sizes: size + "x" + size,
364
- "purpose": "any"
365
- };
366
- manifest.icons.push(iconInfo);
367
- manifest.icons.push({
368
- ...iconInfo,
369
- purpose: "maskable"
370
- })
371
- }
376
+
377
+ const iconName = "pwa-icon-allSizes.svg";
378
+ const iconPath = iconDir + "/" + iconName;
379
+ writeFileSync(iconPath, defaultIconSVG, 'utf8');
380
+ const iconSrc = "./" + iconName;
381
+ // log("Generated PWA icon", iconSrc);
382
+ const iconInfo = {
383
+ src: iconSrc,
384
+ type: "image/svg+xml",
385
+ sizes: sizes.map(s => s + "x" + s).join(" "),
386
+ "purpose": "any"
387
+ };
388
+ manifest.icons.push(iconInfo);
389
+ context.generatedFiles.push(iconPath);
372
390
  }
373
391
 
374
392
  /** Tries to copy the icons to the output directory
393
+ * TODO this should not be needed if we use pwaOptions.includeAssets
375
394
  * @param {Partial<import("vite-plugin-pwa").ManifestOptions>} manifest
376
395
  */
377
396
  function copyIcons(manifest, outDir) {
src/engine-components/ContactShadows.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { CustomBlending, DoubleSide, FrontSide, Group, Matrix4, MaxEquation, Mesh, MeshBasicMaterial, MeshDepthMaterial, MinEquation, Object3D, OrthographicCamera, PlaneGeometry, RenderItem, ShaderMaterial, WebGLRenderTarget } from "three";
1
+ import { CustomBlending, DoubleSide, FrontSide, Group, Matrix4, MaxEquation, Mesh, MeshBasicMaterial, MeshDepthMaterial, MinEquation, Object3D, OrthographicCamera, PlaneGeometry, RenderItem, ShaderMaterial, Vector3, WebGLRenderTarget } from "three";
2
2
  import { HorizontalBlurShader } from 'three/examples/jsm/shaders/HorizontalBlurShader.js';
3
3
  import { VerticalBlurShader } from 'three/examples/jsm/shaders/VerticalBlurShader.js';
4
4
 
5
+ import { addComponent } from "../engine/engine_components.js";
6
+ import { Context } from "../engine/engine_context.js";
7
+ import { Gizmos } from "../engine/engine_gizmos.js";
5
8
  import { serializable } from "../engine/engine_serialization_decorator.js";
9
+ import { getBoundingBox } from "../engine/engine_three_utils.js";
6
10
  import { getParam } from "../engine/engine_utils.js"
7
11
  import { setCustomVisibility } from "../engine/js-extensions/Layers.js";
8
12
  import { Behaviour } from "./Component.js";
@@ -21,7 +25,33 @@
21
25
  */
22
26
  export class ContactShadows extends Behaviour {
23
27
 
28
+ private static _instance: ContactShadows;
29
+ /**
30
+ * Create contact shadows for the scene. Automatically fits the shadows to the scene.
31
+ * The instance of contact shadows will be created only once.
32
+ * @param context The context to create the contact shadows in.
33
+ * @returns The instance of the contact shadows.
34
+ */
35
+ static auto(context?: Context): ContactShadows {
36
+ if (!this._instance || this._instance.destroyed) {
37
+ const obj = new Object3D();
38
+ this._instance = addComponent(obj, ContactShadows, {
39
+ autoFit: false,
40
+ occludeBelowGround: false
41
+ });
42
+ }
43
+ if (!context) context = Context.Current;
44
+ context.scene.add(this._instance.gameObject);
45
+ this._instance.fitShadows();
46
+ return this._instance;
47
+ }
48
+
49
+ /**
50
+ * When enabled the contact shadows component will be created to fit the whole scene.
51
+ */
24
52
  @serializable()
53
+ autoFit: boolean = false;
54
+ @serializable()
25
55
  darkness: number = 0.5;
26
56
  @serializable()
27
57
  opacity: number = 0.5;
@@ -46,9 +76,22 @@
46
76
  private horizontalBlurMaterial?: ShaderMaterial;
47
77
  private verticalBlurMaterial?: ShaderMaterial;
48
78
 
79
+ /**
80
+ * Call to fit the shadows to the scene.
81
+ */
82
+ fitShadows() {
83
+ if (debug) console.log("Fitting shadows to scene");
84
+ const box = getBoundingBox(this.context.scene.children, [this.gameObject]);
85
+ if (debug) Gizmos.DrawWireBox3(box, 0xffff00, 1);
86
+ const min = box.min;
87
+ this.gameObject.position.set((min.x + box.max.x) / 2, min.y - .01, (min.z + box.max.z) / 2);
88
+ this.gameObject.scale.set(box.max.x - min.x, 1, box.max.z - min.z);
89
+ this.gameObject.scale.multiplyScalar(1.5);
90
+ }
91
+
49
92
  /** @internal */
50
93
  start(): void {
51
- if(debug) console.log("Create ContactShadows on " + this.gameObject.name, this)
94
+ if (debug) console.log("Create ContactShadows on " + this.gameObject.name, this)
52
95
  const textureSize = 512;
53
96
 
54
97
  this.shadowGroup = new Group();
@@ -64,14 +107,16 @@
64
107
 
65
108
  // make a plane and make it face up
66
109
  const planeGeometry = new PlaneGeometry(1, 1).rotateX(Math.PI / 2);
67
-
110
+
68
111
  if (this.gameObject instanceof Mesh) {
69
- if (debug) console.log("ContactShadows: use existing mesh", this.gameObject)
70
- this.plane = this.gameObject as any as Mesh;
71
- // Make sure we clone the material once because it might be used on another object as well
72
- const mat = this.plane.material = (this.plane.material as MeshBasicMaterial).clone();
73
- mat.map = this.renderTarget.texture;
74
- mat.opacity = this.opacity;
112
+ console.warn("ContactShadows can not be added to a Mesh. Please add it to a Group or an empty Object");
113
+ // this.enabled = false;
114
+ setCustomVisibility(this.gameObject, false);
115
+ // this.plane = this.gameObject as any as Mesh;
116
+ // // Make sure we clone the material once because it might be used on another object as well
117
+ // const mat = this.plane.material = (this.plane.material as MeshBasicMaterial).clone();
118
+ // mat.map = this.renderTarget.texture;
119
+ // mat.opacity = this.opacity;
75
120
  // mat.transparent = true;
76
121
  // mat.depthWrite = false;
77
122
  // mat.needsUpdate = true;
@@ -80,33 +125,30 @@
80
125
  // mat.transparent = true;
81
126
  // mat.depthWrite = false;
82
127
  }
83
- else {
84
- const planeMaterial = new MeshBasicMaterial({
85
- map: this.renderTarget.texture,
86
- opacity: this.opacity,
87
- color: 0x000000,
88
- transparent: true,
89
- depthWrite: false,
90
- });
91
- this.plane = new Mesh(planeGeometry, planeMaterial);
92
- this.plane.scale.y = - 1;
93
- this.gameObject.add(this.plane);
94
- }
128
+
129
+ const planeMaterial = new MeshBasicMaterial({
130
+ map: this.renderTarget.texture,
131
+ opacity: this.opacity,
132
+ color: 0x000000,
133
+ transparent: true,
134
+ depthWrite: false,
135
+ });
136
+ this.plane = new Mesh(planeGeometry, planeMaterial);
137
+ this.plane.scale.y = - 1;
138
+ this.gameObject.add(this.plane);
139
+
95
140
  if (this.plane) this.plane.renderOrder = 1;
96
141
 
142
+ this.occluderMesh = new Mesh(this.plane.geometry, new MeshBasicMaterial({
143
+ depthWrite: true,
144
+ stencilWrite: true,
145
+ colorWrite: false,
146
+ }))
147
+ // .rotateX(Math.PI)
148
+ .translateY(-0.0001);
149
+ this.occluderMesh.renderOrder = -100;
150
+ this.gameObject.add(this.occluderMesh);
97
151
 
98
- if (this.occludeBelowGround) {
99
- this.occluderMesh = new Mesh(this.plane.geometry, new MeshBasicMaterial({
100
- depthWrite: true,
101
- stencilWrite: true,
102
- colorWrite: false,
103
- }))
104
- // .rotateX(Math.PI)
105
- .translateY(-0.0001);
106
- this.occluderMesh.renderOrder = -100;
107
- this.gameObject.add(this.occluderMesh);
108
- }
109
-
110
152
  // the plane onto which to blur the texture
111
153
  this.blurPlane = new Mesh(planeGeometry);
112
154
  this.blurPlane.visible = false;
@@ -123,8 +165,6 @@
123
165
  // this will properly overlap calculated shadows
124
166
  this.depthMaterial.blending = CustomBlending;
125
167
  this.depthMaterial.blendEquation = MaxEquation;
126
- if (this.backfaceShadows)
127
- this.depthMaterial.side = DoubleSide;
128
168
 
129
169
  // this.depthMaterial.blendEquation = MinEquation;
130
170
  this.depthMaterial.onBeforeCompile = shader => {
@@ -150,6 +190,8 @@
150
190
  this.verticalBlurMaterial.depthTest = false;
151
191
 
152
192
  this.shadowGroup.visible = false;
193
+
194
+ if (this.autoFit) this.fitShadows();
153
195
  }
154
196
 
155
197
  /** @internal */
@@ -197,7 +239,7 @@
197
239
  this.plane.visible = false;
198
240
 
199
241
  if (this.gameObject instanceof Mesh) {
200
- this.gameObject.visible = false;
242
+ // this.gameObject.visible = false;
201
243
  setCustomVisibility(this.gameObject, false);
202
244
  }
203
245
 
@@ -207,6 +249,11 @@
207
249
 
208
250
  // force the depthMaterial to everything
209
251
  scene.overrideMaterial = this.depthMaterial;
252
+ if (this.backfaceShadows)
253
+ this.depthMaterial.side = DoubleSide;
254
+ else {
255
+ this.depthMaterial.side = FrontSide;
256
+ }
210
257
 
211
258
  // set renderer clear alpha
212
259
  const initialClearAlpha = renderer.getClearAlpha();
@@ -240,7 +287,7 @@
240
287
  this.blurShadow(this.blur * 0.4);
241
288
 
242
289
  this.shadowGroup.visible = false;
243
- if (this.occluderMesh) this.occluderMesh.visible = true;
290
+ if (this.occluderMesh) this.occluderMesh.visible = this.occludeBelowGround;
244
291
  this.plane.visible = planeWasVisible;
245
292
 
246
293
  // reset and render the normal scene
src/engine/engine_context.ts CHANGED
@@ -58,6 +58,7 @@
58
58
  export declare class ContextCreateArgs {
59
59
  /** list of glTF or GLB files to load */
60
60
  files: Array<string>;
61
+ abortSignal?: AbortSignal;
61
62
  /** called when loading a provided glTF file started */
62
63
  onLoadingStart?: (index: number, file: string) => void;
63
64
  /** called on update for each loaded glTF file */
@@ -821,7 +822,9 @@
821
822
  prepare_succeeded = false;
822
823
  }
823
824
  if (!prepare_succeeded) return false;
824
- if (createId !== this._createId) return false;
825
+ if (createId !== this._createId || opts?.abortSignal?.aborted) {
826
+ return false;
827
+ }
825
828
 
826
829
  this.internalOnUpdateVisible();
827
830
 
@@ -946,11 +949,14 @@
946
949
  this.domElement?.internalSetLoadingMessage("finish loading");
947
950
  await res;
948
951
  }
952
+ if (opts?.abortSignal?.aborted) {
953
+ return false;
954
+ }
949
955
  invokeLifecycleFunctions(this, ContextEvent.ContextCreated);
950
956
  if (debug) console.log("Context Created...", this.renderer, this.renderer.domElement)
951
957
 
952
958
  this._isCreating = false;
953
- if (!this.isManagedExternally)
959
+ if (!this.isManagedExternally && !opts?.abortSignal?.aborted)
954
960
  this.restartRenderLoop();
955
961
  return res;
956
962
  }
@@ -973,6 +979,10 @@
973
979
  // this hash should be constant since it is used to initialize the UIDProvider per initially loaded scene
974
980
  const loadingHash = 0;
975
981
  for (let i = 0; i < files.length; i++) {
982
+ if (args.abortSignal?.aborted) {
983
+ if (debug) console.log("Aborting loading because of abort signal");
984
+ break;
985
+ }
976
986
  // abort loading if the create id has changed
977
987
  if (createId !== this._createId) {
978
988
  if (debug) console.log("Aborting loading because create id changed", createId, this._createId);
@@ -982,6 +992,7 @@
982
992
  args?.onLoadingStart?.call(this, i, file);
983
993
  if (debug) console.log("Context Load " + file);
984
994
  const res = await loader.loadSync(this, file, file, loadingHash, prog => {
995
+ if (args.abortSignal?.aborted) return;
985
996
  progressArg.name = file;
986
997
  progressArg.progress = prog;
987
998
  progressArg.index = i;
@@ -1003,7 +1014,7 @@
1003
1014
 
1004
1015
  // if the id was changed while still loading
1005
1016
  // then we want to cleanup/destroy previously loaded files
1006
- if (createId !== this._createId) {
1017
+ if (createId !== this._createId || args.abortSignal?.aborted) {
1007
1018
  for (const res of results) {
1008
1019
  if (res && res.file) {
1009
1020
  for (const scene of res.file.scenes)
src/engine/engine_element_loading.ts CHANGED
@@ -208,7 +208,8 @@
208
208
  this._loadingElement.style.flexDirection = "column";
209
209
  this._loadingElement.style.pointerEvents = "none";
210
210
  this._loadingElement.style.color = "white";
211
- this._loadingElement.style.fontFamily = "Roboto, sans-serif, Arial";
211
+ this._loadingElement.style.fontFamily = 'system-ui, Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
212
+ this._loadingElement.style.fontSize = "1rem";
212
213
  if (loadingStyle === "light")
213
214
  this._loadingElement.style.color = "rgba(0,0,0,.6)";
214
215
  else
@@ -332,9 +333,8 @@
332
333
  const messageContainer = document.createElement("div");
333
334
  this._messageContainer = messageContainer;
334
335
  messageContainer.style.display = "flex";
335
- messageContainer.style.fontSize = ".8em";
336
+ messageContainer.style.fontSize = ".8rem";
336
337
  messageContainer.style.paddingTop = ".2rem";
337
- messageContainer.style.fontWeight = "200";
338
338
  // messageContainer.style.border = "1px solid rgba(255,255,255,.1)";
339
339
  messageContainer.style.justifyContent = "center";
340
340
  details.appendChild(messageContainer);
src/engine/engine_element.ts CHANGED
@@ -261,7 +261,7 @@
261
261
  if (debug) console.log("attributeChangedCallback", name, _oldValue, newValue);
262
262
  switch (name) {
263
263
  case "src":
264
- if (debug) console.warn("src changed to type:", typeof newValue, ", from \"", _oldValue, "\" to \"", newValue, "\"")
264
+ if (debug) console.warn("<needle-engine src>\nchanged from \"", _oldValue, "\" to \"", newValue, "\"")
265
265
  this.onLoad();
266
266
  // this._watcher?.onSourceChanged(newValue);
267
267
  break;
@@ -297,7 +297,9 @@
297
297
  }
298
298
 
299
299
  private _loadId: number = 0;
300
+ private _abortController: AbortController | null = null;
300
301
  private _lastSourceFiles: Array<string> | null = null;
302
+ private _createContextPromise: Promise<any> | null = null;
301
303
 
302
304
  private async onLoad() {
303
305
 
@@ -313,16 +315,25 @@
313
315
  }
314
316
 
315
317
  const filesToLoad = this.getSourceFiles();
318
+ if (!this.checkIfSourceHasChanged(filesToLoad, this._lastSourceFiles)) {
319
+ return;
320
+ }
321
+
322
+ // Abort previous loading (if it's still running)
323
+ if (this._abortController) {
324
+ if (debug) console.warn("Abort previous loading process")
325
+ this._abortController.abort();
326
+ this._abortController = null;
327
+ }
328
+ this._lastSourceFiles = filesToLoad;
329
+ const loadId = ++this._loadId;
330
+
316
331
  if (filesToLoad === null || filesToLoad === undefined || filesToLoad.length <= 0) {
317
332
  if (debug) console.warn("Clear scene", filesToLoad);
318
333
  this._context.clear();
334
+ if (loadId !== this._loadId) return;
319
335
  }
320
- else if (!this.checkIfSourceHasChanged(filesToLoad, this._lastSourceFiles)) {
321
- return;
322
- }
323
336
 
324
- this._lastSourceFiles = filesToLoad;
325
- const loadId = ++this._loadId;
326
337
  const alias = this.getAttribute("alias");
327
338
  this.classList.add("loading");
328
339
 
@@ -371,7 +382,7 @@
371
382
  }, 300)
372
383
  }
373
384
  }
374
- if (debug) console.warn("--------------", loadId, "Needle Engine: Begin loading", alias ?? "", filesToLoad);
385
+ if (debug) console.warn("--------------\nNeedle Engine: Begin loading " + loadId + "\n", filesToLoad);
375
386
  this.onBeforeBeginLoading();
376
387
 
377
388
  const loadedFiles: Array<LoadedGLTF> = [];
@@ -385,9 +396,13 @@
385
396
  };
386
397
  const progressEvent = new CustomEvent("progress", { detail: progressEventDetail });
387
398
  const displayNames = new Array<string>();
399
+ const controller = new AbortController();
400
+ this._abortController = controller;
388
401
  const args: ContextCreateArgs = {
389
402
  files: filesToLoad,
403
+ abortSignal: controller.signal,
390
404
  onLoadingProgress: evt => {
405
+ if (controller.signal.aborted) return;
391
406
  const index = evt.index;
392
407
  if (!displayNames[index] && evt.name) {
393
408
  displayNames[index] = getDisplayName(evt.name);
@@ -401,6 +416,7 @@
401
416
  this.dispatchEvent(progressEvent);
402
417
  },
403
418
  onLoadingFinished: (_index, file, glTF) => {
419
+ if (controller.signal.aborted) return;
404
420
  if (glTF) {
405
421
  loadedFiles.push({
406
422
  src: file,
@@ -414,9 +430,14 @@
414
430
  if (currentHash !== null && currentHash !== undefined)
415
431
  this._context.hash = currentHash;
416
432
  this._context.alias = alias;
417
- await this._context.create(args);
418
- if (this._loadId !== loadId) return;
419
- if (debug) console.warn("--------------", loadId, "Needle Engine: finished loading", alias ?? "", filesToLoad);
433
+ this._createContextPromise = this._context.create(args);
434
+ await this._createContextPromise;
435
+ if (debug) console.warn("--------------\nNeedle Engine: finished loading " + loadId + "\n", filesToLoad, `Aborted? ${controller.signal.aborted}`);
436
+ if (this._loadId !== loadId || controller.signal.aborted) {
437
+ console.log("Loading finished but aborted...")
438
+ return;
439
+ }
440
+
420
441
  this._loadingProgress01 = 1;
421
442
  if (useDefaultLoading) {
422
443
  this._loadingView?.onLoadingUpdate(1, "creating scene");
src/engine/engine_three_utils.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { AnimationAction, Euler, Mesh, Object3D, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, Texture, Uniform, Vector3 } from "three";
1
+ import { AnimationAction, Box3, Box3Helper, Euler, GridHelper, Mesh, Object3D, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, ShadowMaterial, Texture, Uniform, Vector3 } from "three";
2
2
  import { ShaderMaterial, WebGLRenderer } from "three";
3
+ import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
3
4
 
5
+ import { useForAutoFit } from "./engine_camera.js";
4
6
  import { Mathf } from "./engine_math.js"
5
7
  import { CircularBuffer } from "./engine_utils.js";
6
8
 
@@ -445,3 +447,66 @@
445
447
  (typeof OffscreenCanvas !== 'undefined' && image instanceof OffscreenCanvas) ||
446
448
  (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap);
447
449
  }
450
+
451
+
452
+
453
+
454
+
455
+ export function getBoundingBox(objects: Object3D[], ignore: Object3D[] = []): Box3 {
456
+ const box = new Box3();
457
+ box.makeEmpty();
458
+
459
+ const emptyChildren = [];
460
+ function expandByObjectRecursive(obj: Object3D) {
461
+ let allowExpanding = true;
462
+ // we dont want to check invisible objects
463
+ if (!obj.visible) return;
464
+ if (useForAutoFit(obj) === false) return;
465
+ if (obj.type === "TransformControlsGizmo" || obj.type === "TransformControlsPlane") return;
466
+ // ignore Box3Helpers
467
+ if (obj instanceof Box3Helper) allowExpanding = false;
468
+ if (obj instanceof GridHelper) allowExpanding = false;
469
+ // ignore GroundProjectedEnv
470
+ if (obj instanceof GroundedSkybox) allowExpanding = false;
471
+ // // Ignore shadow catcher geometry
472
+ if ((obj as Mesh).material instanceof ShadowMaterial) allowExpanding = false;
473
+ // ONLY fit meshes
474
+ if (!(obj instanceof Mesh)) allowExpanding = false;
475
+ // Ignore things parented to the camera + ignore the camera
476
+ if (ignore?.includes(obj)) return;
477
+ // We don't want to fit UI objects
478
+ if (obj["isUI"] === true) return;
479
+ // If we encountered some geometry that should be ignored
480
+ // Then we don't want to use that for expanding the view
481
+ if (allowExpanding) {
482
+ // Temporary override children
483
+ const children = obj.children;
484
+ obj.children = emptyChildren;
485
+ // TODO: validate that object doesn't contain NaN values
486
+ const pos = obj.position;
487
+ const scale = obj.scale;
488
+ if (Number.isNaN(pos.x) || Number.isNaN(pos.y) || Number.isNaN(pos.z)) {
489
+ console.warn(`Object \"${obj.name}\" has NaN values in position or scale.... will ignore it`, pos, scale);
490
+ return;
491
+ }
492
+ box.expandByObject(obj, true);
493
+ obj.children = children;
494
+ }
495
+ for (const child of obj.children) {
496
+ expandByObjectRecursive(child);
497
+ }
498
+ }
499
+ let hasAnyObject = false;
500
+ for (const object of objects) {
501
+ if (!object) continue;
502
+ hasAnyObject = true;
503
+ object.updateMatrixWorld();
504
+ expandByObjectRecursive(object);
505
+ }
506
+ if (!hasAnyObject) {
507
+ console.warn("No objects to fit camera to...");
508
+ return box;
509
+ }
510
+
511
+ return box;
512
+ }
plugins/types/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export * from './userconfig';
2
- export * from "./needleConfig";
3
- export * from "./webmanifest";
1
+ export type * from "./needleConfig.d.ts";
2
+ export type * from './userconfig.d.ts';
3
+ export type * from "./webmanifest.d.ts";
src/engine/js-extensions/Layers.ts CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
 
4
4
  const $customVisibilityFlag = Symbol("customVisibilityFlag");
5
+
6
+ /**
7
+ * Sets the visibility of a single object without affecting the visibility of the child hierarchy
8
+ */
5
9
  export function setCustomVisibility(obj: Object3D, visible: boolean) {
6
10
  obj.layers[$customVisibilityFlag] = visible;
7
11
  }
src/engine-components/OrbitControls.ts CHANGED
@@ -7,7 +7,7 @@
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
- import { getWorldDirection, getWorldPosition, getWorldRotation, setWorldRotation } from "../engine/engine_three_utils.js";
10
+ import { getBoundingBox, getWorldDirection, getWorldPosition, getWorldRotation, setWorldRotation } from "../engine/engine_three_utils.js";
11
11
  import type { ICameraController } from "../engine/engine_types.js";
12
12
  import { delayForFrames, getParam, isMobileDevice } from "../engine/engine_utils.js";
13
13
  import { Camera } from "./Camera.js";
@@ -607,68 +607,12 @@
607
607
 
608
608
  const size = new Vector3();
609
609
  const center = new Vector3();
610
- const box = new Box3();
611
-
612
610
  // TODO would be much better to calculate the bounds in camera space instead of world space -
613
611
  // we would get proper view-dependant fit.
614
612
  // Right now it's independent from where the camera is actually looking from,
615
613
  // and thus we're just getting some maximum that will work for sure.
614
+ const box = getBoundingBox(objects);
616
615
 
617
- box.makeEmpty();
618
- const emptyChildren = [];
619
- function expandByObjectRecursive(obj: Object3D) {
620
- let allowExpanding = true;
621
- // we dont want to check invisible objects
622
- if (!obj.visible) return;
623
- if (useForAutoFit(obj) === false) return;
624
- if(obj.type === "TransformControlsGizmo" || obj.type === "TransformControlsPlane") return;
625
- // ignore Box3Helpers
626
- if (obj instanceof Box3Helper) allowExpanding = false;
627
- if (obj instanceof GridHelper) allowExpanding = false;
628
- // ignore GroundProjectedEnv
629
- if (obj instanceof GroundedSkybox) allowExpanding = false;
630
- // // Ignore shadow catcher geometry
631
- if ((obj as Mesh).material instanceof ShadowMaterial) allowExpanding = false;
632
- // ONLY fit meshes
633
- if (!(obj instanceof Mesh)) allowExpanding = false;
634
- // Ignore things parented to the camera + ignore the camera
635
- if (obj === camera) return;
636
- // We don't want to fit UI objects
637
- if (obj["isUI"] === true) return;
638
- // If we encountered some geometry that should be ignored
639
- // Then we don't want to use that for expanding the view
640
- if (allowExpanding) {
641
- if (debugCameraFit)
642
- console.log(obj.name, obj.type, obj);
643
- // Temporary override children
644
- const children_length = obj.children;
645
- obj.children = emptyChildren;
646
- // TODO: validate that object doesn't contain NaN values
647
- const pos = obj.position;
648
- const scale = obj.scale;
649
- if (Number.isNaN(pos.x) || Number.isNaN(pos.y) || Number.isNaN(pos.z)) {
650
- console.warn(`Object \"${obj.name}\" has NaN values in position or scale.... will ignore it`, pos, scale);
651
- return;
652
- }
653
- box.expandByObject(obj, true);
654
- obj.children = children_length;
655
- }
656
- for (const child of obj.children) {
657
- expandByObjectRecursive(child);
658
- }
659
- }
660
- let hasAnyObject = false;
661
- for (const object of objects) {
662
- if (!object) continue;
663
- hasAnyObject = true;
664
- object.updateMatrixWorld();
665
- expandByObjectRecursive(object);
666
- }
667
- if (!hasAnyObject) {
668
- console.warn("No objects to fit camera to...");
669
- return;
670
- }
671
-
672
616
  camera.updateMatrixWorld();
673
617
  camera.updateProjectionMatrix();
674
618
  box.getCenter(center);
plugins/types/userconfig.d.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import { NeedlePWAOptions } from "./webmanifest.d.ts";
1
2
 
2
-
3
3
  export type needleModules = {
4
4
  webpack: object | undefined
5
5
  }
@@ -50,11 +50,9 @@
50
50
  */
51
51
  posterGenerationMode?: "default" | "once";
52
52
 
53
- /** When set to true the needle pwa plugin will not run */
54
- noPWA?: boolean;
55
- /** Pass in the VitePWA options */
56
- /** @type {import("vite-plugin-pwa").VitePWAOptions} */
57
- pwaOptions?: {};
53
+ /** Pass in a mix of VitePWA and NeedlePWA options, or "false" */
54
+ /** @type {import("vite-plugin-pwa").VitePWAOptions & Partial<NeedlePWAOptions> | false} */
55
+ pwa?: undefined;
58
56
 
59
57
  /** used by nextjs config to forward the webpack module */
60
58
  modules: needleModules
plugins/types/webmanifest.d.ts CHANGED
@@ -3,8 +3,11 @@
3
3
 
4
4
  declare type Icon = {
5
5
  src: string;
6
+ /** string with concatenated size options, e.g. "48x48 96x96 192x192" */
6
7
  sizes: string;
8
+ /** MIME type, e.g. image/svg+xml */
7
9
  type: string;
10
+ /** Icon purpose, e.g. "any" or "maskable", which have different safe zones. */
8
11
  purpose: string;
9
12
  }
10
13
 
@@ -12,15 +15,19 @@
12
15
  start_url: string;
13
16
  name?: string;
14
17
  short_name?: string;
18
+ /** Unique ID – make sure that apps that live on the same subdomain use different IDs. */
15
19
  id?: string;
16
20
  icons?: Array<Icon>;
21
+ /** Workbox config as used by vite-pwa */
17
22
  workbox?: object;
18
23
  }
19
24
 
20
25
  export declare type NeedlePWAOptions = {
21
-
26
+ /** Update check interval in milliseconds. After this time, a check is done if a new version is available. */
27
+ updateInterval?: number;
22
28
  }
23
29
 
24
30
  export declare type NeedlePWAProcessContext = {
31
+ /** Temporarily generated files that will be cleaned up after PWA export */
25
32
  generatedFiles: Array<string>;
26
33
  }