Needle Engine

Changes between version 3.37.8-alpha.2 and 3.37.9-alpha
Files changed (9) hide show
  1. plugins/vite/pwa.js +187 -149
  2. src/engine-components/AnimatorController.ts +55 -15
  3. src/engine/engine_input.ts +15 -6
  4. src/engine/engine_scenetools.ts +4 -35
  5. src/engine/extensions/extensions.ts +34 -18
  6. src/engine/extensions/NEEDLE_animator_controller_model.ts +3 -2
  7. src/engine/codegen/register_types.ts +2 -2
  8. plugins/types/userconfig.d.ts +1 -0
  9. src/engine-components/webxr/WebXRImageTracking.ts +46 -3
plugins/vite/pwa.js CHANGED
@@ -1,99 +1,78 @@
1
1
 
2
- import { resolve, join, isAbsolute } from 'path'
3
- import { readdirSync, existsSync, readFileSync, copyFileSync, writeFileSync, rmSync, mkdirSync } from 'fs';
4
- import { builtAssetsDirectory, getOutputDirectory, tryLoadProjectConfig } from './config.js';
2
+ import { copyFileSync, existsSync, mkdirSync,readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
5
3
 
4
+ import { getOutputDirectory } from './config.js';
5
+ import { getPosterPath } from './poster.js';
6
6
 
7
7
 
8
- /** Process the PWA manifest
8
+ /** Provides reasonable defaults for a PWA manifest and workbox settings.
9
9
  * @param {import('../types').userSettings} userSettings
10
- * @returns {import('vite').Plugin}
10
+ * @param {import("../types/needleConfig").needleMeta | null} config
11
+ * @param {import("../types/userconfig").userSettings | null} userSettings
12
+ * @returns {import('vite').Plugin | void}
11
13
  */
12
14
  export const needlePWA = (command, config, userSettings) => {
13
- if (command !== "build") return;
14
-
15
- if (config?.noPWA === true || userSettings?.noPWA === true) {
16
- return;
15
+ // @ts-ignore // TODO correctly type the userSettings.pwaOptions object
16
+ /** @type {import("vite-plugin-pwa").VitePWAOptions} */
17
+ const pwaOptions = userSettings.pwaOptions;
18
+
19
+ if (!pwaOptions) {
20
+ 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)\"");
21
+ return cleanup(context);
17
22
  }
23
+
24
+ // @ts-ignore // TODO need an extra type so we can add more options into the VitePWAOptions object
25
+ /** @type {number | undefined} */
26
+ const updateInterval = pwaOptions.updateInterval || undefined;
18
27
 
19
- const pwaOptions = userSettings.pwaOptions;
28
+ if ((command !== "build" && !pwaOptions?.devOptions.enabled) || pwaOptions?.disable) return;
29
+ if (userSettings?.noPWA === true) return;
20
30
 
21
- const currentDir = process.cwd();
22
- const manifests = readdirSync(currentDir).filter(f => f.endsWith(".webmanifest")) || [];
23
- if (manifests.length <= 0 && !pwaOptions) {
24
- log("No webmanifests found in web project directory or in pwa options that are passed to the needle plugin (via `needlePlugin(command, needleConfig, { pwaOptions: PWAOptions })`)");
25
- return;
26
- }
31
+ // The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
32
+ // If gzip is wanted, the server should serve files with gzip compression on the fly.
33
+ if (config) config.gzip = false;
27
34
 
28
35
  /** The context contains files that are generated by the plugin and should be deleted after
29
36
  * @type {import('../types').NeedlePWAProcessContext} */
30
37
  const context = { generatedFiles: [] }
31
38
 
32
- if (manifests.length <= 0) {
33
- // write the manifes to disc temporarely
34
- log("No webmanifests found in web project directory. Generating temporary webmanifest...");
35
- const manifestName = "needle.generated.webmanifest";
36
- const tempManifestPath = currentDir + "/" + manifestName;
37
- const content = JSON.stringify(pwaOptions.manifest || {});
38
- writeFileSync(tempManifestPath, content, 'utf8');
39
- context.generatedFiles.push(tempManifestPath);
40
- manifests.push(manifestName);
39
+ 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");
41
+ pwaOptions.registerType = "autoUpdate";
41
42
  }
43
+ if (!pwaOptions.outDir) {
44
+ const outDir = getOutputDirectory();
45
+ log("Set PWA outDir to " + outDir);
46
+ pwaOptions.outDir = outDir;
47
+ }
42
48
 
43
- if (!pwaOptions) {
44
- 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)\"");
45
- return cleanup(context);
49
+ const currentDir = process.cwd();
50
+
51
+ /** @type {Array<string | Partial<import("vite-plugin-pwa").ManifestOptions>>} */
52
+ const manifests = readdirSync(currentDir).filter(f => f.endsWith(".webmanifest")) || [];
53
+
54
+ if (manifests.length > 1) {
55
+ log("WARN: Multiple webmanifests found in web project directory. Only the first one will be processed: ", manifests[0]);
46
56
  }
47
- else {
48
- if (!pwaOptions.registerType) {
49
- 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
- pwaOptions.registerType = "autoUpdate";
51
- }
52
- if (!pwaOptions.outDir) {
53
- const outDir = getOutputDirectory();
54
- log("Set PWA outDir to " + outDir);
55
- pwaOptions.outDir = outDir;
56
- }
57
+
58
+ if (manifests.length <= 0 && !pwaOptions) {
59
+ log("No webmanifests found in web project directory or in pwa options that are passed to the needle plugin (via `needlePlugin(command, needleConfig, { pwaOptions: PWAOptions })`)");
60
+ return;
57
61
  }
58
62
 
59
- // backup the original manifests
60
- /** @type {Array<{path:string, copypath:string}>} */
61
- const originalManifestContents = manifests.map(filename => {
62
- const path = currentDir + "/" + filename;
63
- const backupDir = currentDir + "/node_modules/.needle";
64
- if (!existsSync(backupDir)) {
65
- mkdirSync(backupDir, { recursive: true });
66
- }
67
- const copyPath = backupDir + "/" + filename + ".original";
68
- log("Backup original webmanifest", path, "to", copyPath);
69
- copyFileSync(path, copyPath);
70
- return { path, copyPath };
71
- });
63
+ const customManifest = manifests.length > 0 ? manifests[0] : {};
72
64
 
65
+ // ensure we have proper icons/name/description to match user settings
66
+ processPWA(customManifest, context, pwaOptions, config, userSettings).catch(e => log("Error post processing PWA", customManifest, e));
67
+ // ensures we have a valid workbox config
68
+ processWorkboxConfig(pwaOptions);
73
69
 
74
- // modify the manifests so they're loaded via VitePlugin
75
- for (const manifest of manifests) {
76
- try {
77
- processPWA(manifest, context).catch(e => log("Error post processing PWA", manifest, e));
78
- log("Assign PWA manifest to VitePWA plugin: \"" + manifest + "\"");
79
- pwaOptions.manifest = {
80
- start_url: "./index.html",
81
- ...JSON.parse(readFileSync(manifest, 'utf8')),
82
- ...pwaOptions.manifest
83
- };
84
- // we only want to process the first manifest
85
- break;
86
- }
87
- catch (e) {
88
- log("Error post processing PWA", manifest, e);
89
- restoreOriginalManifests(originalManifestContents);
90
- break;
91
- }
92
- }
93
-
94
70
  /** @type {Array<string>} */
95
71
  const postBuildMessages = [];
96
72
 
73
+ // for debugging
74
+ // log("PWA options", pwaOptions);
75
+
97
76
  return {
98
77
  name: 'needle-pwa',
99
78
  apply: 'build',
@@ -108,7 +87,7 @@
108
87
  if (existsSync(indexPath)) {
109
88
  const indexContent = readFileSync(indexPath, 'utf8');
110
89
  if (indexContent.includes(".webmanifest")) {
111
- throw new Error("ERR: [needle-pwa] index.html contains a reference to a webmanifest. This is not supported in PWA mode. Please remove the reference from the index.html");
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.");
112
91
  }
113
92
  }
114
93
  }
@@ -116,9 +95,6 @@
116
95
  cleanup(context);
117
96
  throw err;
118
97
  }
119
- finally {
120
- restoreOriginalManifests(originalManifestContents);
121
- }
122
98
  },
123
99
  transformIndexHtml: {
124
100
  enforce: 'pre',
@@ -126,10 +102,59 @@
126
102
  // see https://vite-pwa-org.netlify.app/guide/auto-update.html
127
103
  // post transform so we want to linebreak after the vite logs
128
104
  console.log("\n");
129
- const scriptContent = `// Injected by needle-pwa
130
- import { registerSW } from 'virtual:pwa-register'
131
- registerSW({ immediate: true })`;
132
- log("Inject PWA script into index.html");
105
+ const scriptContent = `// 🌵 Added by the [needle-pwa] plugin.
106
+ import { registerSW } from 'virtual:pwa-register';
107
+ const debugServiceWorker = new URLSearchParams(window.location.search).get('debugpwa') !== null;
108
+ ${updateInterval !== undefined ? `const intervalMS = ${updateInterval};` : ''}
109
+ let updateSW;
110
+ updateSW = registerSW({
111
+ immediate: true,
112
+ onRegisteredSW(swUrl, r) {
113
+ // console.log('[needle-pwa] Service worker for PWA support has been registered.');
114
+ ${updateInterval !== undefined ? `
115
+ r && setInterval(async () => {
116
+ if (debugServiceWorker) console.log('[needle-pwa] Checking for updates...');
117
+
118
+ if (!(!r.installing && navigator)) {
119
+ if (debugServiceWorker) console.log('[needle-pwa] Service worker is not ready yet');
120
+ return;
121
+ }
122
+
123
+ // navigator.onLine is currently broken in Chrome
124
+ /*
125
+ if (('connection' in navigator) && !navigator.onLine) {
126
+ // console.log('[needle-pwa] Service worker update check skipped – offline');
127
+ return;
128
+ }
129
+ */
130
+
131
+ const resp = await fetch(swUrl, {
132
+ cache: 'no-store',
133
+ headers: {
134
+ 'cache': 'no-store',
135
+ 'cache-control': 'no-cache',
136
+ },
137
+ });
138
+
139
+ if (resp?.status === 200) {
140
+ // if there's actually an update, onNeedRefresh will be called.
141
+ await r.update();
142
+ }
143
+ }, intervalMS);
144
+ ` : ``}
145
+ },
146
+ ${updateInterval !== undefined ? `
147
+ onNeedRefresh() {
148
+ if (debugServiceWorker) console.log('[needle-pwa] The web app has been updated in the background. Refreshing.');
149
+ updateSW(true);
150
+ },
151
+ ` : ``}
152
+ onOfflineReady() {
153
+ if (debugServiceWorker) console.log('[needle-pwa] This web app is now installed and ready to be used offline.');
154
+ },
155
+ });
156
+ `;
157
+ log("Added PWA registration script to index.html." + (updateInterval !== undefined ? " Auto update interval is set to " + updateInterval + "ms." : ""));
133
158
  return {
134
159
  html,
135
160
  tags: [
@@ -146,16 +171,12 @@
146
171
  closeBundle() {
147
172
  // copy the icons to the output directory
148
173
  const outputDir = getOutputDirectory();
149
- const webmanifestPath = outputDir + "/" + readdirSync(outputDir).find(f => f.endsWith(".webmanifest"));
150
- if (webmanifestPath) {
151
- try {
152
- const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
153
- copyIcons(manifest, outputDir);
154
- }
155
- catch (e) {
156
- log("Error post processing PWA", webmanifestPath, e);
157
- }
174
+ try {
175
+ copyIcons(pwaOptions.manifest, outputDir);
158
176
  }
177
+ catch (e) {
178
+ log("Error post processing PWA", e);
179
+ }
159
180
 
160
181
  cleanup(context);
161
182
 
@@ -200,80 +221,93 @@
200
221
  context.generatedFiles.length = 0;
201
222
  }
202
223
 
203
- /**
204
- * @param {Array<{path:string, copypath:string}>} manifests
205
- */
206
- function restoreOriginalManifests(manifests) {
207
- // restore the manifest
208
- for (const manifest of manifests) {
209
- try {
210
- log("Restore original webmanifest", manifest.path);
211
- copyFileSync(manifest.copyPath, manifest.path);
212
- rmSync(manifest.copyPath);
213
- }
214
- catch (e) {
215
- log("Error restoring original webmanifest", manifest, e);
216
- }
217
- }
218
- manifests.length = 0;
219
- }
220
-
221
-
222
224
  function log(...args) {
223
225
  console.log("[needle-pwa]", ...args);
224
226
  }
225
- function delay(ms) {
226
- return new Promise(resolve => setTimeout(resolve, ms));
227
- }
228
227
 
229
228
  /**
230
- * @param {string} webmanifestPath
229
+ * @param {string | Partial<import("vite-plugin-pwa").ManifestOptions>} webmanifestPath Path to the webmanifest file, or partial manifest itself
231
230
  * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
232
- * @param {import("../types/index.d.ts").NeedlePWAOptions?} options
231
+ * @param {import("vite-plugin-pwa").VitePWAOptions} pwaOptions
232
+ * @param {import("../types/needleConfig").needleMeta | null} config
233
+ * @param {import("../types/userconfig").userSettings | null} userSettings
233
234
  */
234
- async function processPWA(webmanifestPath, context, options, iteration = 0) {
235
+ async function processPWA(webmanifestPath, context, pwaOptions, config, userSettings) {
235
236
  const outDir = getOutputDirectory();
236
- if (!existsSync(webmanifestPath)) {
237
- if (iteration > 2)
238
- return log("No webmanifest found at", webmanifestPath);
239
- // try a few times...
240
- await delay(100);
241
- return processPWA(webmanifestPath, context, options, iteration + 2);
237
+
238
+ // if a path is provided, we read that and treat it as manifest
239
+ // if an object is provided, we treat it as partial manifest
240
+
241
+ /** @type {Partial<import("vite-plugin-pwa").ManifestOptions>} */
242
+ let manifest = {};
243
+
244
+ if (typeof webmanifestPath === "string") {
245
+ manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
242
246
  }
247
+ else {
248
+ manifest = webmanifestPath || {};
249
+ }
243
250
 
244
- /** @type {import("../types/webmanifest.d.ts").Webmanifest}} */
245
- const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
246
- let modifiedManifest = false;
251
+ processIcons(manifest, outDir, context);
247
252
 
248
- if (processIcons(manifest, outDir, context) === true) modifiedManifest = true;
249
- if (processWorkboxConfig(manifest) === true) modifiedManifest = true;
250
-
251
253
  const packageJsonPath = process.cwd() + "/package.json";
252
254
  const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
253
255
 
254
- if (manifest.id === undefined) {
255
- let name = packageJson.name || manifest.short_name || manifest.name;
256
- if (name) {
257
- manifest.id = "com." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", ".");
258
- modifiedManifest = true;
259
- }
256
+ const name = packageJson.name;
257
+ const appName = config?.sceneName || packageJson.name || "Needle App";
258
+
259
+ /** @type {Partial<import("vite-plugin-pwa").ManifestOptions>} */
260
+ const defaultManifest = {
261
+ // Use the same title as in NeedleMeta
262
+ name: appName,
263
+ short_name: appName,
264
+ id: "app.made-with-needle." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", "."),
265
+ description: config.description || packageJson.description || "Made with Needle Engine",
266
+ start_url: "./index.html",
267
+ display: "standalone",
268
+ display_override: [
269
+ "window-controls-overlay",
270
+ "standalone",
271
+ "browser"
272
+ ],
273
+ dir: "ltr",
274
+ lang: "en",
275
+ related_applications: [],
276
+ prefer_related_applications: false,
277
+ categories: ["spatial", "3D", "needle", "webgl", "webxr", "xr"],
278
+ };
279
+
280
+ // Use the poster image if that exists
281
+ if (!userSettings.noPoster) {
282
+ const posterPath = getPosterPath();
283
+ defaultManifest.screenshots = [{
284
+ src: "./" + posterPath,
285
+ type: "image/webp",
286
+ sizes: "1080x1080", // TODO get actual size
287
+ form_factor: "wide",
288
+ }];
260
289
  }
261
- if (manifest.description === undefined) {
262
- manifest.description = packageJson.description || "Made with Needle Engine";
263
- if (manifest.description?.length)
264
- modifiedManifest = true;
265
- }
266
290
 
267
- if (modifiedManifest) {
268
- log("Write modified webmanifest to " + webmanifestPath);
269
- const newManifest = JSON.stringify(manifest);
270
- writeFileSync(webmanifestPath, newManifest, 'utf8');
271
- }
291
+ // display override and window controls override, for native look and feel
292
+ if (defaultManifest.display === undefined) defaultManifest.display = "standalone";
293
+ if (defaultManifest.display_override === undefined) defaultManifest.display_override = [
294
+ "window-controls-overlay",
295
+ "standalone",
296
+ "browser"
297
+ ];
298
+
299
+ // Merge into pwaOptions. start_url is important, and
300
+ // we also want to keep whatever is already in the pwaOptions.manifest object
301
+ pwaOptions.manifest = {
302
+ ...defaultManifest,
303
+ ...manifest,
304
+ ...pwaOptions.manifest
305
+ };
272
306
  }
273
307
 
274
308
  /** Copies icons to the output directory
275
309
  * If a start_url is provided, it will be used as the base path for relative icon paths
276
- * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
310
+ * @param {Partial<import("vite-plugin-pwa").ManifestOptions>} manifest
277
311
  * @param {string} outDir
278
312
  * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
279
313
  * @returns {boolean} */
@@ -285,10 +319,12 @@
285
319
  generateIcons(manifest, context);
286
320
  }
287
321
 
322
+ if (!manifest.icons) return modified;
323
+
288
324
  for (let i = 0; i < manifest.icons?.length; i++) {
289
325
  try {
290
326
  const icon = manifest.icons[i];
291
- let src = icon.src;
327
+ const src = icon.src;
292
328
  const iconSrcIsAbsolute = src.startsWith("http://") || src.startsWith("https://");
293
329
  const hasAbsoluteStartUrl = manifest.start_url?.startsWith("http://") || manifest.start_url?.startsWith("https://");
294
330
  if (!iconSrcIsAbsolute && hasAbsoluteStartUrl) {
@@ -305,7 +341,7 @@
305
341
  }
306
342
 
307
343
  /**
308
- * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
344
+ * @param {Partial<import("vite-plugin-pwa").ManifestOptions>} manifest
309
345
  * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
310
346
  */
311
347
  function generateIcons(manifest, context) {
@@ -314,7 +350,7 @@
314
350
  const sizes = [192, 512];
315
351
  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>`;
316
352
  const iconDir = process.cwd();
317
- for (let size of sizes) {
353
+ for (const size of sizes) {
318
354
  const iconName = "pwa-icon-" + size + ".svg";
319
355
  const iconPath = iconDir + "/" + iconName;
320
356
  writeFileSync(iconPath, defaultIconSVG, 'utf8');
@@ -336,6 +372,7 @@
336
372
  }
337
373
 
338
374
  /** Tries to copy the icons to the output directory
375
+ * @param {Partial<import("vite-plugin-pwa").ManifestOptions>} manifest
339
376
  */
340
377
  function copyIcons(manifest, outDir) {
341
378
  for (let i = 0; i < manifest.icons?.length; i++) {
@@ -378,13 +415,14 @@
378
415
 
379
416
  /**
380
417
  * Merges the current workbox config with the default workbox config
381
- * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
418
+ * @param {import("vite-plugin-pwa").VitePWAOptions} manifest
382
419
  */
383
420
  function processWorkboxConfig(manifest) {
384
421
 
385
422
  // Workaround: urlPattern, ignoreSearch und ignoreURLParametersMatching, dontCacheBustURLsMatching are because we currently append ?v=... to loaded GLB files
386
423
 
387
424
  // this is our default config
425
+ /** @type {Partial<import("workbox-build").GenerateSWOptions>} */
388
426
  const defaultWorkboxConfig = {
389
427
  globPatterns: ['**'],
390
428
  // glb files are large – we need to increase the cache size here.
@@ -407,12 +445,12 @@
407
445
  ],
408
446
  }
409
447
 
410
- if (manifest.workboxConfig) {
411
- manifest.workboxConfig = { ...defaultWorkboxConfig, ...manifest.workboxConfig };
448
+ if (manifest.workbox) {
449
+ manifest.workbox = { ...defaultWorkboxConfig, ...manifest.workbox };
412
450
  return true;
413
451
  }
414
452
  else {
415
- manifest.workboxConfig = defaultWorkboxConfig;
453
+ manifest.workbox = defaultWorkboxConfig;
416
454
  return true;
417
455
  }
418
456
  }
src/engine-components/AnimatorController.ts CHANGED
@@ -333,6 +333,9 @@
333
333
  }
334
334
 
335
335
  private setStartTransition() {
336
+ if (this.model.layers.length > 1 && (debug || isDevEnvironment())) {
337
+ console.warn("Multiple layers are not supported yet " + this.animator?.name);
338
+ }
336
339
  for (const layer of this.model.layers) {
337
340
  const sm = layer.stateMachine;
338
341
  if (sm.defaultState === undefined) {
@@ -342,6 +345,7 @@
342
345
  }
343
346
  const start = sm.states[sm.defaultState];
344
347
  this.transitionTo(start, 0, this.normalizedStartOffset);
348
+ break;
345
349
  }
346
350
  }
347
351
 
@@ -380,11 +384,16 @@
380
384
  if (action) {
381
385
  const dur = state.motion.clip!.duration;
382
386
  const normalizedTime = dur <= 0 ? 1 : Math.abs(action.time / dur);
387
+ let exitTime = transition.exitTime;
388
+
389
+ // When the animation is playing backwards we need to check exit time inverted
390
+ if (action.timeScale < 0) {
391
+ exitTime = 1 - exitTime;
392
+ }
393
+
383
394
  let makeTransition = false;
384
395
  if (transition.hasExitTime) {
385
- if (action.timeScale > 0) makeTransition = normalizedTime >= transition.exitTime;
386
- // When the animation is playing backwards we need to check exit time inverted
387
- else if (action.timeScale < 0) makeTransition = 1 - normalizedTime >= transition.exitTime;
396
+ makeTransition = normalizedTime >= exitTime;
388
397
  }
389
398
  else {
390
399
  makeTransition = true;
@@ -403,7 +412,8 @@
403
412
  action.clampWhenFinished = true;
404
413
  // else action.clampWhenFinished = false;
405
414
  if (debug) {
406
- console.log("transition to " + transition.destinationState, transition, normalizedTime, transition.exitTime, transition.hasExitTime);
415
+ const targetState = this.getState(transition.destinationState, 0);
416
+ console.log(`Transition to ${transition.destinationState} / ${targetState?.name}`, transition, "\nTimescale: " + action.timeScale, "\nNormalized time: " + normalizedTime.toFixed(3), "\nExit Time: " + exitTime, transition.hasExitTime);
407
417
  // console.log(action.time, transition.exitTime);
408
418
  }
409
419
  this.transitionTo(transition.destinationState, transition.duration, transition.offset);
@@ -423,12 +433,7 @@
423
433
 
424
434
  // update timescale
425
435
  if (action) {
426
- let speedFactor = state.speed ?? 1;
427
- if (state.speedParameter)
428
- speedFactor *= this.getFloat(state.speedParameter);
429
- if (speedFactor !== undefined) {
430
- action.timeScale = speedFactor * this._speed;
431
- }
436
+ this.setTimescale(action, state);
432
437
  }
433
438
 
434
439
  let didTriggerLooping = false;
@@ -466,6 +471,15 @@
466
471
 
467
472
  }
468
473
 
474
+ private setTimescale(action: AnimationAction, state: State) {
475
+ let speedFactor = state.speed ?? 1;
476
+ if (state.speedParameter)
477
+ speedFactor *= this.getFloat(state.speedParameter);
478
+ if (speedFactor !== undefined) {
479
+ action.timeScale = speedFactor * this._speed;
480
+ }
481
+ }
482
+
469
483
  private getState(state: State | number, layerIndex: number): State | null {
470
484
  if (typeof state === "number") {
471
485
  if (state == -1) {
@@ -481,6 +495,17 @@
481
495
  return state;
482
496
  }
483
497
 
498
+ /**
499
+ * These actions have been active previously but not faded out because we entered a state that has no real animation - no duration. In which case we hold the previously active actions until they are faded out.
500
+ */
501
+ private readonly _heldActions: AnimationAction[] = [];
502
+ private releaseHeldActions(duration: number) {
503
+ for (const prev of this._heldActions) {
504
+ prev.fadeOut(duration);
505
+ }
506
+ this._heldActions.length = 0;
507
+ }
508
+
484
509
  private transitionTo(state: State | number, durationInSec: number, offsetNormalized: number) {
485
510
 
486
511
  if (!this.animator) return;
@@ -519,19 +544,31 @@
519
544
  }
520
545
 
521
546
  const prevAction = this._activeState?.motion.action;
522
- if (prevAction) {
523
- prevAction!.fadeOut(durationInSec);
524
- }
547
+
548
+
525
549
  if (isSelf) {
526
550
  state.motion.action = state.motion.action_loopback;
527
551
  state.motion.action_loopback = prevAction;
528
552
  }
529
553
  const prev = this._activeState;
530
554
  this._activeState = state;
555
+ const action = state.motion?.action;
531
556
 
532
- const action = state.motion?.action;
557
+ const clip = state.motion.clip;
558
+
559
+
560
+ if (clip?.duration <= 0 && clip.tracks.length <= 0) {
561
+ // if the new state doesn't have a valid clip / no tracks we don't fadeout the previous action and instead hold the previous action.
562
+ if (prevAction) {
563
+ this._heldActions.push(prevAction);
564
+ }
565
+ }
566
+ else if (prevAction) {
567
+ prevAction!.fadeOut(durationInSec);
568
+ this.releaseHeldActions(durationInSec);
569
+ }
570
+
533
571
  if (action) {
534
-
535
572
  offsetNormalized = Math.max(0, Math.min(1, offsetNormalized));
536
573
  if (state.cycleOffsetParameter) {
537
574
  let val = this.getFloat(state.cycleOffsetParameter);
@@ -550,6 +587,7 @@
550
587
  action.stop();
551
588
  action.reset();
552
589
  action.enabled = true;
590
+ this.setTimescale(action, state);
553
591
  const duration = state.motion.clip!.duration;
554
592
  // if we are looping to the same state we don't want to offset the current start time
555
593
  action.time = isSelf ? 0 : offsetNormalized * duration;
@@ -649,6 +687,7 @@
649
687
 
650
688
  // ensure we have a motion even if none was exported
651
689
  if (!state.motion) {
690
+ if (debug) console.warn("No motion", state);
652
691
  state.motion = createMotion(state.name);
653
692
  // console.warn("Missing motion", "AnimatorController: " + this.model.name, state);
654
693
  // sm.states.splice(index, 1);
@@ -670,6 +709,7 @@
670
709
 
671
710
  // ensure we have a clip to blend to
672
711
  if (!state.motion.clip) {
712
+ if (debug) console.warn("No clip assigned to state", state);
673
713
  const clip = new AnimationClip(undefined, undefined, []);
674
714
  state.motion.clip = clip;
675
715
  }
src/engine/engine_input.ts CHANGED
@@ -686,6 +686,7 @@
686
686
  window.addEventListener("touchend", this.onTouchEnd, { passive: true });
687
687
 
688
688
  this._htmlEventSource.addEventListener('wheel', this.onMouseWheel, { passive: true });
689
+ window.addEventListener("wheel", this.onWheelWindow, { passive: true })
689
690
 
690
691
  window.addEventListener("keydown", this.onKeyDown, false);
691
692
  window.addEventListener("keypress", this.onKeyPressed, false);
@@ -704,6 +705,7 @@
704
705
  window.removeEventListener('pointercancel', this.onPointerCancel);
705
706
 
706
707
  this._htmlEventSource?.removeEventListener('wheel', this.onMouseWheel, false);
708
+ window.removeEventListener("wheel", this.onWheelWindow, false);
707
709
 
708
710
  window.removeEventListener("keydown", this.onKeyDown, false);
709
711
  window.removeEventListener("keypress", this.onKeyPressed, false);
@@ -818,6 +820,13 @@
818
820
  this.onDispatchEvent(ne);
819
821
  }
820
822
 
823
+ private onWheelWindow = (evt: WheelEvent) => {
824
+ // check if we are in pointer lock mode
825
+ if (document.pointerLockElement) {
826
+ // only if yes we want to use the mouse wheel as a pointer event
827
+ this.onMouseWheel(evt);
828
+ }
829
+ };
821
830
  private onMouseWheel = (evt: WheelEvent) => {
822
831
  if (this.canReceiveInput(evt) === false) return;
823
832
  if (this._mouseWheelDeltaY.length <= 0) this._mouseWheelDeltaY.push(0);
@@ -942,12 +951,12 @@
942
951
  }
943
952
 
944
953
  // Prevent the same event being handled twice (e.g. on touch we get a mouseUp and touchUp evt with the same timestamp)
945
- private isNewEvent(timestamp: number, index: number, arr: number[]): boolean {
946
- while (arr.length <= index) arr.push(-1);
947
- if (timestamp === arr[index]) return false;
948
- arr[index] = timestamp;
949
- return true;
950
- }
954
+ // private isNewEvent(timestamp: number, index: number, arr: number[]): boolean {
955
+ // while (arr.length <= index) arr.push(-1);
956
+ // if (timestamp === arr[index]) return false;
957
+ // arr[index] = timestamp;
958
+ // return true;
959
+ // }
951
960
 
952
961
  private isInRect(e: { clientX: number, clientY: number }): boolean {
953
962
  if (this.context.isInXR) return true;
src/engine/engine_scenetools.ts CHANGED
@@ -11,20 +11,17 @@
11
11
  import { Context } from "./engine_setup.js"
12
12
  import { type SourceIdentifier, type UIDProvider } from "./engine_types.js";
13
13
  import * as utils from "./engine_utils.js";
14
- import { registerComponentExtension, registerExtensions } from "./extensions/extensions.js";
14
+ import { invokeAfterImportPluginHooks, registerComponentExtension, registerExtensions } from "./extensions/extensions.js";
15
15
  import { NEEDLE_components } from "./extensions/NEEDLE_components.js";
16
16
 
17
-
18
17
  /** @internal */
19
18
  export class NeedleGltfLoader implements INeedleGltfLoader {
20
19
  createBuiltinComponents(context: Context, gltfId: string, gltf: any, seed: number | UIDProvider | null, extension?: NEEDLE_components | undefined) {
21
20
  return createBuiltinComponents(context, gltfId, gltf, seed, extension);
22
21
  }
23
-
24
22
  writeBuiltinComponentData(comp: any, context: SerializationContext) {
25
23
  return writeBuiltinComponentData(comp, context);
26
24
  }
27
-
28
25
  parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<GLTF | undefined> {
29
26
  return parseSync(context, data, path, seed);
30
27
  }
@@ -32,7 +29,6 @@
32
29
  return loadSync(context, url, sourceId, seed, prog);
33
30
  }
34
31
  }
35
-
36
32
  registerLoader(NeedleGltfLoader);
37
33
 
38
34
 
@@ -53,7 +49,6 @@
53
49
  loader: GLTFLoader;
54
50
  path: string;
55
51
  gltf?: GLTF;
56
-
57
52
  constructor(context: Context, path: string, loader: GLTFLoader, gltf?: GLTF) {
58
53
  this.context = context;
59
54
  this.path = path;
@@ -99,8 +94,7 @@
99
94
 
100
95
  export async function createGLTFLoader(url: string, context: Context) {
101
96
  const loader = new GLTFLoader();
102
- const sourceId: SourceIdentifier = url;
103
- await registerExtensions(loader, context, sourceId);
97
+ await registerExtensions(loader, context, url);
104
98
  return loader;
105
99
  }
106
100
 
@@ -139,6 +133,7 @@
139
133
  invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, path, loader));
140
134
  const camera = context.mainCamera;
141
135
  loader.parse(data, "", async res => {
136
+ invokeAfterImportPluginHooks(path, res, context);
142
137
  invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, path, loader, res));
143
138
  await handleLoadedGltf(context, path, res, seed, componentsExtension);
144
139
  await compileAsync(res.scene, context, camera);
@@ -185,6 +180,7 @@
185
180
  invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, url, loader));
186
181
  const camera = context.mainCamera;
187
182
  loader.load(url, async res => {
183
+ invokeAfterImportPluginHooks(url, res, context);
188
184
  invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, url, loader, res));
189
185
  await handleLoadedGltf(context, sourceId, res, seed, componentsExtension);
190
186
  await compileAsync(res.scene, context, camera);
@@ -240,30 +236,3 @@
240
236
  a.click();
241
237
  }
242
238
  }
243
-
244
- // TODO: save references in guid map
245
- // const guidMap = {};
246
-
247
-
248
- /** @deprecated */
249
- export function tryFindObjectByName(name: string, obj: Object3D, recursive = true) {
250
- if (obj.userData && obj.userData.name === name) return obj;
251
- if (obj.children && obj.children.length > 0) {
252
- for (let i = 0; i < obj.children.length; i++) {
253
- const child = obj.children[i];
254
- const found = tryFindObjectByName(name, child, recursive);
255
- if (found) return found;
256
- }
257
- }
258
- }
259
-
260
- /** @deprecated */
261
- export function tryFindScript(globalObjectIdentifier: string, list = null) {
262
- const arr = list ?? Context.Current.scripts;
263
- for (const i in arr) {
264
- const script = arr[i];
265
- if (script && script.guid === globalObjectIdentifier)
266
- return script;
267
- }
268
- return null;
269
- }
src/engine/extensions/extensions.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { isDevEnvironment } from "../debug/index.js";
5
5
  import { isResourceTrackingEnabled } from "../engine_assetdatabase.js";
6
6
  import { Context } from "../engine_setup.js";
7
- import { type ConstructorConcrete, type SourceIdentifier } from "../engine_types.js";
7
+ import { type ConstructorConcrete, GLTF, type SourceIdentifier } from "../engine_types.js";
8
8
  import { getParam } from "../engine_utils.js";
9
9
  import { NEEDLE_lightmaps } from "../extensions/NEEDLE_lightmaps.js";
10
10
  import { EXT_texture_exr } from "./EXT_texture_exr.js";
@@ -29,15 +29,28 @@
29
29
  });
30
30
 
31
31
 
32
- declare type OnImportCallback = (loader: GLTFLoader, sourceId: SourceIdentifier, context: Context) => void;
33
- declare type OnExportCallback = (exp: GLTFExporter, context: Context) => void;
32
+ declare type OnImportCallback = (loader: GLTFLoader, url: string, context: Context) => void;
33
+ declare type OnExportCallback = (exporter: GLTFExporter, context: Context) => void;
34
34
 
35
+ /**
36
+ * Interface for registering custom glTF extensions to the Needle Engine GLTFLoaders. Register your plugin via {@link addCustomExtensionPlugin}
37
+ */
35
38
  export interface INeedleGLTFExtensionPlugin {
39
+ /** The Name of your plugin */
36
40
  name: string;
41
+ /** Called before starting to load a glTF file. This callback can be used to add custom extensions to the GLTFLoader */
37
42
  onImport?: OnImportCallback;
38
- onExport?: OnExportCallback
43
+ /** Called after the glTF has been loaded */
44
+ onLoaded?: (url: string, gltf: GLTF, context: Context) => void;
45
+ /** Called before starting to export a glTF file. This callback can be used to add custom extensions to the GLTFExporter */
46
+ onExport?: OnExportCallback;
39
47
  }
40
48
 
49
+ /**
50
+ * Registered custom glTF extension plugins
51
+ */
52
+ const _plugins = new Array<INeedleGLTFExtensionPlugin>();
53
+
41
54
  /** Register callbacks for registering custom gltf importer or exporter plugins */
42
55
  export function addCustomExtensionPlugin(ext: INeedleGLTFExtensionPlugin) {
43
56
  if (!_plugins.includes(ext)) {
@@ -50,7 +63,6 @@
50
63
  if (index >= 0)
51
64
  _plugins.splice(index, 1);
52
65
  }
53
- const _plugins = new Array<INeedleGLTFExtensionPlugin>();
54
66
 
55
67
 
56
68
  /** Registers the Needle Engine components extension */
@@ -75,27 +87,22 @@
75
87
  }
76
88
  }
77
89
 
78
- export async function registerExtensions(loader: GLTFLoader, context: Context, sourceId: SourceIdentifier) {
90
+ export async function registerExtensions(loader: GLTFLoader, context: Context, url: string) {
79
91
 
80
92
  // Make sure to remove any url parameters from the sourceId (because the source id in the renderer does not have a ?v=xxx so it will not be able to register the resolved lightmap otherwise)
81
- const idEnd = sourceId.lastIndexOf("?");
82
- if (idEnd >= 0) sourceId = sourceId.substring(0, idEnd);
93
+ const idEnd = url.indexOf("?");
94
+ if (idEnd >= 0) url = url.substring(0, idEnd);
83
95
 
84
-
85
96
  loader.register(p => new NEEDLE_gameobject_data(p));
86
97
  loader.register(p => new NEEDLE_persistent_assets(p));
87
- loader.register(p => new NEEDLE_lightmaps(p, context.lightmaps, sourceId));
88
- loader.register(p => new NEEDLE_lighting_settings(p, sourceId, context));
89
- loader.register(p => new NEEDLE_techniques_webgl(p, sourceId));
90
- loader.register(p => new NEEDLE_render_objects(p, sourceId));
91
- loader.register(p => new NEEDLE_progressive(p, sourceId));
98
+ loader.register(p => new NEEDLE_lightmaps(p, context.lightmaps, url));
99
+ loader.register(p => new NEEDLE_lighting_settings(p, url, context));
100
+ loader.register(p => new NEEDLE_techniques_webgl(p, url));
101
+ loader.register(p => new NEEDLE_render_objects(p, url));
102
+ loader.register(p => new NEEDLE_progressive(p, url));
92
103
  loader.register(p => new EXT_texture_exr(p));
93
104
  if (isResourceTrackingEnabled()) loader.register(p => new InternalUsageTrackerPlugin(p))
94
105
 
95
- for (const plugin of _plugins) {
96
- if (plugin.onImport) plugin.onImport(loader, sourceId, context);
97
- }
98
-
99
106
  await KHR_ANIMATIONPOINTER_IMPORT.catch(_ => { })
100
107
  loader.register(p => {
101
108
  if (GLTFAnimationPointerExtension) {
@@ -112,9 +119,18 @@
112
119
  }
113
120
  });
114
121
 
122
+ for (const plugin of _plugins) {
123
+ if (plugin.onImport) plugin.onImport(loader, url, context);
124
+ }
115
125
  }
116
126
 
117
127
  export function registerExportExtensions(exp: GLTFExporter, context: Context) {
118
128
  for (const ext of _plugins)
119
129
  if (ext.onExport) ext.onExport(exp, context);
130
+ }
131
+
132
+ /** @internal */
133
+ export function invokeAfterImportPluginHooks(url: string, gltf: GLTF, context: Context) {
134
+ for (const ext of _plugins)
135
+ if (ext.onLoaded) ext.onLoaded(url, gltf, context);
120
136
  }
src/engine/extensions/NEEDLE_animator_controller_model.ts CHANGED
@@ -108,14 +108,15 @@
108
108
 
109
109
  export function createMotion(name: string, id?: InstantiateIdProvider): Motion {
110
110
  return {
111
- name: "",
111
+ name: "Empty",
112
112
  isLooping: false,
113
113
  guid: id?.generateUUID() ?? MathUtils.generateUUID(),
114
114
  index: -1,
115
- clip: new AnimationClip(name, 1, []),
115
+ clip: new AnimationClip(name, 0, []),
116
116
  }
117
117
  }
118
118
 
119
+
119
120
  export declare type ClipMapping = {
120
121
  /** the object this clip is for */
121
122
  node: Object3D;
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";
@@ -220,7 +220,7 @@
220
220
  import { XRFlag } from "../../engine-components/webxr/XRFlag.js";
221
221
  import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
222
222
  import { XRState } from "../../engine-components/webxr/XRFlag.js";
223
-
223
+
224
224
  // Register types
225
225
  TypeStore.add("__Ignore", __Ignore);
226
226
  TypeStore.add("ActionBuilder", ActionBuilder);
plugins/types/userconfig.d.ts CHANGED
@@ -53,6 +53,7 @@
53
53
  /** When set to true the needle pwa plugin will not run */
54
54
  noPWA?: boolean;
55
55
  /** Pass in the VitePWA options */
56
+ /** @type {import("vite-plugin-pwa").VitePWAOptions} */
56
57
  pwaOptions?: {};
57
58
 
58
59
  /** used by nextjs config to forward the webpack module */
src/engine-components/webxr/WebXRImageTracking.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Object3D, Quaternion, Vector3 } from "three";
1
+ import { Matrix4, Object3D, Quaternion, Vector3 } from "three";
2
2
 
3
3
  import { showBalloonWarning } from "../../engine/debug/index.js";
4
4
  import { AssetReference } from "../../engine/engine_addressables.js";
@@ -89,6 +89,15 @@
89
89
 
90
90
  declare type WebXRImageTrackingEvent = (images: WebXRImageTrackingEvent[]) => void;
91
91
 
92
+ /** Initial state of assigned tracked image objects that are already present in the scene
93
+ * This is used to restore the state of the object when the webxr session has ended to return to the original state
94
+ */
95
+ declare type InitialTrackedObjectState = {
96
+ visible: boolean;
97
+ parent: Object3D | undefined | null;
98
+ matrix: Matrix4;
99
+ }
100
+
92
101
  export class WebXRImageTrackingModel {
93
102
 
94
103
  @serializable(URL)
@@ -205,8 +214,15 @@
205
214
  if (this.trackedImages) {
206
215
  for (const trackedImage of this.trackedImages) {
207
216
  if (trackedImage.object?.asset) {
208
- const obj = trackedImage.object.asset;
209
- // obj.visible = false;
217
+ // capture the initial state of tracked images in the scene to restore them when the session ends
218
+ const obj = trackedImage.object.asset as Object3D;
219
+ if (!obj.userData) obj.userData = {};
220
+ const state: InitialTrackedObjectState = {
221
+ visible: obj.visible,
222
+ parent: obj.parent,
223
+ matrix: obj.matrix.clone()
224
+ };
225
+ obj.userData["image-tracking"] = state;
210
226
  }
211
227
  }
212
228
  }
@@ -216,6 +232,27 @@
216
232
  }
217
233
  };
218
234
 
235
+ onLeaveXR(_args: NeedleXREventArgs): void {
236
+ if (this.trackedImages) {
237
+ for (const trackedImage of this.trackedImages) {
238
+ if (trackedImage.object?.asset) {
239
+ const obj = trackedImage.object.asset as Object3D;
240
+ if (obj.userData) {
241
+ // restore the initial state of tracked images in the scene
242
+ const state = obj.userData["image-tracking"] as InitialTrackedObjectState | undefined;
243
+ if (state) {
244
+ obj.visible = state.visible;
245
+ state.parent?.add(obj);
246
+ obj.matrix.copy(state.matrix);
247
+ obj.matrix.decompose(obj.position, obj.quaternion, obj.scale);
248
+ }
249
+ delete obj.userData["image-tracking"];
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+
219
256
  private readonly imageToObjectMap = new Map<WebXRImageTrackingModel, { object: GameObject | null, frames: number, lastTrackingTime: number }>();
220
257
  private readonly currentImages: WebXRTrackedImage[] = [];
221
258
 
@@ -234,6 +271,12 @@
234
271
  showBalloonWarning(warning);
235
272
  return;
236
273
  }
274
+ // Check if enabled features (if available) contains image tracking - if it's not available this statement should not catch
275
+ // This handles mobile VR with image tracking. Seems like the "getImageTrackingResults" is available on the frame object but then we get runtime exceptions because the feature is (in VR) not enabled
276
+ else if (args.xr.session.enabledFeatures?.includes("image-tracking") === false) {
277
+ // Image tracking is not enabled for this session
278
+ return;
279
+ }
237
280
  else if (frame.session && typeof frame.getImageTrackingResults === "function") {
238
281
  const results = frame.getImageTrackingResults();
239
282
  if (results.length > 0) {