Needle Engine

Changes between version 3.51.1 and 4.0.0-alpha
Files changed (69) hide show
  1. plugins/next/alias.cjs +0 -2
  2. plugins/common/config.cjs +1 -1
  3. plugins/common/license.cjs +0 -116
  4. plugins/next/license.cjs +19 -3
  5. plugins/vite/alias.js +24 -6
  6. plugins/vite/asap.js +5 -0
  7. plugins/vite/build-pipeline.js +256 -69
  8. plugins/common/buildinfo.js +4 -3
  9. plugins/vite/buildinfo.js +20 -14
  10. plugins/vite/config.js +5 -4
  11. plugins/vite/copyfiles.js +68 -67
  12. plugins/vite/defines.js +8 -1
  13. plugins/vite/dependencies.js +78 -16
  14. plugins/vite/drop.js +5 -0
  15. plugins/vite/index.js +1 -1
  16. plugins/vite/license.js +14 -11
  17. plugins/next/next.js +2 -0
  18. plugins/vite/reload.js +2 -1
  19. src/engine-components/api.ts +2 -1
  20. src/engine/api.ts +1 -1
  21. src/engine-components/AvatarLoader.ts +2 -3
  22. src/engine-components/ui/Button.ts +1 -1
  23. src/engine-components/codegen/components.ts +1 -0
  24. src/engine-components/DropListener.ts +282 -63
  25. src/engine/engine_addressables.ts +2 -2
  26. src/engine/engine_animation.ts +2 -2
  27. src/engine/engine_components_internal.ts +5 -7
  28. src/engine/engine_components.ts +3 -3
  29. src/engine/engine_constants.ts +11 -6
  30. src/engine/engine_context_registry.ts +2 -2
  31. src/engine/engine_context.ts +7 -7
  32. src/engine/engine_element.ts +40 -14
  33. src/engine/engine_gameobject.ts +2 -2
  34. src/engine/engine_gltf.ts +3 -5
  35. src/engine/engine_input.ts +7 -0
  36. src/engine/engine_license.ts +5 -3
  37. src/engine/engine_loaders.ts +14 -13
  38. src/engine/engine_networking_auto.ts +26 -26
  39. src/engine/engine_networking_files_default_components.ts +5 -3
  40. src/engine/engine_networking_files.ts +158 -151
  41. src/engine/engine_networking_streams.ts +1 -1
  42. src/engine/engine_networking.ts +45 -23
  43. src/engine/engine_scenetools.ts +26 -22
  44. src/engine/engine_serialization_builtin_serializer.ts +3 -3
  45. src/engine/engine_types.ts +20 -12
  46. src/engine/engine_web_api.ts +0 -120
  47. src/engine/engine.ts +0 -14
  48. src/engine-components/EventList.ts +9 -6
  49. src/engine-components/EventTrigger.ts +1 -1
  50. src/engine-components/ui/InputField.ts +2 -2
  51. plugins/types/license.d.ts +18 -4
  52. src/asap/needle-asap.ts +25 -13
  53. src/engine/webcomponents/needle menu/needle-menu.ts +2 -2
  54. plugins/types/needleConfig.d.ts +4 -1
  55. src/engine/js-extensions/Object3D.ts +4 -0
  56. src/engine-components/OrbitControls.ts +2 -2
  57. src/engine-components-experimental/networking/PlayerSync.ts +1 -1
  58. src/engine/codegen/register_types.ts +2 -0
  59. src/engine-components/SceneSwitcher.ts +1 -1
  60. src/engine-components/timeline/SignalAsset.ts +1 -1
  61. src/engine-components/SpatialTrigger.ts +3 -3
  62. plugins/types/userconfig.d.ts +26 -5
  63. src/engine-components/webxr/WebXR.ts +1 -1
  64. plugins/common/files.js +32 -0
  65. plugins/common/license.js +191 -0
  66. plugins/common/timers.js +8 -0
  67. src/engine/engine_networking_blob.ts +254 -0
  68. src/engine/analytics/index.ts +11 -0
  69. src/engine/analytics/lcp.ts +35 -0
plugins/next/alias.cjs CHANGED
@@ -37,6 +37,4 @@
37
37
  }
38
38
  }
39
39
 
40
- console.log(config.resolve.alias);
41
-
42
40
  }
plugins/common/config.cjs CHANGED
@@ -7,7 +7,7 @@
7
7
  if (needleConfig.codegenDirectory) {
8
8
  const metaPath = workingDirectory + "/" + needleConfig.codegenDirectory + "/meta.json";
9
9
 
10
- /**@type {import("../types").needleConfig} */
10
+ /**@type {import("../types").needleMeta} */
11
11
  const meta = require(metaPath);
12
12
  return meta;
13
13
  }
plugins/common/license.cjs DELETED
@@ -1,116 +0,0 @@
1
- const { getMeta } = require("./config.cjs");
2
- const https = require('https');
3
-
4
-
5
- /**
6
- * @param {string} code
7
- * @param {string | null | undefined} licenseType
8
- */
9
- module.exports.replaceLicense = async function (code, licenseType) {
10
-
11
- if (!licenseType) {
12
- const meta = getMeta();
13
- if (meta) {
14
- licenseType = meta.license;
15
- }
16
- }
17
-
18
- if (typeof licenseType === "object") {
19
- licenseType = await module.exports.resolveLicense(licenseType);
20
- }
21
-
22
- if (!licenseType) {
23
- return code;
24
- }
25
-
26
- const index = code.indexOf("NEEDLE_ENGINE_LICENSE_TYPE");
27
- if (index >= 0) {
28
- const end = code.indexOf(";", index);
29
- if (end >= 0) {
30
- const line = code.substring(index, end);
31
- const replaced = "NEEDLE_ENGINE_LICENSE_TYPE = \"" + licenseType + "\"";
32
- code = code.replace(line, replaced);
33
- return code;
34
- }
35
- }
36
- }
37
-
38
-
39
-
40
-
41
-
42
- const LICENSE_ENDPOINT = `https://urls.needle.tools/license-endpoint`
43
-
44
- /**
45
- * Resolve the license for a given license key (e.g. invoice id) and id (e.g. email that was used for the purchase)
46
- * @param {import('../types/license.js').License} license
47
- * @returns {Promise<string | null>}
48
- */
49
- module.exports.resolveLicense = async function (license) {
50
- if (typeof license !== "object") {
51
- return license;
52
- }
53
-
54
- if (!license) {
55
- return null;
56
- }
57
-
58
- if (!license.key) {
59
- console.warn("WARN: License key is missing.");
60
- return null;
61
- }
62
- else if (!license.id) {
63
- console.warn("WARN: License id is missing.");
64
- return null;
65
- }
66
-
67
- console.log("INFO: Resolve license for " + obscure(license.id + "::" + license.key));
68
- const url = await fetch(LICENSE_ENDPOINT, { method: "GET" }).catch(console.error);
69
- if (!url) {
70
- console.warn("WARN: Failed to fetch license URL from endpoint");
71
- return null;
72
- }
73
- const licenseRequestUrl = `${url}?email=${license.id}&key=${license.key}&version=2`;
74
- const licenseResponse = await fetch(licenseRequestUrl, { method: "GET" }).catch(console.error);
75
- if (!licenseResponse) {
76
- console.warn("WARN: Failed to fetch license");
77
- return null;
78
- }
79
- /** @type {{license:string}} */
80
- const licenseJson = JSON.parse(licenseResponse);
81
- console.log("\n");
82
- if (licenseJson.license) {
83
- console.log(`INFO: Successfully received \"${licenseJson.license?.toUpperCase()}\" license`)
84
- return licenseJson.license;
85
- }
86
- console.warn("WARN: Received invalid license.");
87
- return null;
88
- }
89
-
90
-
91
- /**
92
- * @param {string} str
93
- */
94
- function obscure(str) {
95
- const start = str.substring(0, 3);
96
- const end = str.substring(str.length - 3);
97
- return start + "***" + end;
98
- }
99
-
100
-
101
- // NODE 16 doesn't support fetch yet
102
- function fetch(url, options) {
103
- return new Promise((resolve, reject) => {
104
- https.get(url, options, (res) => {
105
- let data = '';
106
- res.on('data', (chunk) => {
107
- data += chunk;
108
- });
109
- res.on('end', () => {
110
- resolve(data);
111
- });
112
- }).on("error", (err) => {
113
- reject(err);
114
- });
115
- });
116
- }
plugins/next/license.cjs CHANGED
@@ -1,5 +1,21 @@
1
- const { replaceLicense } = require("../common/license.cjs");
1
+ const { getMeta } = require("../common/config.cjs");
2
2
 
3
- module.exports = function (source, _map) {
4
- return replaceLicense(source);
3
+ const mod = import("../common/license.js");
4
+
5
+ module.exports = async function (source, _map) {
6
+
7
+ /** @type {string | undefined} */
8
+ let team = undefined;
9
+
10
+ const meta = await getMeta();
11
+ if (meta) {
12
+ team = meta.license?.team;
13
+ }
14
+
15
+ // console.log("RESOLVE LICENSE...");
16
+ return (await mod).replaceLicense(source, {
17
+ team: team,
18
+ }).then(_ => {
19
+ // console.log("LICENSE RESOLVED");
20
+ })
5
21
  };
plugins/vite/alias.js CHANGED
@@ -7,6 +7,9 @@
7
7
  * the first argument is the already resoled absolute path (it is only invoked if the path was found in node_modules)
8
8
  * the 2,3,4 args are the same as in vite.alias (packageName, index, path);
9
9
  */
10
+ /**
11
+ * @type {Record<string, null | ((res: string, packageName: string, index: number, path: string) => string | null | void)>}
12
+ */
10
13
  const packages_to_resolve = {
11
14
  // Handle all previous imports where users did import using @needle-engine/src
12
15
  '@needle-tools/engine/src': (res, packageName, index, path) => {
@@ -15,6 +18,11 @@
15
18
  return res + "/../lib";
16
19
  }
17
20
  },
21
+ /*
22
+ Removed the previously present @needle-tools/engine entry
23
+ because this is automatically done by vite according to whatever we define in our package.json exports
24
+ This did previously prevent us from declaring proper exports in package.json
25
+ */
18
26
  '@needle-tools/engine': (res, packageName, index, path) => {
19
27
  // Check if the import is something like @needle-tools/engine/engine/engine_utils
20
28
  // in which case we want to resolve into the lib directory
@@ -22,7 +30,9 @@
22
30
  return res + "/lib";
23
31
  }
24
32
  },
25
- /* Removed. Three.js is manually resolved below to ensure all dependencies resolve to the same three.js version.
33
+
34
+ /*
35
+ Removed. Three.js is manually resolved below to ensure all dependencies resolve to the same three.js version.
26
36
  'three': null,
27
37
  */
28
38
  'peerjs': null,
@@ -53,8 +63,8 @@
53
63
  outputDebugFile.write(`[needle-alias] Logging to: ${outputFilePath} (${timestamp})\n`);
54
64
  log("[needle-alias] Logging to: ", outputFilePath);
55
65
  }
56
-
57
66
 
67
+
58
68
  const aliasPlugin = {
59
69
  name: "needle-alias",
60
70
  config(config) {
@@ -134,19 +144,27 @@
134
144
  function addPathResolver(name, aliasDict, cb) {
135
145
  // If a package at the node_modules path exist we resolve the request there
136
146
  // introduced in 89a50718c38940abb99ee16c5e029065e41d7d65
137
- const res = path.resolve(projectDir, 'node_modules', name);
147
+ const fullpath = path.resolve(projectDir, 'node_modules', name);
138
148
  if (typeof cb !== "function") cb = null;
139
149
  const isDevEnvironment = process.env.NODE_ENV === "development";
140
- if (existsSync(res)) {
150
+
151
+ if (existsSync(fullpath)) {
141
152
  aliasDict[name] = (packageName, index, path) => {
142
153
  if (cb !== null && !isDevEnvironment) {
143
- const overrideResult = cb(res, packageName, index, path);
154
+ const overrideResult = cb(fullpath, packageName, index, path);
144
155
  if (overrideResult !== undefined)
145
156
  if (existsSync(overrideResult)) {
157
+ console.warn(`[needle-alias] \"${path}\" was requested and resolved to \"${overrideResult}\"`);
146
158
  return overrideResult;
147
159
  }
148
160
  }
149
- return res;
161
+
162
+ if (path != packageName) {
163
+ // TODO: we might want to check if the package.json exports contains the path to see if it's valid
164
+ console.warn(`[needle-alias] \"${path}\" was requested and resolved to \"${fullpath}\"`);
165
+ }
166
+
167
+ return fullpath;
150
168
  }
151
169
  }
152
170
  }
plugins/vite/asap.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  /**
7
7
  * Injects needle asap script into the index.html for when the main needle engine bundle is still being downloaded
8
+ * @param {"build" | "serve"} command
8
9
  * @param {import('../types').userSettings} userSettings
9
10
  * @returns {Promise<import('vite').Plugin | null>}
10
11
  */
@@ -14,6 +15,10 @@
14
15
 
15
16
  fixMainTs();
16
17
 
18
+ if (command != "build") {
19
+ return null;
20
+ }
21
+
17
22
  return {
18
23
  name: 'needle:asap',
19
24
  transformIndexHtml: {
plugins/vite/build-pipeline.js CHANGED
@@ -1,21 +1,85 @@
1
- import { ChildProcess, exec, execSync, spawn } from 'child_process';
2
- import { getOutputDirectory, loadConfig, tryLoadProjectConfig } from './config.js';
3
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
1
+ import { ChildProcess, exec } from 'child_process';
2
+ import { getOutputDirectory, loadConfig } from './config.js';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
4
+ import { copyFilesSync } from '../common/files.js';
5
+ import { delay } from '../common/timers.js';
4
6
 
7
+ /**
8
+ * @param {import('../types').userSettings} config
9
+ * @returns {boolean}
10
+ */
11
+ function validateCloudBuildConfiguration(config) {
12
+ if (config.buildPipeline != undefined) {
13
+ if (process.env.CI) {
14
+ if ((process.env.NEEDLE_CLOUD_TOKEN == undefined || process.env.NEEDLE_CLOUD_TOKEN.length <= 0)) {
15
+ const isGithubAction = process.env.CI && process.env.GITHUB_ACTION;
16
+ let msg = `Missing Needle Cloud access token. Please set your Needle Cloud token via \`process.env.NEEDLE_CLOUD_TOKEN\`.`;
17
+ if (isGithubAction) {
18
+ msg += `
19
+ Make sure to pass the token as a secret to the Github action.
20
+ For this you may have to modify your .github workflow yaml file to include the following code:
21
+ env:
22
+ - NEEDLE_CLOUD_TOKEN: \${{ secrets.NEEDLE_CLOUD_TOKEN }}
23
+ `;
24
+ let error = `${msg}`;
25
+ throw new Error(error);
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ return true;
32
+ }
33
+
5
34
  // see https://linear.app/needle/issue/NE-3798
6
35
 
7
- export let buildPipelineTask;
8
36
 
37
+ /** @type {Promise<any>|null} */
38
+ let buildPipelineTask;
39
+ /** @type {null | {tempDirectory:string, outputDirectory:string}} */
40
+ let buildPipelineTaskResults = null;
41
+
42
+ export function waitForBuildPipelineToFinish() {
43
+ return buildPipelineTask;
44
+ }
45
+
46
+ /** This time is set when a build plugin is triggered to run
47
+ * We increase the time by 10-20 seconds each time because we might have a multi step process
48
+ * where the build pipeline is triggered in the SSR build (which we can not always detect)
49
+ * and the final client build is triggered later (but the build pipeline is still running and waiting)
50
+ */
51
+ let maxOutputDirectoryCreatedWaitTime = 0;
9
52
  /**
53
+ * @param {boolean} debugLog
54
+ */
55
+ function increaseMaxWaitTime(debugLog) {
56
+ maxOutputDirectoryCreatedWaitTime = Date.now();
57
+ let maxWaitTime = 10_000;
58
+ if (process.env.CI) {
59
+ maxWaitTime += 50_000;
60
+ }
61
+ maxOutputDirectoryCreatedWaitTime += maxWaitTime;
62
+ if (debugLog) {
63
+ log(`Increased max wait time by ${maxWaitTime / 1000}sec until ${new Date(maxOutputDirectoryCreatedWaitTime).toISOString()}`);
64
+ }
65
+ }
66
+
67
+ /**
10
68
  * Runs the needle build pipeline as part of the vite build process
69
+ * @param {string} command
70
+ * @param {import('vite').UserConfig} config
11
71
  * @param {import('../types').userSettings} userSettings
12
- * @returns {import('vite').Plugin}
72
+ * @returns {Promise<import('vite').Plugin | null>}
13
73
  */
14
74
  export const needleBuildPipeline = async (command, config, userSettings) => {
15
75
 
16
76
  // we only want to run compression here if this is a distribution build
17
77
  // this is handled however in the `apply` hook
18
- if (userSettings.noBuildPipeline) return;
78
+ if (userSettings.noBuildPipeline) return null;
79
+ if (userSettings.buildPipeline?.enabled === false) {
80
+ log("Skipping build pipeline because it is disabled in the user settings via `buildPipeline.enabled: false`");
81
+ return null;
82
+ }
19
83
 
20
84
  const packageJsonPath = process.cwd() + "/package.json";
21
85
  await fixPackageJson(packageJsonPath);
@@ -26,53 +90,102 @@
26
90
  if (productionArgument >= 0) {
27
91
  shouldRun = true;
28
92
  }
29
- else {
30
- const meta = await loadConfig();
31
- if (meta && meta.developmentBuild === false) {
32
- shouldRun = true;
33
- }
34
- }
35
93
 
36
94
  if (!shouldRun) {
37
95
  log("Skipping build pipeline because this is a development build.\n- Invoke with `--production` to run the build pipeline.\n- For example \"vite build -- --production\".");
38
96
  await new Promise((resolve, _) => setTimeout(resolve, 1000));
39
- return;
97
+ return null;
40
98
  }
41
99
 
42
- /** @type {Promise<any>|null} */
43
- let task = null;
44
- let taskFinished = false;
45
- let taskSucceeded = false;
100
+ if (process.env.CI) {
101
+ log("Running in CI environment");
102
+ }
103
+
104
+ validateCloudBuildConfiguration(userSettings);
105
+
106
+ const verboseOutput = userSettings?.buildPipeline?.verbose || false;
107
+ let taskHasCompleted = false;
108
+
46
109
  return {
47
110
  name: 'needle:buildpipeline',
48
111
  enforce: "post",
49
- apply: 'build',
50
- buildEnd() {
51
- // start the compression process once vite is done copying the files
52
- task = invokeBuildPipeline(userSettings).then((res) => {
53
- taskFinished = true;
54
- taskSucceeded = res;
55
- });
56
- buildPipelineTask = task;
112
+ apply: (_conf, env) => {
113
+ if (verboseOutput) {
114
+ log("Apply:", env);
115
+ }
116
+ // Don't run for SSR builds (e.g. sveltekit).
117
+ // Unfortunately this is always falls in vite 4.3 so we can not rely on it solely
118
+ if (env.ssrBuild) return false;
119
+ // Dont run if there's already a build pipeline task running
120
+ if (env.command === "build") {
121
+ increaseMaxWaitTime(verboseOutput);
122
+ if (buildPipelineTask) {
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+ return false;
57
128
  },
129
+ buildEnd(error) {
130
+ increaseMaxWaitTime(verboseOutput);
131
+
132
+ if (verboseOutput) {
133
+ log("Build end:", error ?? "No error");
134
+ }
135
+ if (error) {
136
+ // if there was an error during the build we should not run the build pipeline
137
+ }
138
+ else {
139
+ if (buildPipelineTask) {
140
+ log("Build pipeline already running...");
141
+ return;
142
+ }
143
+ let taskSucceeded = false;
144
+ // start the compression process once vite is done copying the files
145
+ buildPipelineTask = invokeBuildPipeline(userSettings)
146
+ .then((res) => {
147
+ taskSucceeded = res;
148
+ })
149
+ .finally(() => {
150
+ taskHasCompleted = true;
151
+ if (!taskSucceeded) {
152
+ throw new Error("[needle-buildpipeline] - Build pipeline failed. Check the logs for more information.");
153
+ }
154
+ else {
155
+ log("Finished successfully");
156
+ }
157
+ });
158
+ }
159
+ },
58
160
  closeBundle() {
59
- log("Waiting for postprocessing to finish...");
60
- // this is the last hook that is called, so we can wait for the task to finish here
61
- if (taskFinished) return;
62
- // delay the final log slightly to give other plugins a chance to log their stuff
63
- wait(100).then(() => {
64
- if (!taskFinished) log("Waiting for postprocessing to finish...")
161
+ if (!buildPipelineTask) {
162
+ return;
163
+ }
164
+ if (!taskHasCompleted) {
165
+ log("Waiting for build pipeline to finish...");
166
+ }
167
+ // // this is the last hook that is called, so we can wait for the task to finish here
168
+ return buildPipelineTask = buildPipelineTask?.then(() => {
169
+ // Copy the results to their final output directory.
170
+ if (buildPipelineTaskResults != null) {
171
+ log("Copying files from temporary output directory to final output directory...");
172
+ const ctx = { count: 0 }
173
+ copyFilesSync(buildPipelineTaskResults.tempDirectory, buildPipelineTaskResults.outputDirectory, true, ctx);
174
+ log(`Copied ${ctx.count} file(s)`);
175
+ }
176
+ else {
177
+ log("No files to copy - build pipeline did not run or did not finish successfully");
178
+ }
65
179
  });
66
- return task.then(_ => {
67
- log("finished", taskSucceeded ? "successfully" : "with errors");
68
- });
69
180
  },
70
181
  }
71
182
  }
72
183
 
184
+
73
185
  /**
74
186
  * Previously we did always install the build pipeline and run an extra command to invoke the build pipeline.
75
187
  * This is now done automatically by the needle build pipeline plugin - so we update all legacy projects to use the new method.
188
+ * @param {string} packageJsonPath
76
189
  */
77
190
  async function fixPackageJson(packageJsonPath) {
78
191
  if (!existsSync(packageJsonPath)) {
@@ -89,9 +202,11 @@
89
202
  writeFileSync(packageJsonPath, fixed);
90
203
  }
91
204
 
205
+ /** @param {any} args */
92
206
  function log(...args) {
93
207
  console.log("[needle-buildpipeline]", ...args);
94
208
  }
209
+ /** @param {any} args */
95
210
  function warn(...args) {
96
211
  console.warn("WARN: [needle-buildpipeline]", ...args);
97
212
  }
@@ -104,63 +219,135 @@
104
219
  const installPath = "node_modules/@needle-tools/gltf-build-pipeline";
105
220
  const fullInstallPath = process.cwd() + "/" + installPath;
106
221
  const existsLocally = existsSync(fullInstallPath);
107
- if (!existsLocally) {
108
- // await execSync("npx --yes @needle-tools/n")
109
- // throw new Error(`ERR: Build pipeline not found at \"${fullInstallPath}\". \nTo disable this plugin you can pass \"noBuildPipeline: true\" to the needle config in vite.config.js`);
110
- warn("@needle-tools/gltf-build-pipeline not found in package - using latest version")
222
+ if (existsLocally) {
223
+ log("Found local build pipeline installation at " + fullInstallPath);
111
224
  }
112
- await wait(500);
225
+ await delay(500);
113
226
  const outputDirectory = getOutputDirectory() + "/assets";
114
- // wait until the output directory exists - this depends on speed
227
+ const startWaitTime = Date.now();
228
+ const maxEndTime = startWaitTime + 120_000;
229
+ /** wait until the output directory exists
230
+ * @param {number} iteration
231
+ * @returns {Promise<boolean>}
232
+ */
115
233
  function waitForOutputDirectory(iteration) {
234
+ // we wait for the output directory
116
235
  if (!existsSync(outputDirectory)) {
117
- if (iteration > 10) {
236
+ if (maxOutputDirectoryCreatedWaitTime != 0 && Date.now() > maxOutputDirectoryCreatedWaitTime) {
118
237
  return Promise.resolve(false);
119
238
  }
120
- if (iteration <= 0) log("Waiting for output directory to be created...");
121
- return wait(1000).then(() => waitForOutputDirectory(iteration + 1));
239
+ else if (Date.now() > maxEndTime) {
240
+ log("Max wait time exceeded - aborting...");
241
+ return Promise.resolve(false);
242
+ }
243
+ if (iteration <= 0) log(`Waiting for output directory to be created... (${outputDirectory})`);
244
+ return delay(1000).then(() => waitForOutputDirectory(iteration + 1));
122
245
  }
123
246
  return Promise.resolve(true);
124
247
  }
125
248
  if (!await waitForOutputDirectory(0)) {
126
- warn("Directory not found at " + outputDirectory);
127
- return;
249
+ warn(`Output directory not found/created at \"${outputDirectory}\" - aborting...`);
250
+ return false;
128
251
  }
129
- const files = readdirSync(outputDirectory).filter(f => f.endsWith(".glb") || f.endsWith(".gltf"));
130
- log(files.length + " file(s) to process");
252
+ const files = readdirSync(outputDirectory).filter(f => f.endsWith(".glb") || f.endsWith(".gltf") || f.endsWith(".vrm") || f.endsWith(".fbx"));
253
+ log(`${files.length} file(s) to process in ${outputDirectory}`);
131
254
 
132
255
  /** @type {null | ChildProcess} */
133
- let sub = null;
256
+ let proc = null;
134
257
 
135
- if (existsLocally) {
136
- const cmd = `needle-gltf transform "${outputDirectory}"`;
258
+ let cloudAccessToken = opts.license?.accessToken;
259
+ if (!cloudAccessToken) {
260
+ cloudAccessToken = process.env.NEEDLE_CLOUD_TOKEN;
261
+ }
262
+ const runInCloud = typeof cloudAccessToken === "string" && cloudAccessToken.length > 0;
263
+ // if a user has defined the build pipeline settings object but not passed in a token we should print out some information
264
+ // or perhaps log an error / prevent the build from running completely
265
+ if (opts.buildPipeline && !runInCloud && process.env.CI) {
266
+ warn(`No cloud access token found. Please set it via process.env.NEEDLE_CLOUD_TOKEN`);
267
+ return false;
268
+ }
269
+
270
+
271
+ // put the processed files first in a temporary directory. They will be moved to the output directory at the end of the buildstep
272
+ // this is so that processes like sveltekit-static-adapter can run first and does not override already compressed files
273
+ const tempOutputPath = process.cwd() + "/node_modules/.needle/build-pipeline/output";
274
+ if (existsSync(tempOutputPath)) {
275
+ log("Removing temporary output directory at " + tempOutputPath);
276
+ rmSync(tempOutputPath, { recursive: true, force: true });
277
+ }
278
+ mkdirSync(tempOutputPath, { recursive: true });
279
+
280
+ /** @param {number} code */
281
+ function onExit(code) {
282
+ if (code === 0)
283
+ buildPipelineTaskResults = {
284
+ tempDirectory: tempOutputPath,
285
+ outputDirectory: outputDirectory
286
+ }
287
+ }
288
+
289
+ // allow running the build pipeline in the cloud. It requires and access token to be set in the vite.config.js
290
+ // this can be set via e.g. process.env.NEEDLE_CLOUD_TOKEN
291
+ if (runInCloud) {
292
+ if (!cloudAccessToken || !(typeof cloudAccessToken === "string") || cloudAccessToken.length <= 0) {
293
+ throw new Error("No cloud access token configured. Please set it via process.env.NEEDLE_CLOUD_TOKEN or in the vite.config.js");
294
+ }
295
+ let cmd = `npx --yes needle-cloud@main optimize "${outputDirectory}" --token ${cloudAccessToken}`;
296
+ let projectName = opts.buildPipeline?.projectName;
297
+ // Default project name for compression
298
+ // TODO: maybe this should be taken from the package.json name field or needle.config?
299
+ if (!projectName) {
300
+ projectName = "compression";
301
+ }
302
+ if (projectName) {
303
+ cmd += ` --name "${projectName}"`;
304
+ }
305
+ if (opts.buildPipeline?.verbose === true) {
306
+ cmd += " --verbose";
307
+ }
308
+ cmd += " --outdir \"" + tempOutputPath + "\"";
309
+ console.log("\n")
310
+ log("Running compression in cloud â›…");
311
+ proc = exec(cmd);
312
+ }
313
+ else if (existsLocally) {
314
+ const cmd = `needle-gltf transform "${outputDirectory}" \"${tempOutputPath}\"`;
137
315
  log("Running command \"" + cmd + "\" at " + process.cwd() + "...");
138
- sub = exec(cmd, { cwd: installPath });
316
+ proc = exec(cmd, { cwd: installPath });
139
317
  }
140
318
  else {
141
- const version = opts.buildPipelineVersion || "latest";
142
- const cmd = `npx --yes @needle-tools/gltf-build-pipeline@${version} transform "${outputDirectory}"`;
143
- log("Running command \"" + cmd);
144
- sub = exec(cmd);
319
+ const version = opts.buildPipeline?.version || "latest";
320
+ const cmd = `npx --yes @needle-tools/gltf-build-pipeline@${version} transform "${outputDirectory}" \"${tempOutputPath}\"`;
321
+ log(`Running compression locally with version ${version}`);
322
+ proc = exec(cmd);
145
323
  }
146
- sub.stdout.on('data', data => {
324
+ /** @param {any} data */
325
+ function onLog(data) {
147
326
  if (data.length <= 0) return;
148
327
  // ensure that it doesnt end with a newline
149
- if (data.endsWith("\n")) data = data.slice(0, -1);
150
- console.log(data);
151
- });
152
- sub.stderr.on('data', console.error);
328
+ while (data.endsWith("\n")) data = data.slice(0, -1);
329
+ if (typeof data === "string") {
330
+ if (data.startsWith("ERR:")) {
331
+ console.error(data);
332
+ return;
333
+ }
334
+ else if (data.startsWith("WARN:")) {
335
+ console.warn(data);
336
+ return;
337
+ }
338
+ // Ignore empty lines
339
+ else if (data.trim().length <= 0) {
340
+ return;
341
+ }
342
+ }
343
+ log(data);
344
+ }
345
+ proc.stdout?.on('data', onLog);
346
+ proc.stderr?.on('data', onLog);
153
347
  return new Promise((resolve, reject) => {
154
- sub.on('exit', (code) => {
348
+ proc.on('exit', (code) => {
349
+ onExit(code || 0);
155
350
  resolve(code === 0);
156
351
  });
157
352
  });
158
353
  }
159
-
160
- function wait(ms) {
161
- return new Promise((resolve, reject) => {
162
- setTimeout(() => {
163
- resolve();
164
- }, ms);
165
- });
166
- }
plugins/common/buildinfo.js CHANGED
@@ -16,11 +16,11 @@
16
16
  totalsize: 0,
17
17
  files: []
18
18
  };
19
- console.log("[needle-buildinfo] - Collect files in " + buildDirectory);
19
+ console.log(`[needle-buildinfo] - Begin collecting files in \"${buildDirectory}\"`);
20
20
  recursivelyCollectFiles(buildDirectory, "", buildInfo);
21
21
  const buildInfoPath = `${buildDirectory}/needle.buildinfo.json`;
22
22
  const totalSizeInMB = buildInfo.totalsize / 1024 / 1024;
23
- console.log(`[needle-buildinfo] - Collected ${buildInfo.files.length} files (${totalSizeInMB.toFixed(2)} MB). Write info to \"${buildInfoPath}\"`);
23
+ console.log(`[needle-buildinfo] - Collected ${buildInfo.files.length} files (${totalSizeInMB.toFixed(2)} MB). Writing build info to \"${buildInfoPath}\"`);
24
24
  fs.writeFileSync(buildInfoPath, JSON.stringify(buildInfo));
25
25
  }
26
26
 
@@ -39,10 +39,11 @@
39
39
  }
40
40
  const newPath = path?.length <= 0 ? entry.name : `${path}/${entry.name}`;
41
41
  const newDirectory = `${directory}/${entry.name}`;
42
- console.log("[needle-buildinfo] - Collect files in " + newPath);
42
+ console.log(`[needle-buildinfo] - Collect files in \"/${newPath}\"`);
43
43
  recursivelyCollectFiles(newDirectory, newPath, info);
44
44
  } else {
45
45
  const relpath = `${path}/${entry.name}`;
46
+ // console.log("[needle-buildinfo] - New File: " + relpath);
46
47
  const filehash = crypto.createHash('sha256');
47
48
  const fullpath = `${directory}/${entry.name}`;
48
49
  filehash.update(fs.readFileSync(fullpath));
plugins/vite/buildinfo.js CHANGED
@@ -1,32 +1,38 @@
1
1
  import { createBuildInfoFile } from '../common/buildinfo.js';
2
- import { buildPipelineTask } from './build-pipeline.js';
2
+ import { waitForBuildPipelineToFinish } from './build-pipeline.js';
3
3
  import { getOutputDirectory } from './config.js';
4
4
 
5
+ let level = 0;
5
6
 
6
-
7
7
  /** Create a buildinfo file in the build directory
8
8
  * @param {import('../types').userSettings} userSettings
9
- * @returns {import('vite').Plugin}
9
+ * @returns {import('vite').Plugin | null}
10
10
  */
11
11
  export const needleBuildInfo = (command, config, userSettings) => {
12
12
 
13
- if (userSettings?.noBuildInfo) return;
13
+ if (userSettings?.noBuildInfo) return null;
14
14
 
15
15
  return {
16
16
  name: 'needle:buildinfo',
17
17
  apply: "build",
18
18
  enforce: "post",
19
+ buildStart: () => {
20
+ level++;
21
+ },
19
22
  closeBundle: async () => {
20
- return new Promise(async res => {
21
- if (buildPipelineTask instanceof Promise) {
22
- await buildPipelineTask.catch(() => { });
23
- }
24
- // wait for gzip
25
- await delay(500);
26
- const buildDirectory = getOutputDirectory();
27
- createBuildInfoFile(buildDirectory);
28
- res();
29
- });
23
+ if (--level > 0) {
24
+ console.log("[needle-buildinfo] - Skipped because of nested build");
25
+ return;
26
+ }
27
+ const task = waitForBuildPipelineToFinish();
28
+ if (task instanceof Promise) {
29
+ console.log("[needle-buildinfo] - Waiting for build pipeline to finish");
30
+ await task.catch(() => { }).finally(() => console.log("[needle-buildinfo] - Build pipeline finished!"));
31
+ }
32
+ // wait for gzip
33
+ await delay(500);
34
+ const buildDirectory = getOutputDirectory();
35
+ createBuildInfoFile(buildDirectory);
30
36
  }
31
37
  }
32
38
  }
plugins/vite/config.js CHANGED
@@ -4,7 +4,8 @@
4
4
  let didLogCanNotFindConfig = false;
5
5
 
6
6
  /** the codegen meta file
7
- * @returns {import("../types/needleConfig").needleMeta}
7
+ * @param {string | null} path
8
+ * @returns {Promise<import("../types/needleConfig").needleMeta | null>}
8
9
  */
9
10
  export async function loadConfig(path) {
10
11
  try {
@@ -32,12 +33,12 @@
32
33
  console.error("Could not find config file at " + path);
33
34
  }
34
35
  }
35
- return {};
36
+ return null;
36
37
  }
37
38
  catch (err) {
38
- console.error("Error loading config file");
39
+ console.error("ERR: Error loading config file");
39
40
  console.error(err);
40
- return {};
41
+ return null;
41
42
  }
42
43
  }
43
44
 
plugins/vite/copyfiles.js CHANGED
@@ -1,41 +1,52 @@
1
1
 
2
2
  import { resolve, join, isAbsolute } from 'path'
3
- import { existsSync, statSync, mkdirSync, readdirSync, copyFileSync, mkdir } from 'fs';
3
+ import { existsSync, statSync, mkdirSync, readdirSync, copyFileSync, mkdir, rmSync } from 'fs';
4
4
  import { builtAssetsDirectory, tryLoadProjectConfig } from './config.js';
5
+ import { copyFilesSync } from '../common/files.js';
5
6
 
7
+ const pluginName = "needle-copy-files";
6
8
 
9
+
7
10
  /** copy files on build from assets to dist
8
11
  * @param {import('../types').userSettings} userSettings
12
+ * @returns {import('vite').Plugin | null}
9
13
  */
10
14
  export const needleCopyFiles = (command, config, userSettings) => {
11
15
 
12
16
  if (config?.noCopy === true || userSettings?.noCopy === true) {
13
- return;
17
+ return null;
14
18
  }
15
19
 
16
20
  return {
17
21
  name: 'needle-copy-files',
22
+ // explicitly don't enforce post or pre because we want to run before the build-pipeline plugin
23
+ enforce: "pre",
18
24
  buildStart() {
19
- return run(false, config);
25
+ return run("start", config);
20
26
  },
21
27
  closeBundle() {
22
- return run(true, config);
28
+ return run("end", config);
23
29
  },
24
30
  }
25
31
  }
26
32
 
27
- async function run(isBuild, config) {
33
+ /**
34
+ * @param {"start" | "end"} buildstep
35
+ * @param {import('../types').userSettings} config
36
+ */
37
+ async function run(buildstep, config) {
38
+ console.log(`[${pluginName}] - Copy files at ${buildstep}`);
28
39
  const copyIncludesFromEngine = config?.copyIncludesFromEngine ?? true;
29
40
 
30
41
  const baseDir = process.cwd();
31
- const pluginName = "needle-copy-files";
42
+ const override = buildstep === "start";
32
43
 
33
44
  let assetsDirName = "assets";
34
45
  let outdirName = "dist";
35
46
 
36
47
  const needleConfig = tryLoadProjectConfig();
37
48
  if (needleConfig) {
38
- assetsDirName = needleConfig.assetsDirectory;
49
+ assetsDirName = needleConfig.assetsDirectory || assetsDirName;
39
50
  while (assetsDirName.startsWith('/')) assetsDirName = assetsDirName.substring(1);
40
51
 
41
52
  if (needleConfig.buildDirectory)
@@ -48,60 +59,67 @@
48
59
  if (existsSync(engineIncludeDir)) {
49
60
  console.log(`[${pluginName}] - Copy engine include to ${baseDir}/include`)
50
61
  const projectIncludeDir = resolve(baseDir, 'include');
51
- copyRecursiveSync(engineIncludeDir, projectIncludeDir);
62
+ copyFilesSync(engineIncludeDir, projectIncludeDir);
52
63
  }
53
64
  }
54
65
 
55
- if (isBuild) {
56
- const outDir = resolve(baseDir, outdirName);
57
- if (!existsSync(outDir)) {
58
- mkdirSync(outDir);
59
- }
66
+ const outDir = resolve(baseDir, outdirName);
67
+ if (!existsSync(outDir)) {
68
+ mkdirSync(outDir);
69
+ }
60
70
 
61
- // copy a list of files or directories declared in build.copy = [] in the needle.config.json
62
- /*
63
- "build": {
64
- "copy": ["myFolder", "myFile.txt"]
65
- }
66
- */
67
- if (needleConfig?.build?.copy) {
68
- const arr = needleConfig.build.copy;
69
- for (let i = 0; i < arr.length; i++) {
70
- const entry = arr[i];
71
- if (Array.isArray(entry)) {
72
- console.log("WARN: build.copy can only contain string paths to copy to. Found array instead.");
73
- continue;
74
- }
75
- const src = resolve(baseDir, entry);
76
- const dest = resolvePath(outDir, entry);
77
- if (existsSync(src)) {
78
- console.log(`[${pluginName}] - Copy ${entry} to ${outdirName}/${entry}`)
79
- copyRecursiveSync(src, dest);
80
- }
71
+ // copy a list of files or directories declared in build.copy = [] in the needle.config.json
72
+ /*
73
+ "build": {
74
+ "copy": ["myFolder", "myFile.txt"]
75
+ }
76
+ */
77
+ if (needleConfig?.build?.copy) {
78
+ const arr = needleConfig.build.copy;
79
+ for (let i = 0; i < arr.length; i++) {
80
+ const entry = arr[i];
81
+ if (Array.isArray(entry)) {
82
+ console.log("WARN: build.copy can only contain string paths to copy to. Found array instead.");
83
+ continue;
81
84
  }
85
+ const src = resolve(baseDir, entry);
86
+ const dest = resolvePath(outDir, entry);
87
+ if (existsSync(src) && dest) {
88
+ console.log(`[${pluginName}] - Copy ${entry} to ${outdirName}/${entry}`)
89
+ copyFilesSync(src, dest, override);
90
+ }
82
91
  }
92
+ }
83
93
 
84
- // copy assets dir
85
- const assetsDir = resolve(baseDir, assetsDirName);
86
- if (existsSync(assetsDir)) {
87
- console.log(`[${pluginName}] - Copy assets to ${outdirName}/${builtAssetsDirectory()}`)
88
- const targetDir = resolve(outDir, 'assets');
89
- copyRecursiveSync(assetsDir, targetDir);
94
+ // copy assets dir
95
+ const assetsDir = resolve(baseDir, assetsDirName);
96
+ if (existsSync(assetsDir)) {
97
+ const targetDir = resolve(outDir, 'assets');
98
+ // ensure that the target directory exists and is cleared if it already exists
99
+ // otherwise we might run into issues where the build pipeline is running for already compressed files
100
+ if (override && existsSync(targetDir)) {
101
+ console.log(`[${pluginName}] - Clearing target directory \"${targetDir}\"`);
102
+ rmSync(targetDir, { recursive: true, force: true });
90
103
  }
91
- else console.log(`WARN: No assets directory found. Skipping copy of ${assetsDirName} resolved to ${assetsDir}`)
92
- // copy include dir
93
- const includeDir = resolve(baseDir, 'include');
94
- if (existsSync(includeDir)) {
95
- console.log(`[${pluginName}] - Copy include to ${outdirName}/include`)
96
- const targetDir = resolve(outDir, 'include');
97
- copyRecursiveSync(includeDir, targetDir);
98
- }
104
+ console.log(`[${pluginName}] - Copy assets to ${outdirName}/${builtAssetsDirectory()}`)
105
+ copyFilesSync(assetsDir, targetDir, override);
99
106
  }
107
+ else console.log(`WARN: No assets directory found. Skipping copy of ${assetsDirName} resolved to ${assetsDir}`)
108
+
109
+ // copy include dir
110
+ const includeDir = resolve(baseDir, 'include');
111
+ if (existsSync(includeDir)) {
112
+ console.log(`[${pluginName}] - Copy include to ${outdirName}/include`)
113
+ const targetDir = resolve(outDir, 'include');
114
+ copyFilesSync(includeDir, targetDir, override);
115
+ }
100
116
  }
101
117
 
102
118
  /** resolves relative or absolute paths to a path inside the out directory
103
119
  * for example D:/myFile.txt would resolve to outDir/myFile.txt
104
120
  * wherereas "some/relative/path" would become outDir/some/relative/path
121
+ * @param {string} outDir
122
+ * @param {string} pathValue
105
123
  */
106
124
  function resolvePath(outDir, pathValue) {
107
125
  if (isAbsolute(pathValue)) {
@@ -110,29 +128,12 @@
110
128
  var stats = exists && statSync(pathValue);
111
129
  if (stats.isDirectory()) {
112
130
  const dirName = pathValue.replaceAll('\\', '/').split('/').pop();
131
+ if (!dirName) return null;
113
132
  return resolve(outDir, dirName);
114
133
  }
115
134
  const fileName = pathValue.replaceAll('\\', '/').split('/').pop();
135
+ if (!fileName) return null;
116
136
  return resolve(outDir, fileName);
117
137
  }
118
138
  return resolve(outDir, pathValue);
119
- }
120
-
121
- function copyRecursiveSync(src, dest) {
122
- if (dest === null) {
123
- console.log(`[${pluginName}] - Copy ${src} to ${dest} - dest is null`)
124
- return;
125
- }
126
- var exists = existsSync(src);
127
- var stats = exists && statSync(src);
128
- var isDirectory = exists && stats.isDirectory();
129
- if (isDirectory) {
130
- if (!existsSync(dest))
131
- mkdirSync(dest, { recursive: true });
132
- readdirSync(src).forEach(function (childItemName) {
133
- copyRecursiveSync(join(src, childItemName), join(dest, childItemName));
134
- });
135
- } else {
136
- copyFileSync(src, dest);
137
- }
138
- };
139
+ }
plugins/vite/defines.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadConfig } from "./config.js";
2
2
  import { tryGetNeedleEngineVersion } from "../common/version.js";
3
+ import { getPublicIdentifier as getPublicIdentifier } from "../common/license.js";
3
4
 
4
5
  // NOTE: ALL DEFINES MUST BE SET HERE! NEVER ADD OR RELY ON DEFINES IN ANY OTHER PLUGIN
5
6
 
@@ -22,7 +23,7 @@
22
23
  return {
23
24
  name: 'needle:defines',
24
25
  enforce: 'pre',
25
- config(viteConfig) {
26
+ async config(viteConfig) {
26
27
  // console.log("Update vite defines -------------------------------------------");
27
28
  if (!viteConfig.define) viteConfig.define = {};
28
29
  const version = tryGetNeedleEngineVersion();
@@ -45,6 +46,12 @@
45
46
 
46
47
  // this gives a timestamp containing the timezone
47
48
  viteConfig.define.NEEDLE_PROJECT_BUILD_TIME = "\"" + new Date().toString() + "\"";
49
+
50
+ const projectId = undefined; // TODO: this needs to be exported by the integration (if any)
51
+ const publicIdentifier = await getPublicIdentifier(projectId).catch(console.warn);
52
+ if(publicIdentifier){
53
+ viteConfig.define.NEEDLE_PUBLIC_KEY = "\"" + publicIdentifier + "\"";
54
+ }
48
55
  }
49
56
  }
50
57
  }
plugins/vite/dependencies.js CHANGED
@@ -7,28 +7,90 @@
7
7
  * @param {import('../types').userSettings} userSettings
8
8
  */
9
9
  export const needleDependencies = (command, config, userSettings) => {
10
+
11
+ const handleChunks = true;
12
+
10
13
  /**
11
14
  * @type {import('vite').Plugin}
12
15
  */
13
16
  return {
14
17
  name: 'needle:dependencies',
15
18
  enforce: 'pre',
16
- config: (config) => {
17
- if (config.optimizeDeps?.include?.includes("three-mesh-bvh")) {
18
- console.log("[needle-dependencies] three-mesh-bvh is included in the optimizeDeps.include array. This may cause issues with the worker import.");
19
- }
20
- else {
21
- if (!config.optimizeDeps) {
22
- config.optimizeDeps = {};
23
- }
24
- if (!config.optimizeDeps.exclude) {
25
- config.optimizeDeps.exclude = [];
26
- }
27
- console.log("[needle-dependencies] Adding three-mesh-bvh to the optimizeDeps.exclude array.");
28
- // This needs to be excluded from optimization because otherwise the worker import fails
29
- // three-mesh-bvh/src/workers/generateMeshBVH.worker.js?worker
30
- config.optimizeDeps.exclude.push("three-mesh-bvh");
31
- }
19
+ /**
20
+ * @param {import('vite').UserConfig} config
21
+ */
22
+ config: (config, env) => {
23
+ handleOptimizeDeps(config);
24
+ handleManualChunks(config);
32
25
  }
33
26
  }
34
27
  }
28
+
29
+ /**
30
+ * @param {import('vite').UserConfig} config
31
+ */
32
+ function handleOptimizeDeps(config) {
33
+ if (config.optimizeDeps?.include?.includes("three-mesh-bvh")) {
34
+ console.log("[needle-dependencies] three-mesh-bvh is included in the optimizeDeps.include array. This may cause issues with the worker import.");
35
+ }
36
+ else {
37
+ if (!config.optimizeDeps) {
38
+ config.optimizeDeps = {};
39
+ }
40
+ if (!config.optimizeDeps.exclude) {
41
+ config.optimizeDeps.exclude = [];
42
+ }
43
+ console.log("[needle-dependencies] Adding three-mesh-bvh to the optimizeDeps.exclude array.");
44
+ // This needs to be excluded from optimization because otherwise the worker import fails
45
+ // three-mesh-bvh/src/workers/generateMeshBVH.worker.js?worker
46
+ config.optimizeDeps.exclude.push("three-mesh-bvh");
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {import('vite').UserConfig} config
52
+ */
53
+ function handleManualChunks(config) {
54
+ if (!config.build) {
55
+ config.build = {};
56
+ }
57
+ if (!config.build.rollupOptions) {
58
+ config.build.rollupOptions = {};
59
+ }
60
+ if (!config.build.rollupOptions.output) {
61
+ config.build.rollupOptions.output = {};
62
+ }
63
+
64
+ const rollupOutput = config.build.rollupOptions.output;
65
+
66
+ if (Array.isArray(rollupOutput)) {
67
+ // append the manualChunks function to the array
68
+ console.log("[needle-dependencies] registering manualChunks");
69
+ rollupOutput.push({
70
+ manualChunks: needleManualChunks
71
+ })
72
+ }
73
+ else {
74
+ if (rollupOutput.manualChunks) {
75
+ // if the user has already defined manualChunks, we don't want to overwrite it
76
+ console.log("[needle-dependencies] manualChunks already defined");
77
+ }
78
+ else {
79
+ console.log("[needle-dependencies] registering manualChunks");
80
+ rollupOutput.manualChunks = needleManualChunks;
81
+ }
82
+ }
83
+
84
+ function needleManualChunks(id) {
85
+ // Push the pmndrs postprocessing package into a separate postprocessing chunk
86
+ if (id.includes("node_modules/postprocessing/" || id.includes("node_modules/n8ao"))) {
87
+ return "postprocessing";
88
+ }
89
+ else if (id.includes("node_modules/three-mesh-ui")) {
90
+ return "three-mesh-ui";
91
+ }
92
+ else if (id.includes("@dimforge/rapier3d")) {
93
+ return "rapier3d";
94
+ }
95
+ }
96
+ }
plugins/vite/drop.js CHANGED
@@ -7,8 +7,13 @@
7
7
  const __dirname = path.dirname(__filename);
8
8
 
9
9
  /** experimental, allow dropping files from Unity into the running scene */
10
+ /**
11
+ * @param {import('../types/userconfig.js').userSettings} userSettings
12
+ */
10
13
  export const needleDrop = (command, config, userSettings) => {
11
14
  if (command === "build") return;
15
+
16
+ if(userSettings.useDrop !== true) return null;
12
17
 
13
18
  return {
14
19
  name: "needle:drop",
plugins/vite/index.js CHANGED
@@ -96,7 +96,7 @@
96
96
  }
97
97
  * ```
98
98
  * @param {string} command
99
- * @param {import('../types').userSettings} userSettings
99
+ * @param {import('../types/index.js').userSettings} userSettings
100
100
  */
101
101
  export const needlePlugins = async (command, config, userSettings) => {
102
102
 
plugins/vite/license.js CHANGED
@@ -1,11 +1,11 @@
1
- import { resolveLicense } from '../common/license.cjs';
1
+ import { resolveLicense } from '../common/license.js';
2
2
  import { loadConfig } from './config.js';
3
3
 
4
4
  /**
5
5
  * This plugin is used to apply the license to the needle engine.
6
6
  * @param {string} command - The command that is being run
7
7
  * @param {object} config - The config object
8
- * @param {import('../types/userconfig.js').userSettings}
8
+ * @param {import('../types/userconfig.js').userSettings} userSettings
9
9
  */
10
10
  export const needleLicense = (command, config, userSettings) => {
11
11
  let license = undefined;
@@ -14,17 +14,20 @@
14
14
  name: "needle:license",
15
15
  enforce: 'pre',
16
16
  async configResolved() {
17
- if (userSettings.license) {
18
- // we only accept a license object here
19
- if (typeof userSettings.license === "object")
20
- license = await resolveLicense(userSettings.license);
21
- }
22
- else {
23
- const needleConfig = await loadConfig();
24
- if (needleConfig) {
25
- license = await resolveLicense(needleConfig.license);
17
+
18
+ let team = userSettings?.license?.team;
19
+ if (!team) {
20
+ const meta = await loadConfig(null);
21
+ if (meta) {
22
+ team = meta.license?.team;
26
23
  }
27
24
  }
25
+
26
+ license = await resolveLicense({
27
+ team: team,
28
+ accessToken: userSettings?.license?.accessToken
29
+ });
30
+
28
31
  },
29
32
  async transform(src, id) {
30
33
  const isNeedleEngineFile = id.includes("engine/engine_license") || id.includes("needle-tools_engine");
plugins/next/next.js CHANGED
@@ -42,6 +42,8 @@
42
42
  /** @param {import ('next').NextConfig config } */
43
43
  function nextWebPack(config, { buildId, dev, isServer, defaultLoaders, webpack }) {
44
44
 
45
+ // TODO: get public identifier key from license server
46
+
45
47
  const meta = getMeta();
46
48
  let useRapier = true;
47
49
  if (userSettings.useRapier === false) useRapier = false;
plugins/vite/reload.js CHANGED
@@ -71,6 +71,7 @@
71
71
  if (config?.allowHotReload === false) return html;
72
72
  if (userSettings?.allowHotReload === false) return html;
73
73
  const file = path.join(__dirname, 'reload-client.js');
74
+ const content = readFileSync(file, 'utf8');
74
75
  return {
75
76
  html,
76
77
  tags: [
@@ -79,7 +80,7 @@
79
80
  attrs: {
80
81
  type: 'module',
81
82
  },
82
- children: readFileSync(file, 'utf8'),
83
+ children: content,
83
84
  injectTo: 'body',
84
85
  },
85
86
  ]
src/engine-components/api.ts CHANGED
@@ -52,8 +52,9 @@
52
52
  import "./AnimationUtilsAutoplay.js"
53
53
 
54
54
  export { DragMode } from "./DragControls.js"
55
+ export type { DropListenerNetworkEventArguments } from "./DropListener.js";
55
56
  export * from "./particlesystem/api.js"
56
57
 
57
58
  // for correct type resolution in JSDoc
58
59
  import type { PhysicsMaterial } from "../engine/engine_physics.types.js";
59
- import type { Animation } from "./Animation.js";
60
+ import type { Animation } from "./Animation.js";
src/engine/api.ts CHANGED
@@ -47,6 +47,7 @@
47
47
  export * from "./engine_math.js";
48
48
  export * from "./engine_networking.js";
49
49
  export { syncField } from "./engine_networking_auto.js";
50
+ export * from "./engine_networking_blob.js";
50
51
  export * from "./engine_networking_files.js";
51
52
  export * from "./engine_networking_instantiate.js";
52
53
  export * from "./engine_networking_peer.js";
@@ -73,7 +74,6 @@
73
74
  export * from "./engine_utils.js";
74
75
  export * from "./engine_utils_format.js";
75
76
  export * from "./engine_utils_screenshot.js";
76
- export * from "./engine_web_api.js";
77
77
  export * from "./export/index.js";
78
78
  export * from "./extensions/index.js";
79
79
  export * from "./js-extensions/index.js";
src/engine-components/AvatarLoader.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  import * as loaders from "../engine/engine_loaders.js"
7
7
  import { Context } from "../engine/engine_setup.js";
8
8
  import * as utils from "../engine/engine_utils.js"
9
- import { download_file } from "../engine/engine_web_api.js";
10
9
  import { GameObject } from "./Component.js";
11
10
 
12
11
  const debug = utils.getParam("debugavatar");
@@ -100,8 +99,8 @@
100
99
  if (blob) bin = await blob.arrayBuffer();
101
100
  }
102
101
  if (!bin) {
103
- bin = await download_file(avatarId, avatarId, 0, "no url here go away", true);
104
- if (!bin) return null;
102
+ // bin = await BlobStorage.download(avatarId, avatarId, 0, "no url here go away", true);
103
+ return null;
105
104
  }
106
105
 
107
106
  const gltf = await getLoader().parseSync(context, bin, null!, 0);
src/engine-components/ui/Button.ts CHANGED
@@ -74,7 +74,7 @@
74
74
  }
75
75
 
76
76
  @serializable(EventList)
77
- onClick: EventList = new EventList();
77
+ onClick: EventList<void> = new EventList();
78
78
 
79
79
  private _isHovered: number = 0;
80
80
 
src/engine-components/codegen/components.ts CHANGED
@@ -61,6 +61,7 @@
61
61
  export { DocumentExtension } from "../export/usdz/extensions/DocumentExtension.js";
62
62
  export { DragControls } from "../DragControls.js";
63
63
  export { DropListener } from "../DropListener.js";
64
+ export { DropListenerAddedEvent } from "../DropListener.js";
64
65
  export { Duplicatable } from "../Duplicatable.js";
65
66
  export { EffectWrapper } from "../postprocessing/Effects/EffectWrapper.js";
66
67
  export { EmissionModule } from "../particlesystem/ParticleSystemModules.js";
src/engine-components/DropListener.ts CHANGED
@@ -1,16 +1,20 @@
1
- import { AxesHelper, Box3, Object3D, Vector2, Vector3 } from "three";
1
+ import { AxesHelper, Box3, Cache, Object3D, Vector2, Vector3 } from "three";
2
2
  import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
 
4
4
  import { isDevEnvironment } from "../engine/debug/index.js";
5
5
  import { AnimationUtils } from "../engine/engine_animation.js";
6
6
  import { addComponent } from "../engine/engine_components.js";
7
+ import { Context } from "../engine/engine_context.js";
7
8
  import { destroy } from "../engine/engine_gameobject.js";
8
9
  import { Gizmos } from "../engine/engine_gizmos.js";
9
- import * as files from "../engine/engine_networking_files.js";
10
+ import { getLoader } from "../engine/engine_gltf.js";
11
+ import { BlobStorage } from "../engine/engine_networking_blob.js";
12
+ import { PreviewHelper } from "../engine/engine_networking_files.js";
13
+ import { generateSeed, InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
10
14
  import { serializable } from "../engine/engine_serialization_decorator.js";
11
- import { fitObjectIntoVolume, placeOnSurface } from "../engine/engine_three_utils.js";
12
- import { Vec3 } from "../engine/engine_types.js";
13
- import { getParam } from "../engine/engine_utils.js";
15
+ import { fitObjectIntoVolume, getBoundingBox, placeOnSurface } from "../engine/engine_three_utils.js";
16
+ import { IGameObject, Model, Vec3 } from "../engine/engine_types.js";
17
+ import { getParam, setParamWithoutReload } from "../engine/engine_utils.js";
14
18
  import { Animation } from "./Animation.js";
15
19
  import { Behaviour } from "./Component.js";
16
20
  import { EventList } from "./EventList.js";
@@ -31,14 +35,43 @@
31
35
  declare type DropContext = {
32
36
  screenposition: Vector2;
33
37
  url?: string,
38
+ file?: File;
39
+ point?: Vec3;
40
+ size?: Vec3;
34
41
  }
35
42
 
36
- declare type DropListenerNetworkEvent = {
43
+
44
+ /** Networking event arguments for the DropListener component */
45
+ export declare type DropListenerNetworkEventArguments = {
37
46
  guid: string,
47
+ name: string,
38
48
  url: string | string[],
49
+ /** Worldspace point where the object was placed in the scene */
39
50
  point: Vec3;
51
+ /** Bounding box size */
52
+ size: Vec3;
53
+ contentMD5: string;
40
54
  }
41
55
 
56
+ declare type AddedEventArguments = {
57
+ sender: DropListener,
58
+ /** the root object added to the scene */
59
+ object: Object3D,
60
+ /** The whole dropped model */
61
+ model: Model,
62
+ contentMD5: string;
63
+ dropped: URL | File | undefined;
64
+ }
65
+
66
+ /** Dispatched when an object is dropped/changed */
67
+ export class DropListenerAddedEvent<T extends AddedEventArguments> extends CustomEvent<T> {
68
+ constructor(detail: T) {
69
+ super(DropListenerEvents.ObjectAdded, { detail });
70
+ }
71
+ }
72
+
73
+ const blobKeyName = "blob";
74
+
42
75
  /** The DropListener component is used to listen for drag and drop events in the browser and add the dropped files to the scene
43
76
  * It can be used to allow users to drag and drop glTF files into the scene to add new objects.
44
77
  *
@@ -70,6 +103,12 @@
70
103
  export class DropListener extends Behaviour {
71
104
 
72
105
  /**
106
+ * When enabled the DropListener will automatically network dropped files to other clients.
107
+ */
108
+ @serializable()
109
+ useNetworking: boolean = true;
110
+
111
+ /**
73
112
  * When assigned the Droplistener will only accept files that are dropped on this object.
74
113
  */
75
114
  @serializable(Object3D)
@@ -95,61 +134,87 @@
95
134
  placeAtHitPosition: boolean = true;
96
135
 
97
136
 
137
+ /**
138
+ * Invoked after a file has been **added** to the scene.
139
+ * Arguments are {@link AddedEventArguments}
140
+ * @event object-added
141
+ * @param {AddedEventArguments} evt
142
+ * @example
143
+ * ```typescript
144
+ * dropListener.onDropped.addEventListener((evt) => {
145
+ * console.log("Object added", evt.model);
146
+ * });
147
+ */
98
148
  @serializable(EventList)
99
- onDropped: EventList = new EventList();
149
+ onDropped: EventList<AddedEventArguments> = new EventList();
100
150
 
101
-
102
151
  /** @internal */
103
152
  onEnable(): void {
104
153
  this.context.renderer.domElement.addEventListener("dragover", this.onDrag);
105
154
  this.context.renderer.domElement.addEventListener("drop", this.onDrop);
106
- window.addEventListener("keyup", this.handlePaste);
155
+ window.addEventListener("paste", this.handlePaste);
107
156
  this.context.connection.beginListen("droplistener", this.onNetworkEvent)
108
157
  }
109
158
  /** @internal */
110
159
  onDisable(): void {
111
160
  this.context.renderer.domElement.removeEventListener("dragover", this.onDrag);
112
161
  this.context.renderer.domElement.removeEventListener("drop", this.onDrop);
113
- window.removeEventListener("keyup", this.handlePaste);
114
- this.context.connection.stopListen("droplistener", this.onNetworkEvent)
162
+ window.removeEventListener("paste", this.handlePaste);
163
+ this.context.connection.stopListen("droplistener", this.onNetworkEvent);
115
164
  }
116
165
 
117
- private onNetworkEvent = (evt: DropListenerNetworkEvent) => {
166
+ /**
167
+ * Loads a file from the given URL and adds it to the scene.
168
+ */
169
+ loadFromURL(url: string, data?: { point?: Vec3, size?: Vec3 }) {
170
+ this.addFromUrl(url, { screenposition: new Vector2(), point: data?.point, size: data?.size, }, true);
171
+ }
172
+
173
+ /**
174
+ * Forgets all previously added objects.
175
+ * The droplistener will then not be able to remove previously added objects.
176
+ */
177
+ forgetObjects() {
178
+ this.removePreviouslyAddedObjects(false);
179
+ }
180
+
181
+ private onNetworkEvent = (evt: DropListenerNetworkEventArguments) => {
182
+ if (!this.useNetworking) {
183
+ if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", evt);
184
+ return;
185
+ }
118
186
  if (evt.guid?.startsWith(this.guid)) {
119
187
  const url = evt.url;
188
+ console.debug("[DropListener] Received networked event", evt);
120
189
  if (url) {
121
190
  if (Array.isArray(url)) {
122
191
  for (const _url of url) {
123
- this.addFromUrl(_url, { screenposition: new Vector2() }, true).then(res => {
124
- res?.position.set(evt.point.x, evt.point.y, evt.point.z);
125
- })
192
+ this.addFromUrl(_url, { screenposition: new Vector2(), point: evt.point, size: evt.size, }, true);
126
193
  }
127
194
  }
128
195
  else {
129
- this.addFromUrl(url, { screenposition: new Vector2() }, true).then(res => {
130
- res?.position.set(evt.point.x, evt.point.y, evt.point.z);
131
- })
196
+ this.addFromUrl(url, { screenposition: new Vector2(), point: evt.point, size: evt.size }, true);
132
197
  }
133
198
  }
134
199
  }
135
200
  }
136
201
 
137
- private handlePaste = async (evt: KeyboardEvent) => {
202
+ private handlePaste = (evt: Event) => {
138
203
  if (this.context.connection.allowEditing === false) return;
139
- // detect paste
140
- const isPasteCommand = (evt.ctrlKey || evt.metaKey) && evt.key === "v";
141
- if (isPasteCommand) {
142
- const clipboard = navigator.clipboard;
143
- const value = await clipboard.readText().catch(console.warn);
144
- if (value) {
145
- const isUrl = value.startsWith("http") || value.startsWith("https") || value.startsWith("blob");
146
- if (isUrl) {
147
- const ctx = { screenposition: new Vector2(this.context.input.mousePosition.x, this.context.input.mousePosition.y) };
148
- if (this.testIfIsInDropArea(ctx))
149
- this.addFromUrl(value, ctx, false);
204
+ if (evt.defaultPrevented) return;
205
+ const clipboard = navigator.clipboard;
206
+ clipboard.readText()
207
+ .then(value => {
208
+ if (value) {
209
+ const isUrl = value.startsWith("http") || value.startsWith("https") || value.startsWith("blob");
210
+ if (isUrl) {
211
+ const ctx = { screenposition: new Vector2(this.context.input.mousePosition.x, this.context.input.mousePosition.y) };
212
+ if (this.testIfIsInDropArea(ctx))
213
+ this.addFromUrl(value, ctx, false);
214
+ }
150
215
  }
151
- }
152
- }
216
+ })
217
+ .catch(console.warn);
153
218
  }
154
219
 
155
220
  private onDrag = (evt: DragEvent) => {
@@ -182,12 +247,13 @@
182
247
  const items = evt.dataTransfer.items;
183
248
  if (!items) return;
184
249
 
250
+ const files: File[] = [];
185
251
  for (const ite in items) {
186
252
  const it = items[ite];
187
253
  if (it.kind === "file") {
188
254
  const file = it.getAsFile();
189
255
  if (!file) continue;
190
- await this.addFiles([file], ctx);
256
+ files.push(file);
191
257
  }
192
258
  else if (it.kind === "string" && it.type == "text/plain") {
193
259
  it.getAsString(str => {
@@ -195,6 +261,9 @@
195
261
  });
196
262
  }
197
263
  }
264
+ if (files.length > 0) {
265
+ await this.addDroppedFiles(files, ctx);
266
+ }
198
267
  }
199
268
 
200
269
  private async addFromUrl(url: string, ctx: DropContext, isRemote: boolean) {
@@ -221,8 +290,17 @@
221
290
  return null;
222
291
  }
223
292
 
224
- const res = await files.addFileFromUrl(new URL(url), this.context);
225
- if (res) {
293
+ // TODO: if the URL is invalid this will become a problem
294
+ this.removePreviouslyAddedObjects();
295
+ // const binary = await fetch(url).then(res => res.arrayBuffer());
296
+ const res = await FileHelper.loadFileFromURL(new URL(url), {
297
+ guid: this.guid,
298
+ context: this.context,
299
+ parent: this.gameObject,
300
+ point: ctx.point,
301
+ size: ctx.size,
302
+ });
303
+ if (res && this._addedObjects.length <= 0) {
226
304
  ctx.url = url;
227
305
  const obj = this.addObject(res, ctx, isRemote);
228
306
  return obj;
@@ -235,50 +313,92 @@
235
313
  return null;
236
314
  }
237
315
 
238
- private async addFiles(fileList: Array<File>, ctx: DropContext) {
316
+ private _abort: AbortController | null = null;
317
+
318
+ private async addDroppedFiles(fileList: Array<File>, ctx: DropContext) {
239
319
  if (debug) console.log("Add files", fileList)
240
320
  if (!Array.isArray(fileList)) return;
241
321
  if (!fileList.length) return;
242
322
 
323
+ this.deleteDropEvent();
324
+ this.removePreviouslyAddedObjects();
325
+ setParamWithoutReload(blobKeyName, null);
326
+
327
+ // Create an abort controller for the current drop operation
328
+ this._abort?.abort("New files dropped");
329
+ this._abort = new AbortController();
330
+
243
331
  for (const file of fileList) {
244
332
  if (!file) continue;
245
- if (debug) console.log("Register file " + file.name, file);
246
- const res = await files.addFile(file, this.context);
247
- this.dispatchEvent(new CustomEvent(DropListenerEvents.FileDropped, { detail: file }));
248
- if (res)
249
- this.addObject(res, ctx, false);
333
+ console.debug("Load file " + file.name);
334
+ const res = await FileHelper.loadFile(file, this.context, { guid: this.guid });
335
+ if (res) {
336
+ this.dispatchEvent(new CustomEvent(DropListenerEvents.FileDropped, { detail: file }));
337
+ ctx.file = file;
338
+ const obj = this.addObject(res, ctx, false);
339
+
340
+ // handle uploading the dropped object and networking the event
341
+ if (obj && this.context.connection.isConnected && this.useNetworking) {
342
+ console.debug("Uploading dropped file to blob storage");
343
+ BlobStorage.upload(file, { abort: this._abort?.signal, })
344
+ .then(upload => {
345
+ // check if the upload was successful and if the object should still be visible
346
+ if (upload?.download_url && this._addedObjects.includes(obj)) {
347
+ // setParamWithoutReload(blobKeyName, upload.key);
348
+ this.sendDropEvent(upload.download_url, obj, res.contentMD5);
349
+ }
350
+ })
351
+ .catch(console.warn);
352
+ }
353
+
354
+ // we currently only support dropping one file
355
+ break;
356
+ }
250
357
  }
251
358
  }
252
359
 
253
360
  /** Previously added objects */
254
361
  private readonly _addedObjects = new Array<Object3D>();
362
+ private readonly _addedModels = new Array<Model>();
255
363
 
256
- private addObject(gltf: GLTF, ctx: DropContext, isRemote: boolean): Object3D | null {
257
- if (debug) console.log(`Dropped ${this.gameObject.name}`, gltf);
258
- if (!gltf?.scene) {
259
- console.warn("No object specified to add to scene", gltf);
260
- return null;
261
- }
262
-
263
- for (const prev of this._addedObjects) {
264
- if (prev.parent === this.gameObject) {
265
- destroy(prev, true, true);
364
+ /** Removes all previously added objects from the scene and removes those object references */
365
+ private removePreviouslyAddedObjects(doDestroy: boolean = true) {
366
+ if (doDestroy) {
367
+ for (const prev of this._addedObjects) {
368
+ if (prev.parent === this.gameObject) {
369
+ destroy(prev, true, true);
370
+ }
266
371
  }
267
372
  }
268
373
  this._addedObjects.length = 0;
374
+ this._addedModels.length = 0;
375
+ }
269
376
 
270
- const obj = gltf.scene;
377
+ /**
378
+ * Adds the object to the scene and fits it into the volume if {@link fitIntoVolume} is enabled.
379
+ */
380
+ private addObject(data: { model: Model, contentMD5: string }, ctx: DropContext, isRemote: boolean): Object3D | null {
271
381
 
382
+ const { model, contentMD5 } = data;
383
+
384
+ if (debug) console.log(`Dropped ${this.gameObject.name}`, model);
385
+ if (!model?.scene) {
386
+ console.warn("No object specified to add to scene", model);
387
+ return null;
388
+ }
389
+
390
+ this.removePreviouslyAddedObjects();
391
+
392
+ const obj = model.scene;
393
+
272
394
  // use attach to ignore the DropListener scale (e.g. if the parent object scale is not uniform)
273
395
  this.gameObject.attach(obj);
274
396
  obj.position.set(0, 0, 0);
275
397
  obj.quaternion.identity();
276
398
 
277
399
  this._addedObjects.push(obj);
400
+ this._addedModels.push(model);
278
401
 
279
- if (debug) obj.add(new AxesHelper(1));
280
-
281
-
282
402
  const volume = new Box3().setFromCenterAndSize(new Vector3(0, this.fitVolumeSize.y * .5, 0).add(this.gameObject.worldPosition), this.fitVolumeSize);
283
403
  if (debug) Gizmos.DrawWireBox3(volume, 0x0000ff, 5);
284
404
  if (this.fitIntoVolume) {
@@ -301,25 +421,51 @@
301
421
  }
302
422
  }
303
423
 
304
- AnimationUtils.assignAnimationsFromFile(gltf, {
424
+ AnimationUtils.assignAnimationsFromFile(model, {
305
425
  createAnimationComponent: obj => addComponent(obj, Animation)
306
426
  });
307
427
 
308
- this.dispatchEvent(new CustomEvent(DropListenerEvents.ObjectAdded, { detail: gltf }));
309
- this.onDropped?.invoke({ sender: this, gltf })
428
+ const evt = new DropListenerAddedEvent({
429
+ sender: this,
430
+ gltf: model,
431
+ model: model,
432
+ object: obj,
433
+ contentMD5: contentMD5,
434
+ dropped: ctx.file || (ctx.url ? new URL(ctx.url) : undefined),
435
+ });
436
+ this.dispatchEvent(evt);
437
+ this.onDropped?.invoke(evt.detail);
310
438
 
311
439
  // send network event
312
440
  if (!isRemote && ctx.url?.startsWith("http") && this.context.connection.isConnected && obj) {
313
- const evt: DropListenerNetworkEvent = {
441
+ this.sendDropEvent(ctx.url, obj, contentMD5);
442
+ }
443
+
444
+ return obj;
445
+ }
446
+
447
+ private async sendDropEvent(url: string, obj: Object3D, contentmd5: string) {
448
+ if (!this.useNetworking) {
449
+ if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", url);
450
+ return;
451
+ }
452
+ if (this.context.connection.isConnected) {
453
+ console.debug("Sending drop event \"" + obj.name + "\"", url);
454
+ const bounds = getBoundingBox([obj]);
455
+ const evt: DropListenerNetworkEventArguments = {
456
+ name: obj.name,
314
457
  guid: this.guid,
315
- url: ctx.url,
316
- point: obj.position.clone(),
458
+ url,
459
+ point: obj.worldPosition.clone(),
460
+ size: bounds.getSize(new Vector3()),
461
+ contentMD5: contentmd5,
317
462
  };
318
463
  this.context.connection.send("droplistener", evt);
319
464
  }
320
-
321
- return obj;
322
465
  }
466
+ private deleteDropEvent() {
467
+ this.context.connection.sendDeleteRemoteState(this.guid);
468
+ }
323
469
 
324
470
 
325
471
 
@@ -331,6 +477,7 @@
331
477
  screenPoint,
332
478
  recursive: true,
333
479
  testObject: obj => {
480
+ // Ignore hits on the already added objects, they don't count as part of the dropzone
334
481
  if (this._addedObjects.includes(obj)) return false;
335
482
  return true;
336
483
  }
@@ -357,4 +504,76 @@
357
504
  console.log("Resolved polyhaven asset url", urlStr, "→", assetUrl);
358
505
  // TODO: need to resolve textures properly
359
506
  return assetUrl;
507
+ }
508
+
509
+
510
+
511
+ namespace FileHelper {
512
+
513
+ export async function loadFile(file: File, context: Context, args: { guid: string }): Promise<{ model: Model, contentMD5: string } | null> {
514
+ const name = file.name.toLowerCase();
515
+ if (name.endsWith(".gltf") ||
516
+ name.endsWith(".glb") ||
517
+ name.endsWith(".fbx") ||
518
+ name.endsWith(".obj") ||
519
+ name.endsWith(".usdz") ||
520
+ name.endsWith(".vrm") ||
521
+ file.type === "model/gltf+json" ||
522
+ file.type === "model/gltf-binary"
523
+ ) {
524
+ return new Promise((resolve, _reject) => {
525
+ const reader = new FileReader()
526
+ reader.readAsArrayBuffer(file);
527
+ reader.onloadend = async (_ev: ProgressEvent<FileReader>) => {
528
+ const content = reader.result as ArrayBuffer;
529
+ // first load it locally
530
+ const seed = args.guid;
531
+ const prov = new InstantiateIdProvider(seed);
532
+ const model = await getLoader().parseSync(context, content, file.name, prov);
533
+ if (model) {
534
+ const hash = BlobStorage.hashMD5(content);
535
+ resolve({ model, contentMD5: hash });
536
+ }
537
+ };
538
+ });
539
+ }
540
+ else {
541
+ console.warn("Unsupported file type: " + name, file.type)
542
+ }
543
+
544
+ return null;
545
+ }
546
+
547
+ export async function loadFileFromURL(url: URL, args: { guid: string, context: Context, parent: Object3D, point?: Vec3, size?: Vec3 }): Promise<{ model: Model, contentMD5: string } | null> {
548
+ return new Promise(async (resolve, _reject) => {
549
+
550
+ const prov = new InstantiateIdProvider(args.guid);
551
+ const urlStr = url.toString();
552
+
553
+ if (debug) Gizmos.DrawWireSphere(args.point!, .1, 0xff0000, 3);
554
+ const preview = PreviewHelper.addPreview({
555
+ guid: args.guid,
556
+ parent: args.parent,
557
+ position: args?.point,
558
+ size: args?.size,
559
+ });
560
+
561
+ const model = await getLoader().loadSync(args.context, urlStr, urlStr, prov, prog => {
562
+ preview.onProgress(prog.loaded / prog.total);
563
+ }).catch(console.warn);
564
+
565
+ if (model) {
566
+ const binary = await fetch(urlStr).then(res => res.arrayBuffer());
567
+ const hash = BlobStorage.hashMD5(binary);
568
+ if (debug) setTimeout(() => PreviewHelper.removePreview(args.guid), 3000);
569
+ else PreviewHelper.removePreview(args.guid);
570
+ resolve({ model, contentMD5: hash });
571
+ }
572
+ else {
573
+ if (debug) setTimeout(() => PreviewHelper.removePreview(args.guid), 3000);
574
+ else PreviewHelper.removePreview(args.guid);
575
+ console.warn("Unsupported file type: " + url.toString());
576
+ }
577
+ });
578
+ }
360
579
  }
src/engine/engine_addressables.ts CHANGED
@@ -4,11 +4,11 @@
4
4
  import { destroy, type IInstantiateOptions, instantiate, InstantiateOptions, isDestroyed } from "./engine_gameobject.js";
5
5
  import { getLoader } from "./engine_gltf.js";
6
6
  import { processNewScripts } from "./engine_mainloop_utils.js";
7
+ import { BlobStorage } from "./engine_networking_blob.js";
7
8
  import { registerPrefabProvider, syncInstantiate,SyncInstantiateOptions } from "./engine_networking_instantiate.js";
8
9
  import { SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
9
10
  import { Context } from "./engine_setup.js";
10
11
  import type { IComponent, IGameObject, SourceIdentifier } from "./engine_types.js";
11
- import { download } from "./engine_web_api.js";
12
12
 
13
13
  const debug = getParam("debugaddressables");
14
14
 
@@ -240,7 +240,7 @@
240
240
  if (this._rawBinary !== undefined) return this._rawBinary;
241
241
  this._isLoadingRawBinary = true;
242
242
  if (debug) console.log("Preload", this._hashedUri);
243
- const res = await download(this._hashedUri, p => {
243
+ const res = await BlobStorage.download(this._hashedUri, p => {
244
244
  this.raiseProgressEvent(p);
245
245
  });
246
246
  this._rawBinary = res?.buffer ?? null;
src/engine/engine_animation.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AnimationAction, AnimationClip, AnimationMixer, Object3D, PropertyBinding } from "three";
2
2
 
3
3
  import type { Context } from "./engine_context.js";
4
- import { GLTF, IAnimationComponent } from "./engine_types.js";
4
+ import { GLTF, IAnimationComponent, Model } from "./engine_types.js";
5
5
 
6
6
  /**
7
7
  * Registry for animation related data. Use {@link registerAnimationMixer} to register an animation mixer instance.
@@ -88,7 +88,7 @@
88
88
  * This method will look for objects in the scene that have animations and assign them to the correct objects.
89
89
  * @param file The GLTF file to assign the animations from
90
90
  */
91
- static assignAnimationsFromFile(file: Pick<GLTF, "animations" | "scene">, opts?: { createAnimationComponent(obj: Object3D, animation: AnimationClip): IAnimationComponent }) {
91
+ static assignAnimationsFromFile(file: Pick<Model, "animations" | "scene">, opts?: { createAnimationComponent(obj: Object3D, animation: AnimationClip): IAnimationComponent }) {
92
92
  if (!file || !file.animations) {
93
93
  console.debug("No animations found in file");
94
94
  return;
src/engine/engine_components_internal.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  import { type IComponent } from "./engine_types.js";
2
2
  import { getParam } from "./engine_utils.js";
3
3
 
4
- export enum ComponentEvents {
5
- Added = "component-added",
6
- Removing = "removing-component"
7
- }
8
4
 
5
+ type ComponentLifecycleEvent = "component-added" | "removing-component";
6
+
9
7
  const debug = getParam("debugcomponentevents");
10
8
 
11
9
  export class ComponentLifecycleEvents {
12
10
 
13
11
  private static eventListeners = new Map<string, ((data: IComponent) => void)[]>();
14
12
 
15
- static addComponentLifecylceEventListener(evt: string, cb: (data: IComponent) => void) {
13
+ static addComponentLifecylceEventListener(evt: ComponentLifecycleEvent | (string & {}), cb: (data: IComponent) => void) {
16
14
  if (this.eventListeners.has(evt)) {
17
15
  this.eventListeners.set(evt, []);
18
16
  }
@@ -23,7 +21,7 @@
23
21
  if(debug) console.log("Added event listener for " + evt, this.eventListeners)
24
22
  }
25
23
 
26
- static removeComponentLifecylceEventListener(evt: string, cb: (data: IComponent) => void) {
24
+ static removeComponentLifecylceEventListener(evt: ComponentLifecycleEvent | (string & {}), cb: (data: IComponent) => void) {
27
25
  const listeners = this.eventListeners.get(evt);
28
26
  if (!listeners) return;
29
27
  const index = listeners.indexOf(cb);
@@ -32,7 +30,7 @@
32
30
 
33
31
  }
34
32
 
35
- static dispatchComponentLifecycleEvent(evt: string, data: IComponent) {
33
+ static dispatchComponentLifecycleEvent(evt: ComponentLifecycleEvent, data: IComponent) {
36
34
  const listeners = this.eventListeners.get(evt);
37
35
  if(debug) console.log("Dispatching event " + evt, listeners)
38
36
  if (!listeners) return;
src/engine/engine_components.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Object3D, Scene } from "three";
2
2
 
3
- import { ComponentEvents, ComponentLifecycleEvents } from "./engine_components_internal.js";
3
+ import { ComponentLifecycleEvents } from "./engine_components_internal.js";
4
4
  import { activeInHierarchyFieldName } from "./engine_constants.js";
5
5
  import { removeScriptFromContext, updateActiveInHierarchyWithoutEventCall } from "./engine_mainloop_utils.js";
6
6
  import { InstantiateIdProvider } from "./engine_networking_instantiate.js";
@@ -30,7 +30,7 @@
30
30
  const index = go.userData.components.indexOf(componentInstance);
31
31
  if (index < 0) return componentInstance;
32
32
 
33
- ComponentLifecycleEvents.dispatchComponentLifecycleEvent(ComponentEvents.Removing, componentInstance);
33
+ ComponentLifecycleEvents.dispatchComponentLifecycleEvent("removing-component", componentInstance);
34
34
 
35
35
  //@ts-ignore
36
36
  componentInstance.gameObject = null;
@@ -67,7 +67,7 @@
67
67
  if (componentInstance.activeAndEnabled)
68
68
  componentInstance.__internalAwake();
69
69
  }
70
- ComponentLifecycleEvents.dispatchComponentLifecycleEvent(ComponentEvents.Added, componentInstance);
70
+ ComponentLifecycleEvents.dispatchComponentLifecycleEvent("component-added", componentInstance);
71
71
  }
72
72
  catch (err) {
73
73
  console.error(err);
src/engine/engine_constants.ts CHANGED
@@ -6,18 +6,21 @@
6
6
  // 2) Vite where global defines are made, vite defines are also automatically set to globalThis
7
7
  // 3) Webpack where global defines are not made BUT declare const variables are replaces with the actual value (via the webpack DefinePlugin)
8
8
 
9
- tryEval(`if(!globalThis["NEEDLE_ENGINE_VERSION"]) globalThis["NEEDLE_ENGINE_VERSION"] = "0.0.0";`)
10
- tryEval(`if(!globalThis["NEEDLE_ENGINE_GENERATOR"]) globalThis["NEEDLE_ENGINE_GENERATOR"] = "unknown";`)
11
- tryEval(`if(!globalThis["NEEDLE_PROJECT_BUILD_TIME"]) globalThis["NEEDLE_PROJECT_BUILD_TIME"] = "unknown";`)
9
+ tryEval(`if(!globalThis["NEEDLE_ENGINE_VERSION"]) globalThis["NEEDLE_ENGINE_VERSION"] = "0.0.0";`);
10
+ tryEval(`if(!globalThis["NEEDLE_ENGINE_GENERATOR"]) globalThis["NEEDLE_ENGINE_GENERATOR"] = "unknown";`);
11
+ tryEval(`if(!globalThis["NEEDLE_PROJECT_BUILD_TIME"]) globalThis["NEEDLE_PROJECT_BUILD_TIME"] = "unknown";`);
12
+ tryEval(`if(!globalThis["NEEDLE_PUBLIC_KEY"]) globalThis["NEEDLE_PUBLIC_KEY"] = "unknown";`);
12
13
 
13
14
  declare const NEEDLE_ENGINE_VERSION: string
14
15
  declare const NEEDLE_ENGINE_GENERATOR: string;
15
16
  declare const NEEDLE_PROJECT_BUILD_TIME: string;
17
+ declare const NEEDLE_PUBLIC_KEY: string;
16
18
 
17
19
  // Make sure to wrap the new global this define in underscores to prevent the bundler from replacing it with the actual value
18
- tryEval(`globalThis["__NEEDLE_ENGINE_VERSION__"] = "` + NEEDLE_ENGINE_VERSION + `";`)
19
- tryEval(`globalThis["__NEEDLE_ENGINE_GENERATOR__"] = "` + NEEDLE_ENGINE_GENERATOR + `";`)
20
- tryEval(`globalThis["__NEEDLE_PROJECT_BUILD_TIME__"] = "` + NEEDLE_PROJECT_BUILD_TIME + `";`)
20
+ tryEval(`globalThis["__NEEDLE_ENGINE_VERSION__"] = "` + NEEDLE_ENGINE_VERSION + `";`);
21
+ tryEval(`globalThis["__NEEDLE_ENGINE_GENERATOR__"] = "` + NEEDLE_ENGINE_GENERATOR + `";`);
22
+ tryEval(`globalThis["__NEEDLE_PROJECT_BUILD_TIME__"] = "` + NEEDLE_PROJECT_BUILD_TIME + `";`);
23
+ tryEval(`globalThis["__NEEDLE_PUBLIC_KEY__"] = "` + NEEDLE_PUBLIC_KEY + `";`);
21
24
 
22
25
  /** The version of the Needle engine */
23
26
  export const VERSION = NEEDLE_ENGINE_VERSION;
@@ -27,6 +30,8 @@
27
30
  export const BUILD_TIME = NEEDLE_PROJECT_BUILD_TIME;
28
31
  if (debug) console.log(`Engine version: ${VERSION} (generator: ${GENERATOR})\nProject built at ${BUILD_TIME}`);
29
32
 
33
+ export const PUBLIC_KEY = NEEDLE_PUBLIC_KEY;
34
+
30
35
  export const activeInHierarchyFieldName = "needle_isActiveInHierarchy";
31
36
  export const builtinComponentKeyName = "builtin_components";
32
37
  // It's easier to use a string than a symbol here because the symbol might not be the same when imported in other packages
src/engine/engine_context_registry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type IComponent, type IContext, type LoadedGLTF } from "./engine_types.js";
1
+ import { type IComponent, type IContext, type LoadedModel } from "./engine_types.js";
2
2
 
3
3
  /** The various events that can be dispatched by a Needle Engine {@link IContext} instance
4
4
  */
@@ -26,7 +26,7 @@
26
26
  export type ContextEventArgs = {
27
27
  event: ContextEvent;
28
28
  context: IContext;
29
- files?: LoadedGLTF[]
29
+ files?: LoadedModel[]
30
30
  }
31
31
 
32
32
  export type ContextCallback = (evt: ContextEventArgs) => void | Promise<any> | IComponent;
src/engine/engine_context.ts CHANGED
@@ -30,8 +30,8 @@
30
30
  import { logHierarchy } from './engine_three_utils.js';
31
31
  import { Time } from './engine_time.js';
32
32
  import { patchTonemapping } from './engine_tonemapping.js';
33
- import type { CoroutineData, GLTF, ICamera, IComponent, IContext, ILight, LoadedGLTF, Vec2 } from "./engine_types.js";
34
- import { deepClone,delay, DeviceUtilities, getParam } from './engine_utils.js';
33
+ import type { CoroutineData, ICamera, IComponent, IContext, ILight, LoadedModel, Model, Vec2 } from "./engine_types.js";
34
+ import { deepClone, delay, DeviceUtilities, getParam } from './engine_utils.js';
35
35
  import type { INeedleXRSessionEventReceiver, NeedleXRSession } from './engine_xr.js';
36
36
  import { NeedleMenu } from './webcomponents/needle menu/needle-menu.js';
37
37
 
@@ -66,7 +66,7 @@
66
66
  /** called on update for each loaded glTF file */
67
67
  onLoadingProgress?: (args: LoadingProgressArgs) => void;
68
68
  /** Called after a gLTF file has finished loading */
69
- onLoadingFinished?: (index: number, file: string, glTF: GLTF | null) => void;
69
+ onLoadingFinished?: (index: number, file: string, glTF: Model | null) => void;
70
70
  }
71
71
 
72
72
  export class ContextArgs {
@@ -855,7 +855,7 @@
855
855
 
856
856
  // load and create scene
857
857
  let prepare_succeeded = true;
858
- let loadedFiles!: Array<LoadedGLTF | null>;
858
+ let loadedFiles!: Array<LoadedModel | null>;
859
859
  try {
860
860
  Context.Current = this;
861
861
  if (opts) {
@@ -1011,8 +1011,8 @@
1011
1011
  return true;
1012
1012
  }
1013
1013
 
1014
- private async internalLoadInitialContent(createId: number, args: ContextCreateArgs): Promise<Array<LoadedGLTF>> {
1015
- const results = new Array<LoadedGLTF>();
1014
+ private async internalLoadInitialContent(createId: number, args: ContextCreateArgs): Promise<Array<LoadedModel>> {
1015
+ const results = new Array<LoadedModel>();
1016
1016
  // early out if we dont have any files to load
1017
1017
  if (args.files.length === 0) return results;
1018
1018
 
@@ -1092,7 +1092,7 @@
1092
1092
  // It's ok to do this at this point because we know the context has been cleared because the whole `src` attribute has been set
1093
1093
  if (!anyModelFound) {
1094
1094
  for (const res of results) {
1095
- if (res && res.file) {
1095
+ if (res && res.file && "parser" in res.file) {
1096
1096
  let y = 0;
1097
1097
  for (let i = 0; i < res.file.parser.json.materials.length; i++) {
1098
1098
  const mat = await res.file.parser.getDependency("material", i);
src/engine/engine_element.ts CHANGED
@@ -2,21 +2,21 @@
2
2
 
3
3
  import { registerLoader } from "../engine/engine_gltf.js";
4
4
  import { isDevEnvironment, showBalloonWarning } from "./debug/index.js";
5
- import { VERSION } from "./engine_constants.js";
5
+ import { PUBLIC_KEY, VERSION } from "./engine_constants.js";
6
6
  import { TonemappingAttributeOptions } from "./engine_element_attributes.js";
7
7
  import { calculateProgress01, EngineLoadingView, type ILoadingViewHandler } from "./engine_element_loading.js";
8
8
  import { arContainerClassName, AROverlayHandler } from "./engine_element_overlay.js";
9
9
  import { hasCommercialLicense } from "./engine_license.js";
10
10
  import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "./engine_loaders.js";
11
- import { NeedleGltfLoader } from "./engine_scenetools.js";
12
- import { Context, ContextCreateArgs } from "./engine_setup.js";
13
- import { type INeedleEngineComponent, type LoadedGLTF } from "./engine_types.js";
11
+ import { NeedleLoader } from "./engine_scenetools.js";
12
+ import { Context, ContextCreateArgs, LoadingProgressArgs } from "./engine_setup.js";
13
+ import { type INeedleEngineComponent, type LoadedModel } from "./engine_types.js";
14
14
  import { getParam } from "./engine_utils.js";
15
15
  import { ensureFonts } from "./webcomponents/fonts.js";
16
16
 
17
17
  //
18
18
  // registering loader here too to make sure it's imported when using engine via vanilla js
19
- registerLoader(NeedleGltfLoader);
19
+ registerLoader(NeedleLoader);
20
20
 
21
21
  const debug = getParam("debugwebcomponent");
22
22
 
@@ -28,6 +28,9 @@
28
28
  const desktopSessionActiveClassName = "desktop-session-active";
29
29
 
30
30
  const observedAttributes = [
31
+ "public-key",
32
+ "version",
33
+
31
34
  "hash",
32
35
  "src",
33
36
  "camera-controls",
@@ -224,6 +227,9 @@
224
227
  console.log("<needle-engine> connected");
225
228
  }
226
229
 
230
+ this.setPublicKey();
231
+ this.setVersion();
232
+
227
233
  this.addEventListener("xr-session-started", this.onXRSessionStarted);
228
234
  this.onSetupDesktop();
229
235
 
@@ -247,10 +253,6 @@
247
253
  if (loadId !== this._loadId) return;
248
254
  this.onLoad();
249
255
  }, 1);
250
-
251
- if (VERSION?.length) {
252
- this.setAttribute("version", VERSION);
253
- }
254
256
  }
255
257
 
256
258
  /**
@@ -279,11 +281,11 @@
279
281
  /**
280
282
  * @internal
281
283
  */
282
- attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
283
- if (debug) console.log("attributeChangedCallback", name, _oldValue, newValue);
284
+ attributeChangedCallback(name: string, oldValue: string, newValue: string) {
285
+ if (debug) console.log("attributeChangedCallback", name, oldValue, newValue);
284
286
  switch (name) {
285
287
  case "src":
286
- if (debug) console.warn("<needle-engine src>\nchanged from \"", _oldValue, "\" to \"", newValue, "\"")
288
+ if (debug) console.warn("<needle-engine src>\nchanged from \"", oldValue, "\" to \"", newValue, "\"")
287
289
  this.onLoad();
288
290
  // this._watcher?.onSourceChanged(newValue);
289
291
  break;
@@ -334,6 +336,16 @@
334
336
  this.applyAttributes();
335
337
  break;
336
338
  }
339
+ case "public-key": {
340
+ if (newValue != PUBLIC_KEY)
341
+ this.setPublicKey();
342
+ break;
343
+ }
344
+ case "version": {
345
+ if (newValue != VERSION)
346
+ this.setVersion();
347
+ break;
348
+ }
337
349
  }
338
350
  }
339
351
 
@@ -426,7 +438,7 @@
426
438
  if (debug) console.warn("--------------\nNeedle Engine: Begin loading " + loadId + "\n", filesToLoad);
427
439
  this.onBeforeBeginLoading();
428
440
 
429
- const loadedFiles: Array<LoadedGLTF> = [];
441
+ const loadedFiles: Array<LoadedModel> = [];
430
442
  const progressEventDetail = {
431
443
  context: this._context,
432
444
  name: "",
@@ -476,10 +488,14 @@
476
488
  this.applyAttributes();
477
489
 
478
490
  if (debug) console.warn("--------------\nNeedle Engine: finished loading " + loadId + "\n", filesToLoad, `Aborted? ${controller.signal.aborted}`);
479
- if (this._loadId !== loadId || controller.signal.aborted) {
491
+ if (controller.signal.aborted) {
480
492
  console.log("Loading finished but aborted...")
481
493
  return;
482
494
  }
495
+ if (this._loadId !== loadId) {
496
+ console.log("Load id changed during loading process")
497
+ return;
498
+ }
483
499
 
484
500
  this._loadingProgress01 = 1;
485
501
  if (useDefaultLoading && success) {
@@ -644,6 +660,16 @@
644
660
  }
645
661
  }
646
662
 
663
+ private setPublicKey() {
664
+ if (PUBLIC_KEY && PUBLIC_KEY.length > 0)
665
+ this.setAttribute("public-key", PUBLIC_KEY);
666
+ }
667
+ private setVersion() {
668
+ if (VERSION && VERSION.length > 0) {
669
+ this.setAttribute("version", VERSION);
670
+ }
671
+ }
672
+
647
673
  /**
648
674
  * @internal
649
675
  */
src/engine/engine_gameobject.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Bone, Object3D, Quaternion, SkinnedMesh, Vector3 } from "three";
2
2
 
3
3
  import { __internalNotifyObjectDestroyed as __internalRemoveReferences, disposeObjectResources } from "./engine_assetdatabase.js";
4
- import { ComponentEvents, ComponentLifecycleEvents } from "./engine_components_internal.js";
4
+ import { ComponentLifecycleEvents } from "./engine_components_internal.js";
5
5
  import { activeInHierarchyFieldName } from "./engine_constants.js";
6
6
  import { editorGuidKeyName } from "./engine_constants.js";
7
7
  import { $isUsingInstancing, InstancingUtil } from "./engine_instancing.js";
@@ -480,7 +480,7 @@
480
480
  // copy.transform = clone;
481
481
  componentsList.push(copy);
482
482
  objectsMap[comp.guid] = { original: comp, clone: copy };
483
- ComponentLifecycleEvents.dispatchComponentLifecycleEvent(ComponentEvents.Added, copy);
483
+ ComponentLifecycleEvents.dispatchComponentLifecycleEvent("component-added", copy);
484
484
  }
485
485
  }
486
486
 
src/engine/engine_gltf.ts CHANGED
@@ -1,16 +1,14 @@
1
- import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
2
-
3
1
  import { SerializationContext } from "./engine_serialization_core.js";
4
2
  import { Context } from "./engine_setup.js";
5
- import type { ConstructorConcrete, SourceIdentifier, UIDProvider } from "./engine_types.js";
3
+ import type { ConstructorConcrete, Model, SourceIdentifier, UIDProvider } from "./engine_types.js";
6
4
  import { NEEDLE_components } from "./extensions/NEEDLE_components.js";
7
5
 
8
6
 
9
7
  export interface INeedleGltfLoader {
10
8
  createBuiltinComponents(context: Context, gltfId: SourceIdentifier, gltf, seed: number | null | UIDProvider, extension?: NEEDLE_components): Promise<void>
11
9
  writeBuiltinComponentData(comp: object, context: SerializationContext);
12
- parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<GLTF | undefined>;
13
- loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (prog: ProgressEvent) => void): Promise<GLTF | undefined>
10
+ parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined>;
11
+ loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (prog: ProgressEvent) => void): Promise<Model | undefined>
14
12
  }
15
13
 
16
14
  let gltfLoader: INeedleGltfLoader;
src/engine/engine_input.ts CHANGED
@@ -379,11 +379,18 @@
379
379
  get mousePositionRC(): Vector2 { return this._pointerPositionsRC[0]; }
380
380
  get mouseDown(): boolean { return this._pointerDown[0]; }
381
381
  get mouseUp(): boolean { return this._pointerUp[0]; }
382
+ /** Is the primary pointer clicked (usually the left button). This is equivalent to `input.click` */
382
383
  get mouseClick(): boolean { return this._pointerClick[0]; }
384
+ /** Was a double click detected for the primary pointer? This is equivalent to `input.doubleClick` */
383
385
  get mouseDoubleClick(): boolean { return this._pointerDoubleClick[0]; }
384
386
  get mousePressed(): boolean { return this._pointerPressed[0]; }
385
387
  get mouseWheelChanged(): boolean { return this.getMouseWheelChanged(0); }
386
388
 
389
+ /** Is the primary pointer double clicked (usually the left button). This is equivalent to `input.mouseDoubleClick` */
390
+ get click() : boolean { return this._pointerClick[0]; }
391
+ /** Was a double click detected for the primary pointer? */
392
+ get doubleClick() : boolean { return this._pointerDoubleClick[0]; }
393
+
387
394
  private _specialCursorTrigger: number = 0;
388
395
 
389
396
  setCursorPointer() {
src/engine/engine_license.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BUILD_TIME, GENERATOR, VERSION } from "./engine_constants.js";
1
+ import { BUILD_TIME, GENERATOR, PUBLIC_KEY, VERSION } from "./engine_constants.js";
2
2
  import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
3
3
  import { Context } from "./engine_setup.js";
4
4
  import type { IContext } from "./engine_types.js";
@@ -9,7 +9,8 @@
9
9
  // This is modified by a bundler (e.g. vite)
10
10
  // Do not edit manually
11
11
  let NEEDLE_ENGINE_LICENSE_TYPE: string = "basic";
12
- if (debug) console.log("License Type: " + NEEDLE_ENGINE_LICENSE_TYPE)
12
+ if (debug)
13
+ console.log("License Type: " + NEEDLE_ENGINE_LICENSE_TYPE)
13
14
 
14
15
  /** @internal */
15
16
  export function hasProLicense() {
@@ -314,7 +315,8 @@
314
315
  // hash: window.location.hash,
315
316
  version: VERSION,
316
317
  generator: GENERATOR,
317
- build_time: BUILD_TIME
318
+ build_time: BUILD_TIME,
319
+ public_key: PUBLIC_KEY,
318
320
  };
319
321
  const res = navigator.sendBeacon?.(finalUrl, JSON.stringify(beaconData));
320
322
  if (debug) console.log("Send beacon result", res);
src/engine/engine_loaders.ts CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { createLoaders, setDracoDecoderLocation, setKTX2TranscoderLocation } from '@needle-tools/gltf-progressive';
2
+ import { addDracoAndKTX2Loaders as addDracoAndKTX2LoadersGLTFProgressive, configureLoader, createLoaders, setDracoDecoderLocation, setKTX2TranscoderLocation } from '@needle-tools/gltf-progressive';
3
3
  import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
4
4
  import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
5
5
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
@@ -10,8 +10,6 @@
10
10
 
11
11
  const debug = getParam("debugdecoders");
12
12
 
13
- let meshoptDecoder: typeof MeshoptDecoder;
14
-
15
13
  let loaders: null | { dracoLoader: DRACOLoader, ktx2Loader: KTX2Loader, meshoptDecoder: typeof MeshoptDecoder } = null;
16
14
 
17
15
  function ensureLoaders() {
@@ -49,8 +47,10 @@
49
47
  }
50
48
 
51
49
  export function setMeshoptDecoder(_meshoptDecoder: any) {
52
- if (_meshoptDecoder !== undefined)
53
- meshoptDecoder = _meshoptDecoder;
50
+ if (_meshoptDecoder !== undefined) {
51
+ const loaders = ensureLoaders();
52
+ loaders.meshoptDecoder = _meshoptDecoder;
53
+ }
54
54
  }
55
55
 
56
56
  /**
@@ -60,26 +60,27 @@
60
60
  * @returns The GLTFLoader instance with the loaders added.
61
61
  */
62
62
  export function addDracoAndKTX2Loaders(loader: GLTFLoader, context: Pick<Context, "renderer">) {
63
-
63
+
64
64
  const loaders = ensureLoaders();
65
65
 
66
- if (!meshoptDecoder) {
67
- meshoptDecoder = loaders.meshoptDecoder;
68
- if (debug) console.log("Using the default meshopt decoder");
69
- }
70
-
71
-
72
66
  if (context.renderer) {
73
67
  loaders.ktx2Loader.detectSupport(context.renderer);
74
68
  }
75
69
  else
76
70
  console.warn("No renderer provided to detect ktx2 support - loading KTX2 textures will probably fail");
77
71
 
72
+ addDracoAndKTX2LoadersGLTFProgressive(loader);
73
+
78
74
  if (!loader.dracoLoader)
79
75
  loader.setDRACOLoader(loaders.dracoLoader);
80
76
  if (!(loader as any).ktx2Loader)
81
77
  loader.setKTX2Loader(loaders.ktx2Loader);
82
78
  if (!(loader as any).meshoptDecoder)
83
- loader.setMeshoptDecoder(meshoptDecoder);
79
+ loader.setMeshoptDecoder(loaders.meshoptDecoder);
80
+
81
+ configureLoader(loader, {
82
+ progressive: true,
83
+ });
84
+
84
85
  return loader;
85
86
  }
src/engine/engine_networking_auto.ts CHANGED
@@ -39,7 +39,7 @@
39
39
  // private getters: { [key: string]: Function } = {};
40
40
  private hasChanges: boolean = false;
41
41
  private changedProperties: { [key: string]: any } = {};
42
- private data = {};
42
+ // private data = {};
43
43
 
44
44
 
45
45
  get networkingKey(): string {
@@ -74,7 +74,7 @@
74
74
 
75
75
  notifyChanged(propertyName: string, value: any) {
76
76
  if (this._isReceiving) return;
77
- if(debug) console.log("Property changed: " + propertyName, value);
77
+ if (debug) console.log("Property changed: " + propertyName, value);
78
78
  this.hasChanges = true;
79
79
  this.changedProperties[propertyName] = value;
80
80
  }
@@ -88,37 +88,28 @@
88
88
  delete this.changedProperties[key];
89
89
  return;
90
90
  }
91
- for (const key in this.data) {
92
- delete this.data[key];
93
- }
94
- this.data["guid"] = this.comp.guid;
95
91
  for (const name in this.changedProperties) {
92
+ const guid = this.comp.guid + "/" + name;
96
93
  const value = this.changedProperties[name];
97
- // console.log(value);
98
- delete this.changedProperties[name];
99
- this.data[name] = value;
94
+ if (debug) console.log("SEND", this.comp.name, this.networkingKey);
95
+ net.send(this.networkingKey, { guid, data: value }, SendQueue.Queued);
100
96
  }
101
- // console.log("SEND", this.comp.name, this.data, this.networkingKey);
102
- net.send(this.networkingKey, this.data, SendQueue.Queued);
103
97
  }
104
98
 
105
- private onHandleReceiving = (val) => {
106
- if (debug)
99
+ private onHandleReceiving = (val: { guid: string, data: any }) => {
100
+ if (debug)
107
101
  console.log("RECEIVE", this.comp.name, this.comp.guid, val);
108
102
  if (!this._isInit) return;
109
103
  if (!this.comp) return;
110
- const guid = val.guid;
111
- if (guid && guid !== this.comp.guid) return;
104
+ // check if this change belongs to this component
105
+ if (val.guid.startsWith(this.comp.guid) === false) {
106
+ return;
107
+ }
108
+ const [_guid, key] = val.guid.split("/");
112
109
  if (debug) console.log("RECEIVED", this.comp.name, this.comp.guid, val);
113
110
  try {
114
111
  this._isReceiving = true;
115
- for (const key in val) {
116
- if (key === "guid") continue;
117
- // TODO: maybe use serializable here?!
118
- const value = val[key];
119
- this.comp[key] = value;
120
- if(debug) console.log("SET", key, value);
121
- }
112
+ this.comp[key] = val.data;
122
113
  }
123
114
  catch (err) {
124
115
  console.error(err);
@@ -210,8 +201,9 @@
210
201
  * (for example a networked color is sent as a number and may be converted to a color in the receiver again)
211
202
  * Parameters: (newValue, previousValue)
212
203
  */
213
- export const syncField = function (onFieldChanged?: string | FieldChangedCallbackFn) {
204
+ export const syncField = function (onFieldChanged: string | FieldChangedCallbackFn | undefined | null = null) {
214
205
 
206
+
215
207
  return function (target: any, _propertyKey: string | { name: string }) {
216
208
  let propertyKey = "";
217
209
  if (typeof _propertyKey === "string") propertyKey = _propertyKey;
@@ -226,6 +218,13 @@
226
218
  fn = onFieldChanged;
227
219
  }
228
220
 
221
+ if (fn == undefined) {
222
+ if (isDevEnvironment() || debug) {
223
+ if (onFieldChanged != undefined)
224
+ console.warn("syncField: no callback function found for property \"" + propertyKey + "\"", "\"" + onFieldChanged + "\"");
225
+ }
226
+ }
227
+
229
228
  const t = target;
230
229
  const internalAwake = t.__internalAwake;
231
230
  if (typeof internalAwake !== "function") {
@@ -254,16 +253,17 @@
254
253
  this[backingFieldName] = value;
255
254
  // Prevent recursive calls when object is assigned in callback
256
255
  if (invokingCallback) {
257
- if (isDevEnvironment())
256
+ if (isDevEnvironment() || debug)
258
257
  console.warn("Recursive call detected", propertyKey);
259
258
  return;
260
259
  }
261
260
  invokingCallback = true;
262
261
  try {
263
262
  const valueChanged = testValueChanged(value, oldValue);
264
- if (debug) console.log("SyncField assignment", propertyKey, "changed?", valueChanged, value);
263
+ if (debug) console.log("SyncField assignment", propertyKey, "changed?", valueChanged, value, fn);
265
264
  if (valueChanged) {
266
- if (fn?.call(this, value, oldValue) !== false) {
265
+ const res = fn?.call(this, value, oldValue);
266
+ if (res !== false) {
267
267
  getSyncer(this)?.notifyChanged(propertyKey, value);
268
268
  }
269
269
  }
src/engine/engine_networking_files_default_components.ts CHANGED
@@ -2,13 +2,15 @@
2
2
  // import { DragControls } from "../engine-components/DragControls.js"
3
3
  // import { ObjectRaycaster } from "../engine-components/ui/Raycaster.js";
4
4
  import { Object3D } from "three";
5
- import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
6
5
 
7
- import type { UIDProvider } from "./engine_types.js";
6
+ import type { Model, UIDProvider } from "./engine_types.js";
8
7
  // import { Animation } from "../engine-components/Animation.js";
9
8
 
10
9
 
11
- export function onDynamicObjectAdded(_obj: Object3D, _idProv: UIDProvider, _gltf?: GLTF) {
10
+ /**
11
+ * @deprecated
12
+ */
13
+ export function onDynamicObjectAdded(_obj: Object3D, _idProv: UIDProvider, _gltf?: Model) {
12
14
 
13
15
  // console.warn("Adding components on object has been temporarily disabled");
14
16
 
src/engine/engine_networking_files.ts CHANGED
@@ -1,16 +1,15 @@
1
- import { BoxGeometry, BoxHelper, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
2
- import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
1
+ import { BoxGeometry, BoxHelper, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Vector3, Vector3Like } from "three";
3
2
 
4
3
  import { getLoader } from "../engine/engine_gltf.js";
5
4
  import { NetworkConnection } from "../engine/engine_networking.js";
6
5
  import { generateSeed, InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
7
6
  import { Context } from "../engine/engine_setup.js";
8
- import * as web from "../engine/engine_web_api.js";
9
7
  import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
10
8
  import { findByGuid } from "./engine_gameobject.js";
9
+ import { BlobStorage } from "./engine_networking_blob.js";
11
10
  import * as def from "./engine_networking_files_default_components.js"
12
11
  import type { IModel } from "./engine_networking_types.js";
13
- import type { IGameObject } from "./engine_types.js";
12
+ import type { IGameObject, Vec3 } from "./engine_types.js";
14
13
 
15
14
  export enum File_Event {
16
15
  File_Spawned = "file-spawned",
@@ -22,187 +21,195 @@
22
21
  file_hash: string;
23
22
  file_size: number;
24
23
  position: Vector3 | null;
24
+ scale: Vector3 | null;
25
25
  seed: number;
26
26
  sender: string;
27
- serverUrl: string;
27
+ /** the url to download the file */
28
+ downloadUrl: string;
28
29
  parentGuid?: string;
29
30
 
30
31
  boundsSize?: Vector3;
31
32
 
32
- constructor(connectionId: string, seed: number, guid: string, name: string, hash: string, size: number, position: Vector3 | null, serverUrl: string) {
33
+ constructor(connectionId: string, seed: number, guid: string, name: string, hash: string, size: number, position: Vector3, scale: Vector3, downloadUrl: string) {
33
34
  this.seed = seed;
34
35
  this.guid = guid;
35
36
  this.file_name = name;
36
37
  this.file_hash = hash;
37
38
  this.file_size = size;
38
39
  this.position = position;
40
+ this.scale = scale;
39
41
  this.sender = connectionId;
40
- this.serverUrl = serverUrl;
42
+ this.downloadUrl = downloadUrl;
41
43
  }
42
44
  }
43
45
 
44
46
 
45
- export async function addFile(file: File, context: Context, backendUrl?: string): Promise<GLTF | null> {
46
47
 
47
- const name = file.name;
48
- if (name.endsWith(".gltf") || name.endsWith(".glb") || file.type === "model/gltf+json" || file.type === "model/gltf-binary") {
49
- return new Promise((resolve, _reject) => {
50
- const reader = new FileReader()
51
- reader.readAsArrayBuffer(file);
52
- reader.onloadend = async (_ev: ProgressEvent<FileReader>) => {
53
- const content = reader.result as ArrayBuffer;
54
- // first load it locally
55
- const seed = generateSeed();
56
- const prov = new InstantiateIdProvider(seed);
57
- const gltf: GLTF = await getLoader().parseSync(context, content, file.name, prov) as GLTF;
58
- if (gltf && gltf.scene) {
59
- const obj = gltf.scene as unknown as IGameObject;
60
- // if we dont have a guid yet (because components guids are actually created in a callback a bit later)
61
- // we just use the same seed and generate a guid for the root only
62
- // this should be the exact same guid the instantiate call will produce
63
- if (!obj.guid) {
64
- const prov = new InstantiateIdProvider(seed);
65
- obj.guid = prov.generateUUID();
66
- }
67
- if (backendUrl)
68
- handleUpload(context.connection, file, seed, obj, backendUrl);
69
- def.onDynamicObjectAdded(obj, prov, gltf);
70
- resolve(gltf);
71
- }
72
- };
73
- });
74
- }
75
- else {
76
- console.warn("Unsupported file type: " + name, file)
77
- }
48
+ // ContextRegistry.registerCallback(ContextEvent.ContextCreated, evt => {
49
+ // beginListenFileSpawn(evt.context as Context);
50
+ // })
78
51
 
79
- return null;
80
- }
52
+ // function beginListenFileSpawn(context: Context) {
53
+ // context.connection.beginListen(File_Event.File_Spawned, async (evt: FileSpawnModel) => {
54
+ // if (evt.sender !== context.connection.connectionId) {
55
+ // console.log("received file event", evt);
56
+ // const prev = addPreview(evt, context);
57
+ // let bin: ArrayBuffer | null = null;
58
+ // try {
59
+ // const arr = await BlobStorage.download(evt.downloadUrl, prog => {
60
+ // prev.onProgress(prog.loaded / prog.total);
61
+ // });
62
+ // if (arr)
63
+ // bin = arr.buffer;
64
+ // }
65
+ // finally {
66
+ // removePreview(evt, context);
67
+ // }
68
+ // if (bin) {
69
+ // const prov = new InstantiateIdProvider(evt.seed);
70
+ // const gltf = await getLoader().parseSync(context, bin, evt.file_name, prov);
71
+ // if (gltf && gltf.scene) {
72
+ // const obj = gltf.scene;
73
+ // def.onDynamicObjectAdded(obj, prov, gltf);
74
+ // // if we process new scripts immediately references that rely on guids are not properly resolved
75
+ // // for example duplicatable "object" reference will not be found anymore because guid has changed
76
+ // // processNewScripts(context);
81
77
 
82
- export async function addFileFromUrl(url: URL, context: Context): Promise<GLTF | null> {
78
+ // // add object to proper parent
79
+ // if (evt.parentGuid) {
80
+ // const parent = findByGuid(evt.parentGuid, context.scene) as Object3D;
81
+ // if (parent && "add" in parent) parent.add(obj);
82
+ // }
83
+ // if (!obj.parent)
84
+ // context.scene.add(obj);
83
85
 
84
- return new Promise(async (resolve, _reject) => {
85
- const seed = generateSeed();
86
- const prov = new InstantiateIdProvider(seed);
87
- const urlStr = url.toString();
88
- const gltf: GLTF = await getLoader().loadSync(context, urlStr, urlStr, prov) as GLTF;
89
- if (gltf && gltf.scene) {
90
- const obj = gltf.scene as unknown as IGameObject;
91
- // handleUpload(context.connection, file, seed, obj); // TODO needs to upload the URL only and store that
92
- def.onDynamicObjectAdded(obj, prov, gltf);
93
- resolve(gltf);
94
- }
95
- else {
96
- console.warn("Unsupported file type: " + url.toString());
97
- }
98
- });
99
- }
86
+ // if (evt.position !== null) {
87
+ // obj.position.copy(evt.position);
88
+ // }
89
+ // }
90
+ // }
91
+ // else console.error("download didnt return file");
92
+ // }
93
+ // });
94
+ // }
100
95
 
101
96
 
102
- ContextRegistry.registerCallback(ContextEvent.ContextCreated, evt => {
103
- beginListenFileSpawn(evt.context as Context);
104
- })
105
97
 
98
+ // async function handleUpload(connection: NetworkConnection, file: File, seed: number, obj: IGameObject) {
99
+ // if (!connection.connectionId) {
100
+ // console.error("Can not upload file: not connected to networking backend");
101
+ // return;
102
+ // }
103
+ // if (!obj.guid) {
104
+ // console.error("Can not upload file: the object does not have a guid");
105
+ // return;
106
+ // }
107
+ // // then try uploading it
108
+ // const upload_result = await BlobStorage.upload(file);
109
+ // if (!upload_result) {
110
+ // return;
111
+ // }
112
+ // if (!upload_result.filename) {
113
+ // console.error("Can not send upload event - no filename", file.name);
114
+ // return;
115
+ // }
116
+ // if (!upload_result.hash) {
117
+ // console.error("Can not send upload event - no hash", file.name);
118
+ // return;
119
+ // }
120
+ // if (!upload_result.url) {
121
+ // console.error("Can not send upload event - no url", file.name);
122
+ // return;
123
+ // }
124
+ // const model = new FileSpawnModel(
125
+ // connection.connectionId,
126
+ // seed,
127
+ // obj.guid,
128
+ // upload_result.filename,
129
+ // upload_result.hash,
130
+ // file.size,
131
+ // obj.position,
132
+ // obj.scale,
133
+ // upload_result.url,
134
+ // );
135
+ // if (obj.parent)
136
+ // model.parentGuid = obj.parent["guid"];
137
+ // connection.send(File_Event.File_Spawned, model);
138
+ // }
106
139
 
107
- export function beginListenFileSpawn(context: Context) {
108
- context.connection.beginListen(File_Event.File_Spawned, async (evt: FileSpawnModel) => {
109
- if (evt.sender !== context.connection.connectionId) {
110
- console.log("received file event", evt);
111
- addPreview(evt, context);
112
- let bin: ArrayBuffer | null = null;
113
- try {
114
- bin = await web.download_file(evt.file_name, evt.file_hash, evt.file_size, evt.serverUrl);
115
- }
116
- finally {
117
- removePreview(evt, context);
118
- }
119
- if (bin) {
120
- const prov = new InstantiateIdProvider(evt.seed);
121
- const gltf = await getLoader().parseSync(context, bin, evt.file_name, prov);
122
- if (gltf && gltf.scene) {
123
- const obj = gltf.scene;
124
- def.onDynamicObjectAdded(obj, prov, gltf);
125
- // if we process new scripts immediately references that rely on guids are not properly resolved
126
- // for example duplicatable "object" reference will not be found anymore because guid has changed
127
- // processNewScripts(context);
128
140
 
129
- // add object to proper parent
130
- if (evt.parentGuid) {
131
- const parent = findByGuid(evt.parentGuid, context.scene) as Object3D;
132
- if (parent && "add" in parent) parent.add(obj);
133
- }
134
- if (!obj.parent)
135
- context.scene.add(obj);
141
+ export namespace PreviewHelper {
136
142
 
137
- if (evt.position !== null) {
138
- obj.position.copy(evt.position);
139
- }
140
- }
141
- }
142
- else console.error("download didnt return file");
143
+ const previews = new Map<string, Object3D>();
144
+
145
+ export declare type PreviewInfo = {
146
+ position?: Vector3Like | Vec3,
147
+ size?: Vector3Like | Vec3,
148
+ }
149
+
150
+ export function addPreview(params: { parent: Object3D, guid: string } & PreviewInfo): {
151
+ object: Object3D,
152
+ onProgress: (downloadProgress: number) => void
153
+ } {
154
+
155
+ if (previews.has(params.guid)) {
156
+ removePreview(params.guid);
143
157
  }
144
- });
145
- }
146
158
 
159
+ const root = new Object3D();
160
+ previews.set(params.guid, root);
147
161
 
162
+ const rendering = new Object3D();
163
+ rendering.position.y = -0.5;
164
+ root.add(rendering);
148
165
 
149
- async function handleUpload(connection: NetworkConnection, file: File, seed: number, obj: IGameObject, backendUrl: string) {
150
- if (!connection.connectionId) {
151
- console.error("Can not upload file - no connection id");
152
- return;
153
- }
154
- if (!obj.guid) {
155
- console.error("Can not upload file - no guid", obj, obj.guid);
156
- return;
157
- }
158
- // then try uploading it
159
- const upload_result = await web.upload_file(file, backendUrl);
160
- if (!upload_result) {
161
- return;
162
- }
163
- if (!upload_result.filename) {
164
- console.error("Can not send upload event - no filename", file.name);
165
- return;
166
- }
167
- if (!upload_result.hash) {
168
- console.error("Can not send upload event - no hash", file.name);
169
- return;
170
- }
171
- const model = new FileSpawnModel(connection.connectionId, seed,
172
- obj.guid, upload_result.filename,
173
- upload_result.hash,
174
- file.size,
175
- obj.position,
176
- upload_result.url ?? backendUrl,
177
- );
178
- if (obj.parent)
179
- model.parentGuid = obj.parent["guid"];
180
- connection.send(File_Event.File_Spawned, model);
181
- }
166
+ const outline = new Mesh(new BoxGeometry(1, 1, 1, 1, 1, 1), new MeshBasicMaterial(
167
+ { color: 0xdddddd, wireframe: true, transparent: true, opacity: .3 }
168
+ ));
169
+ outline.position.y = 0.5;
170
+ rendering.add(outline);
182
171
 
172
+ const progress = new Object3D();
173
+ rendering.add(progress);
183
174
 
184
- const previews: { [key: string]: Object3D } = {};
175
+ const progressMesh = new Mesh(new BoxGeometry(1, 1, 1, 1, 1, 1), new MeshBasicMaterial({
176
+ color: 0xbbcccc, transparent: true, opacity: .4
177
+ }));
178
+ progressMesh.position.y = 0.5;
179
+ progress.scale.y = 0.01;
180
+ progress.add(progressMesh);
185
181
 
186
- function addPreview(evt: FileSpawnModel, context: Context) {
187
- const sphere = new BoxGeometry();
188
- const object = new Mesh(sphere, new MeshBasicMaterial({ color: 0x00ff00 }));
189
- const box = new BoxHelper(object, 0x555555);
190
- previews[evt.guid] = box;
191
- context.scene.add(box);
192
- if (evt.parentGuid) {
193
- const parent = findByGuid(evt.parentGuid, context.scene) as Object3D;
194
- if (parent)
195
- parent.add(box);
182
+ const progressMeshTopPlane = new Mesh(new PlaneGeometry(1, 1, 1, 1), new MeshBasicMaterial({
183
+ color: 0x000022, transparent: true, opacity: .05, depthTest: false,
184
+ }));
185
+ progressMeshTopPlane.rotateX(-Math.PI / 2);
186
+ progressMeshTopPlane.position.y = .51;
187
+ progressMesh.add(progressMeshTopPlane);
188
+
189
+
190
+ params.parent.add(root);
191
+ if (params.position) root.position?.copy(params.position);
192
+ if (params.size) {
193
+ root.worldScale = new Vector3().copy(params.size);
194
+ // root.scale?.copy(params.size);
195
+ }
196
+ root.position.y = root.scale.y / 2;
197
+
198
+ return {
199
+ object: root,
200
+ onProgress: (downloadProgress: number) => {
201
+ if (progress instanceof Object3D) {
202
+ progress.scale.set(1, downloadProgress, 1);
203
+ }
204
+ },
205
+ }
196
206
  }
197
- if (evt.position)
198
- box.position.copy(evt.position);
199
- }
200
207
 
201
- function removePreview(evt: FileSpawnModel, _context: Context) {
202
- const guid = evt.guid;
203
- const existing = previews[guid];
204
- if (existing) {
205
- delete previews[guid];
206
- existing.removeFromParent();
208
+ export function removePreview(guid: string) {
209
+ const existing = previews.get(guid);
210
+ if (existing) {
211
+ previews.delete(guid);
212
+ existing.removeFromParent();
213
+ }
207
214
  }
208
- }
215
+ }
src/engine/engine_networking_streams.ts CHANGED
@@ -556,7 +556,7 @@
556
556
  }
557
557
  }
558
558
 
559
- private _tickIntervalId?: number;
559
+ private _tickIntervalId?: any; /* for webpack */
560
560
 
561
561
  private tick = () => {
562
562
  this.updateSendingCalls();
src/engine/engine_networking.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  const defaultNetworkingBackendUrlProvider = "https://urls.needle.tools/default-networking-backend/index";
2
- let serverUrl: string | undefined = "wss://needle-tiny-starter.glitch.me/socket";
2
+ let networkingServerUrl: string | undefined = "wss://networking.needle.tools/socket";
3
3
 
4
4
  import * as flatbuffers from 'flatbuffers';
5
5
  import { type Websocket } from 'websocket-ts';
@@ -55,6 +55,15 @@
55
55
  /** When a user joins a room, the server sends the entire room state. Afterwards, the server sends the room-state-sent event. */
56
56
  RoomStateSent = "room-state-sent",
57
57
  }
58
+ /**
59
+ * See {@link RoomEvents} for all event names and docs.
60
+ * - `joined-room`: When the local user has joined a room
61
+ * - `left-room`: When the local user has left a room
62
+ * - `user-joined-room`: When a other user has joined the room
63
+ * - `user-left-room`: When a other user has left the room
64
+ * - `room-state-sent`: When the server has sent the room state to the client
65
+ */
66
+ type RoomEventsIncoming = Exclude<`${RoomEvents}`, "join-room" | "leave-room">;
58
67
 
59
68
  /** Received when listening to `RoomEvents.JoinedRoom` event */
60
69
  export class JoinedRoomResponse {
@@ -88,6 +97,7 @@
88
97
  GainedOwnershipBroadcast = 'gained-ownership-broadcast',
89
98
  LostOwnershipBroadcast = 'lost-ownership-broadcast',
90
99
  }
100
+ type OwnershipEventNamesIncoming = Exclude<`${OwnershipEvent}`, "request-has-owner" | "request-is-owner" | "request-ownership" | "remove-ownership">;
91
101
 
92
102
  declare type GainedOwnershipBroadcastResponse = {
93
103
  guid: string;
@@ -281,6 +291,9 @@
281
291
  return this._connectionId;
282
292
  }
283
293
 
294
+ /** Returns true if the networking backend is in debug mode.
295
+ * To see all networking messages in the console use `?debugnet` in the url
296
+ */
284
297
  public get isDebugEnabled(): boolean {
285
298
  return debugNet;
286
299
  }
@@ -442,7 +455,7 @@
442
455
  }
443
456
 
444
457
  /** Use to start listening to networking events */
445
- public beginListen(key: string | OwnershipEvent, callback: Function): Function {
458
+ public beginListen(key: (string & {}) | OwnershipEvent | OwnershipEventNamesIncoming | RoomEventsIncoming | RoomEvents, callback: Function): Function {
446
459
  if (!this._listeners[key])
447
460
  this._listeners[key] = [];
448
461
  this._listeners[key].push(callback);
@@ -450,10 +463,10 @@
450
463
  }
451
464
 
452
465
  /**@deprecated please use stopListen instead (2.65.2-pre) */
453
- public stopListening(key: string | OwnershipEvent, callback: Function | null) { return this.stopListen(key, callback); }
466
+ public stopListening(key: (string & {}) | OwnershipEvent | OwnershipEventNamesIncoming | RoomEventsIncoming | RoomEvents, callback: Function | null) { return this.stopListen(key, callback); }
454
467
 
455
468
  /** Use to stop listening to networking events */
456
- public stopListen(key: string | OwnershipEvent, callback: Function | null) {
469
+ public stopListen(key: (string & {}) | OwnershipEvent | OwnershipEventNamesIncoming | RoomEventsIncoming | RoomEvents, callback: Function | null) {
457
470
  if (!callback) return;
458
471
  if (!this._listeners[key]) return;
459
472
  const index = this._listeners[key].indexOf(callback);
@@ -487,16 +500,21 @@
487
500
  }
488
501
 
489
502
  /** Used to connect to the networking server */
490
- public async connect() {
491
- if (this.connected) return Promise.resolve(true);
492
- if (debugNet)
493
- console.log("connecting");
494
- const overrideUrl = this.netWebSocketUrlProvider?.getWebsocketUrl();
503
+ public async connect(url?: string) {
504
+ if (this.connected && url && url !== networkingServerUrl) {
505
+ return Promise.reject("Can not connect to different server url. Please disconnect first.");
506
+ }
507
+ if (this.connected) {
508
+ return Promise.resolve(true);
509
+ }
510
+ if (url)
511
+ console.debug("Connecting to user provided url " + url);
512
+ const overrideUrl = url || this.netWebSocketUrlProvider?.getWebsocketUrl();
495
513
  if (overrideUrl) {
496
- serverUrl = overrideUrl;
514
+ networkingServerUrl = overrideUrl;
497
515
  }
498
516
  else if (isHostedOnGlitch()) {
499
- serverUrl = "wss://" + window.location.host + "/socket";
517
+ networkingServerUrl = "wss://" + window.location.host + "/socket";
500
518
  }
501
519
  return this.connectWebsocket();
502
520
  };
@@ -505,6 +523,7 @@
505
523
  public disconnect() {
506
524
  this._ws?.close();
507
525
  this._ws = undefined;
526
+ networkingServerUrl = undefined;
508
527
  }
509
528
 
510
529
  private _listeners: { [key: string]: Function[] } = {};
@@ -535,33 +554,33 @@
535
554
  didResolve = true;
536
555
  res(val);
537
556
  }
538
- if (serverUrl === undefined) {
557
+ if (networkingServerUrl === undefined) {
539
558
  console.log("Fetch default backend url: " + defaultNetworkingBackendUrlProvider);
540
559
  const failed = false;
541
560
  const defaultUrlResponse = await fetch(defaultNetworkingBackendUrlProvider);
542
- serverUrl = await defaultUrlResponse.text();
561
+ networkingServerUrl = await defaultUrlResponse.text();
543
562
  if (failed) return;
544
563
  }
545
564
 
546
- if (serverUrl === undefined) {
565
+ if (networkingServerUrl === undefined) {
547
566
  resolve(false);
548
567
  return;
549
568
  }
550
569
 
551
- console.debug("⊡ Connecting to networking backend on\n" + serverUrl)
570
+ console.debug("⊡ Connecting to networking backend on\n" + networkingServerUrl)
552
571
  const pkg = await import('websocket-ts');
553
572
  // @ts-ignore
554
573
  const WebsocketBuilder = pkg.default?.WebsocketBuilder ?? pkg.WebsocketBuilder;
555
574
  const ExponentialBackoff = pkg.default?.ExponentialBackoff ?? pkg.ExponentialBackoff;
556
- const ws = new WebsocketBuilder(serverUrl)
557
- .withMaxRetries(3)
558
- .withBackoff(new ExponentialBackoff(2000, 2))
575
+ const ws = new WebsocketBuilder(networkingServerUrl)
576
+ .withMaxRetries(10)
577
+ .withBackoff(new ExponentialBackoff(2000, 4))
559
578
  .onOpen(() => {
560
579
  this._connectingToWebsocketPromise = null;
561
580
  this._ws = ws;
562
581
  this.connected = true;
563
- if (isDevEnvironment() || debugNet) console.log("⊞ Connected to networking backend\n" + serverUrl);
564
- else console.debug("⊞ Connected to networking backend", serverUrl);
582
+ if (isDevEnvironment() || debugNet) console.log("⊞ Connected to networking backend\n" + networkingServerUrl);
583
+ else console.debug("⊞ Connected to networking backend", networkingServerUrl);
565
584
  resolve(true);
566
585
  this.onSendQueued(SendQueue.OnConnection);
567
586
  })
@@ -570,12 +589,15 @@
570
589
  this.connected = false;
571
590
  this._isInRoom = false;
572
591
  resolve(false);
592
+ let msg = "Websocket connection closed...";
593
+ if (!networkingServerUrl?.includes("/socket")) msg += ` Do you perhaps mean to connect to \"/socket\"?`;
594
+ console.error(msg);
573
595
  })
574
- .onError((_) => {
575
- console.error("⊠ Websocket error");
596
+ .onError((_e) => {
597
+ console.error("⊠ Websocket connection failed...");
576
598
  resolve(false);
577
599
  })
578
- .onRetry(() => { console.log("Retry connecting to networking websocket") })
600
+ .onRetry(() => { console.log("→ Retry connecting to networking websocket") })
579
601
  .build();
580
602
  ws.addEventListener(pkg.WebsocketEvent.message, (socket, msg) => {
581
603
  this.onMessage(socket, msg);
src/engine/engine_scenetools.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Camera, Loader, Material, Mesh, Object3D } from "three";
1
+ import { Cache, Camera, Loader, Material, Mesh, Object3D } from "three";
2
2
  import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
3
3
  import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
4
4
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
@@ -13,28 +13,28 @@
13
13
  import { SerializationContext } from "./engine_serialization_core.js";
14
14
  import { Context } from "./engine_setup.js"
15
15
  import { postprocessFBXMaterials } from "./engine_three_utils.js";
16
- import { type UIDProvider } from "./engine_types.js";
16
+ import { Model, type UIDProvider } from "./engine_types.js";
17
17
  import * as utils from "./engine_utils.js";
18
- import { FileType, tryDetermineFileTypeFromURL } from "./engine_utils_format.js"
18
+ import { tryDetermineFileTypeFromURL } from "./engine_utils_format.js"
19
19
  import { invokeAfterImportPluginHooks, registerComponentExtension, registerExtensions } from "./extensions/extensions.js";
20
20
  import { NEEDLE_components } from "./extensions/NEEDLE_components.js";
21
21
 
22
22
  /** @internal */
23
- export class NeedleGltfLoader implements INeedleGltfLoader {
23
+ export class NeedleLoader implements INeedleGltfLoader {
24
24
  createBuiltinComponents(context: Context, gltfId: string, gltf: any, seed: number | UIDProvider | null, extension?: NEEDLE_components | undefined) {
25
25
  return createBuiltinComponents(context, gltfId, gltf, seed, extension);
26
26
  }
27
27
  writeBuiltinComponentData(comp: any, context: SerializationContext) {
28
28
  return writeBuiltinComponentData(comp, context);
29
29
  }
30
- parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<GLTF | undefined> {
30
+ parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> {
31
31
  return parseSync(context, data, path, seed);
32
32
  }
33
- loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: ((ProgressEvent: any) => void) | undefined): Promise<GLTF | undefined> {
33
+ loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: ((ProgressEvent: any) => void) | undefined): Promise<Model | undefined> {
34
34
  return loadSync(context, url, sourceId, seed, prog);
35
35
  }
36
36
  }
37
- registerLoader(NeedleGltfLoader);
37
+ registerLoader(NeedleLoader); // Register the loader
38
38
 
39
39
 
40
40
  const printGltf = utils.getParam("printGltf") || utils.getParam("printgltf");
@@ -105,8 +105,12 @@
105
105
 
106
106
  switch (type) {
107
107
  case "unknown":
108
- console.warn("Unknown file type. Assuming glTF:", url);
109
- return new GLTFLoader();
108
+ {
109
+ console.warn("Unknown file type. Assuming glTF:", url);
110
+ const loader = new GLTFLoader();
111
+ await registerExtensions(loader, context, url);
112
+ return loader;
113
+ }
110
114
  case "fbx":
111
115
  return new FBXLoader();
112
116
  case "obj":
@@ -114,7 +118,7 @@
114
118
  case "usd":
115
119
  case "usda":
116
120
  case "usdz":
117
- console.warn("USD files are not supported.")
121
+ console.warn(type.toUpperCase() + " files are not supported.")
118
122
  return null;
119
123
  // return new USDZLoader();
120
124
  default:
@@ -122,9 +126,11 @@
122
126
  case "gltf":
123
127
  case "glb":
124
128
  case "vrm":
125
- const loader = new GLTFLoader();
126
- await registerExtensions(loader, context, url);
127
- return loader;
129
+ {
130
+ const loader = new GLTFLoader();
131
+ await registerExtensions(loader, context, url);
132
+ return loader;
133
+ }
128
134
  }
129
135
  }
130
136
 
@@ -135,7 +141,7 @@
135
141
  * @param seed The seed for generating unique ids
136
142
  * @returns The loaded gltf object
137
143
  */
138
- export async function parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<GLTF | undefined> {
144
+ export async function parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> {
139
145
  if (typeof path !== "string") {
140
146
  console.warn("Parse gltf binary without path, this might lead to errors in resolving extensions. Please provide the source path of the gltf/glb file", path, typeof path);
141
147
  path = "";
@@ -148,9 +154,7 @@
148
154
 
149
155
  // Handle OBJ Loader
150
156
  if (loader instanceof OBJLoader) {
151
- if (typeof data !== "string") {
152
- data = new TextDecoder().decode(data);
153
- }
157
+ if (typeof data !== "string") { data = new TextDecoder().decode(data); }
154
158
  const res = loader.parse(data);
155
159
  return {
156
160
  animations: res.animations,
@@ -159,7 +163,8 @@
159
163
  } as GLTF;
160
164
  }
161
165
  // Handle any other loader that is not a GLTFLoader
162
- if (!(loader instanceof GLTFLoader)) {
166
+ const isNotGLTF = !(loader instanceof GLTFLoader);
167
+ if (isNotGLTF) {
163
168
  const res = loader.parse(data, path);
164
169
  postprocessLoadedFile(loader, res);
165
170
  return {
@@ -222,7 +227,7 @@
222
227
  * @param prog A progress callback
223
228
  * @returns The loaded gltf object
224
229
  */
225
- export async function loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (ProgressEvent) => void): Promise<GLTF | undefined> {
230
+ export async function loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (ProgressEvent) => void): Promise<Model | undefined> {
226
231
  // better to create new loaders every time
227
232
  // (maybe we can cache them...)
228
233
  // but due to the async nature and potentially triggering multiple loads at the same time
@@ -278,8 +283,7 @@
278
283
  async function compileAsync(scene: Object3D, context: Context, camera?: Camera | null) {
279
284
  if (!camera) camera = context.mainCamera;
280
285
  try {
281
- if (camera)
282
- {
286
+ if (camera) {
283
287
  await context.renderer.compileAsync(scene, camera, context.scene)
284
288
  .catch(err => {
285
289
  console.warn(err.message);
@@ -288,7 +292,7 @@
288
292
  else
289
293
  registerPrewarmObject(scene, context);
290
294
  }
291
- catch (err:Error | any) {
295
+ catch (err: Error | any) {
292
296
  console.warn(err?.message || err);
293
297
  }
294
298
  }
src/engine/engine_serialization_builtin_serializer.ts CHANGED
@@ -263,12 +263,12 @@
263
263
  super([EventList]);
264
264
  }
265
265
 
266
- onSerialize(_data: EventList, _context: SerializationContext): EventListData | undefined {
266
+ onSerialize(_data: EventList<any>, _context: SerializationContext): EventListData | undefined {
267
267
  console.log("TODO: SERIALIZE EVENT");
268
268
  return undefined;
269
269
  }
270
270
 
271
- onDeserialize(data: EventListData, context: SerializationContext): EventList | undefined | null {
271
+ onDeserialize(data: EventListData, context: SerializationContext): EventList<any> | undefined | null {
272
272
  // TODO: check that we dont accidentally deserialize methods to EventList objects. This is here to make is easy for react-three-fiber to just add props as { () => { } }
273
273
  if (typeof data === "function") {
274
274
  const evtList = new EventList([new CallInfo(data, null, [], true)]);
@@ -355,7 +355,7 @@
355
355
  }
356
356
  }
357
357
  }
358
- const evt: EventList = new EventList(fns);
358
+ const evt = new EventList(fns);
359
359
 
360
360
  if (debugExtension)
361
361
  console.log(evt);
src/engine/engine_types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { QueryFilterFlags } from "@dimforge/rapier3d-compat";
2
2
  import { AnimationClip, Color, Material, Mesh, Object3D, Quaternion } from "three";
3
3
  import { Vector3 } from "three";
4
- import { type GLTF as GLTF3 } from "three/examples/jsm/loaders/GLTFLoader.js";
4
+ import { type GLTF as THREE_GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
5
5
 
6
6
  import type { Camera as CameraComponent } from "../engine-components/api.js";
7
7
  import type { Context } from "./engine_context.js";
@@ -21,20 +21,28 @@
21
21
  type NoInternalNeedleEngineState<T> = Omit<T, "destroyed" | "gameObject" | "activeAndEnabled" | "context" | "isComponent" | "scene" | "up" | "forward" | "right" | "worldRotation" | "worldEuler" | "worldPosition" | "worldQuaternion">;
22
22
  export type ComponentInit<T> = Partial<NoInternalNeedleEngineState<NoInternals<NoUndefinedNoFunctions<T>>>>
23
23
 
24
- export type GLTF = GLTF3 & {
25
- // asset: { generator: string, version: string }
26
- // animations: AnimationClip[];
27
- // cameras: Camera[];
28
- // scene: Object3D;
29
- // scenes: Object3D[];
30
- // userData: object;
31
- // parser: any;
24
+ /** GLTF, GLB or VRM */
25
+ export type GLTF = THREE_GLTF;
26
+ /** FBX type */
27
+ export type FBX = {
28
+ animations: AnimationClip[];
29
+ scene: Object3D;
30
+ scenes: Object3D[];
32
31
  }
32
+ /** OBJ type */
33
+ export type OBJ = {
34
+ animations: AnimationClip[];
35
+ scene: Object3D;
36
+ scenes: Object3D[];
37
+ }
38
+ /** All possible model types that Needle Engine can load */
39
+ export type Model = (GLTF | FBX | OBJ);
33
40
 
34
- export type LoadedGLTF = {
41
+ /** A loaded model */
42
+ export type LoadedModel = {
35
43
  src: string;
36
- file: GLTF;
37
- }
44
+ file: Model;
45
+ };
38
46
 
39
47
  /** used to find data registered via gltf files e.g. find lightmaps for a Renderer component that were shipped inside a gltf */
40
48
  export declare type SourceIdentifier = string;
src/engine/engine_web_api.ts DELETED
@@ -1,120 +0,0 @@
1
- // workaround for this is not a function when deployed
2
- // see https://github.com/pvorb/node-md5/issues/52
3
- import md5 from "md5";
4
-
5
- export class Upload_Result {
6
- success: boolean;
7
- filename: string | null;
8
- hash: string | null;
9
- size: number | null;
10
- url?: string | null;
11
-
12
- constructor(success: boolean, filename: string | null, hash: string | null, size: number | null) {
13
- this.success = success;
14
- this.filename = filename;
15
- this.hash = hash;
16
- this.size = size;
17
- }
18
- }
19
-
20
- export async function upload_file(file: File, url: string): Promise<Upload_Result | null> {
21
- const buffer = await file.arrayBuffer();
22
- const hashString = hash(buffer);
23
- const ext = file.name.split('.').pop();
24
- const filename = hashString + "." + ext;
25
- const alias = file.name.split('.').shift();
26
- console.assert(alias !== undefined);
27
- const body = { alias: alias, filename: filename };
28
- const exists = await fetch(url + '/exists', { method: "POST", body: JSON.stringify(body) });
29
- const answer: { success: boolean, exists: boolean } = await exists.json();
30
- if (!answer.success) {
31
- console.warn("exists check did fail");
32
- }
33
- if (answer.exists) {
34
- console.log("file already exists", hashString);
35
- return new Upload_Result(true, filename, hashString, file.size);
36
- }
37
-
38
- console.log("begin uploading file", alias, file.size);
39
- const formData = new FormData();
40
- formData.append("file", file);
41
- const headers: Record<string, string> = {};
42
- headers["filesize"] = file.size as unknown as string;
43
- if (alias)
44
- headers["alias"] = alias;
45
- const upload_res_raw = await fetch(url + '/upload/file', { method: "POST", body: formData, headers: headers });
46
- const upload_res: { success: boolean, id: string, hash_sum: string, message?: string } = await upload_res_raw.json();
47
- if (upload_res?.success === false) {
48
- if (upload_res.message !== undefined) {
49
- console.error("Upload failed:", upload_res.message);
50
- }
51
- else {
52
- console.error("Upload failed");
53
- }
54
- return null;
55
- }
56
- console.assert(upload_res.hash_sum === hashString, "hash sum did not match", "received:", upload_res.hash_sum, "expected:", hashString);
57
- if (upload_res.success) console.log("successfully uploaded", hashString, upload_res.id);
58
- const res = new Upload_Result(upload_res.success, filename, hashString, file.size);
59
- res.url = url;
60
- return res;
61
- }
62
-
63
- export function hash(buffer: ArrayBuffer): string {
64
- return md5(new Uint8Array(buffer))
65
- };
66
-
67
- export async function download_file(filename: string, expectedHash: string, expectedSize: number, serverUrl: string, skipChecks: boolean = false): Promise<ArrayBuffer | null> {
68
- try {
69
- const download_res = await fetch(serverUrl + '/download/file', { method: "POST", body: filename });
70
- if (download_res.status !== 200) {
71
- // const res = await download_res.json();
72
- console.error("download failed", download_res);
73
- return null;
74
- }
75
- const bin = await download_res.blob();
76
- const buffer = await bin.arrayBuffer();
77
- if (!skipChecks)
78
- console.assert(bin.size === expectedSize, "size mismatch", "expected:", expectedSize, "got:", bin.size);
79
- const result_hash = hash(buffer);
80
- if (!skipChecks)
81
- console.assert(result_hash === expectedHash, "hash mismatch, downloaded file is invalid");
82
- return bin.arrayBuffer();
83
- }
84
- catch (err) {
85
- console.error(err);
86
- }
87
- return null;
88
- }
89
-
90
- export async function download(url: string, progressCallback: (prog: ProgressEvent) => void) : Promise<Uint8Array | null> {
91
- const response = await fetch(url);
92
-
93
- const reader = response.body?.getReader();
94
- const contentLength = response.headers.get('Content-Length');
95
- const total = contentLength ? parseInt(contentLength) : 0;
96
-
97
- if (!reader) return null;
98
-
99
- let received: number = 0;
100
- const chunks: Uint8Array[] = [];
101
- while (true) {
102
- const { done, value } = await reader.read();
103
- if (value) {
104
- chunks.push(value);
105
- received += value.length;
106
- progressCallback.call(null, new ProgressEvent('progress', { loaded: received, total: total }));
107
- }
108
-
109
- if (done) {
110
- break;
111
- }
112
- }
113
- const final = new Uint8Array(received);
114
- let position = 0;
115
- for (const chunk of chunks) {
116
- final.set(chunk, position);
117
- position += chunk.length;
118
- }
119
- return final;
120
- }
src/engine/engine.ts DELETED
@@ -1,14 +0,0 @@
1
- import "./engine_hot_reload.js";
2
- import "./tests/test_utils.js";
3
-
4
- import * as engine_scenetools from "./engine_scenetools.js";
5
- import * as engine_setup from "./engine_setup.js";
6
- import { RGBAColor } from "./js-extensions/RGBAColor.js";
7
-
8
- const engine : any = {
9
- ...engine_setup,
10
- ...engine_scenetools,
11
- RGBAColor,
12
- };
13
-
14
- export { engine as engine }
src/engine-components/EventList.ts CHANGED
@@ -90,14 +90,16 @@
90
90
 
91
91
  const isUpperCase = (string) => /^[A-Z]*$/.test(string);
92
92
 
93
- export class EventListEvent extends Event { //implements ArrayLike<T> {
94
- args?: [];
93
+ export class EventListEvent<TArgs extends any> extends Event { //implements ArrayLike<T> {
94
+ args?: TArgs;
95
95
  }
96
96
 
97
+
98
+
97
99
  /**
98
100
  * The EventList is a class that can be used to create a list of event listeners that can be invoked
99
101
  */
100
- export class EventList implements IEventList {
102
+ export class EventList<TArgs extends any = any> implements IEventList {
101
103
 
102
104
  /** checked during instantiate to create a new instance */
103
105
  readonly isEventList = true;
@@ -201,7 +203,7 @@
201
203
  }
202
204
 
203
205
  /** Invoke all the methods that are subscribed to this event */
204
- invoke(...args: any) {
206
+ invoke(...args: Array<TArgs>) {
205
207
  if (this._isInvoking) {
206
208
  console.warn("Circular event invocation detected. Please check your event listeners for circular references.", this);
207
209
  return false;
@@ -242,7 +244,7 @@
242
244
  }
243
245
 
244
246
  /** Add a new event listener to this event */
245
- addEventListener(cb: Function): Function {
247
+ addEventListener(cb: (args: TArgs) => void): Function {
246
248
  this.methods.push(new CallInfo(cb));
247
249
  return cb;
248
250
  }
@@ -260,4 +262,5 @@
260
262
  removeAllEventListeners() {
261
263
  this.methods.length = 0;
262
264
  }
263
- }
265
+ }
266
+
src/engine-components/EventTrigger.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  @serializable()
9
9
  eventID!: EventType;
10
10
  @serializable(EventList)
11
- callback?: EventList;
11
+ callback: EventList<any> = new EventList();
12
12
  }
13
13
 
14
14
  /**
src/engine-components/ui/InputField.ts CHANGED
@@ -30,10 +30,10 @@
30
30
  private placeholder?: Text;
31
31
 
32
32
  @serializable(EventList)
33
- onValueChanged?: EventList;
33
+ onValueChanged?: EventList<any>;
34
34
 
35
35
  @serializable(EventList)
36
- onEndEdit?: EventList;
36
+ onEndEdit?: EventList<any>;
37
37
 
38
38
 
39
39
  private static active: InputField | null = null;
plugins/types/license.d.ts CHANGED
@@ -4,8 +4,22 @@
4
4
  * Use to activate a needle engine license
5
5
  */
6
6
  export type License = {
7
- /** this is the email you bought the license with */
8
- id: string,
9
- /** this is one of the invoice IDs for an active subscription */
10
- key: string,
7
+ /** this is the email you bought the license with
8
+ * @deprecated using the access token now
9
+ */
10
+ id?: string,
11
+ /** this is one of the invoice IDs for an active subscription
12
+ * @deprecated using the access token now
13
+ */
14
+ key?: string,
15
+
16
+ /**
17
+ * Team ID
18
+ */
19
+ team?: string,
20
+
21
+ /**
22
+ * Pass in an access token to activate a license or use the NEEDLE_CLOUD_TOKEN environment variable
23
+ */
24
+ accessToken?: string,
11
25
  }
src/asap/needle-asap.ts CHANGED
@@ -75,8 +75,22 @@
75
75
 
76
76
 
77
77
  function insertTemporaryContentWhileEngineHasntLoaded(needleEngineElement: HTMLElement) {
78
- if (needleEngineHasLoaded()) return;
78
+ // if (needleEngineHasLoaded()) return;
79
79
 
80
+ const asapdiv = document.createElement("div");
81
+ asapdiv.style.cssText = `
82
+ position: absolute;
83
+ top: 0;
84
+ left: 0;
85
+ width: 100%;
86
+ height: 100%;
87
+ z-index: -1;
88
+ display: flex;
89
+ justify-content: center;
90
+ align-items: center;
91
+ pointer-events: none;
92
+ `
93
+
80
94
  const img = document.createElement("img");
81
95
 
82
96
  // if a custom logo is assigned we should use this here
@@ -86,24 +100,22 @@
86
100
  img.src = customLogoUrl;
87
101
  else
88
102
  img.src = needleLogoOnlySVG;
89
- img.style.position = "absolute";
90
- img.style.top = "50%";
91
- img.style.left = "50%";
92
- img.style.transform = "translate(-50%, -50%)";
93
103
  img.style.zIndex = "-10";
94
- img.style.pointerEvents = "none";
95
- img.style.width = "33px";
104
+ img.style.width = "140px";
96
105
  img.style.height = "auto";
97
- img.style.filter = "grayscale(100%)";
98
- img.style.opacity = "0";
106
+ img.style.opacity = "1";
99
107
  img.style.textShadow = "0 0 10px 0px rgba(0,0,0,.5)";
100
- needleEngineElement.appendChild(img);
101
108
 
109
+ asapdiv.appendChild(img);
110
+ needleEngineElement.appendChild(asapdiv);
111
+
102
112
  // animation to pulsate
113
+ // don't animate to opacity 0 because for LCP we need to keep the element in the DOM and visible to the user
114
+ // see https://web.dev/articles/lcp#what-elements-are-considered
103
115
  img.animate([
104
- { opacity: 0 },
105
- { opacity: .5 },
106
- { opacity: 0 }
116
+ { opacity: .2 },
117
+ { opacity: .7 },
118
+ { opacity: .2 }
107
119
  ], {
108
120
  duration: 3000,
109
121
  iterations: Infinity
src/engine/webcomponents/needle menu/needle-menu.ts CHANGED
@@ -587,10 +587,10 @@
587
587
  margin-left: 1rem;
588
588
  margin-bottom: .02rem;
589
589
  }
590
- .compact .options {
590
+ .compact .options {
591
591
  /** e.g. if we have a very wide menu item like a select with long option names we don't want to overflow **/
592
592
  max-width: 100%;
593
-
593
+
594
594
  & > button, & > select {
595
595
  display: flex;
596
596
  flex-basis: 100%;
plugins/types/needleConfig.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  generator: string | null,
10
10
  gzip: boolean,
11
11
  allowHotReload: boolean,
12
- license: License | string | null | undefined,
12
+ license: License | null | undefined,
13
13
  useRapier: boolean,
14
14
  developmentBuild: boolean,
15
15
  }
@@ -22,4 +22,7 @@
22
22
  assetsDirectory?: string;
23
23
  scriptsDirectory?: string;
24
24
  codegenDirectory?: string;
25
+ build?: {
26
+ copy?: Array<string>;
27
+ }
25
28
  }
src/engine/js-extensions/Object3D.ts CHANGED
@@ -22,6 +22,10 @@
22
22
  // NOTE: keep in sync with method declarations below
23
23
  declare module 'three' {
24
24
  export interface Object3D {
25
+
26
+ get guid(): string | undefined;
27
+ set guid(value: string | undefined);
28
+
25
29
  /**
26
30
  * Add a Needle Engine component to the {@link Object3D}.
27
31
  * @param comp The component instance or constructor to add.
src/engine-components/OrbitControls.ts CHANGED
@@ -122,10 +122,10 @@
122
122
  maxPolarAngle: number = Math.PI;
123
123
 
124
124
  /** When enabled the camera can be moved using keyboard keys. The keys are defined in the `controls.keys` property
125
- * @default true
125
+ * @default false
126
126
  */
127
127
  @serializable()
128
- enableKeys: boolean = true;
128
+ enableKeys: boolean = false;
129
129
  /** When enabled the camera movement will be damped
130
130
  * @default true
131
131
  */
src/engine-components-experimental/networking/PlayerSync.ts CHANGED
@@ -57,7 +57,7 @@
57
57
 
58
58
  /** Event called when an instance is spawned */
59
59
  @serializable(EventList)
60
- onPlayerSpawned?: EventList;
60
+ onPlayerSpawned?: EventList<Object3D>;
61
61
 
62
62
 
63
63
  private _localInstance?: Promise<IGameObject>;
src/engine/codegen/register_types.ts CHANGED
@@ -63,6 +63,7 @@
63
63
  import { DocumentExtension } from "../../engine-components/export/usdz/extensions/DocumentExtension.js";
64
64
  import { DragControls } from "../../engine-components/DragControls.js";
65
65
  import { DropListener } from "../../engine-components/DropListener.js";
66
+ import { DropListenerAddedEvent } from "../../engine-components/DropListener.js";
66
67
  import { Duplicatable } from "../../engine-components/Duplicatable.js";
67
68
  import { EffectWrapper } from "../../engine-components/postprocessing/Effects/EffectWrapper.js";
68
69
  import { EmissionModule } from "../../engine-components/particlesystem/ParticleSystemModules.js";
@@ -283,6 +284,7 @@
283
284
  TypeStore.add("DocumentExtension", DocumentExtension);
284
285
  TypeStore.add("DragControls", DragControls);
285
286
  TypeStore.add("DropListener", DropListener);
287
+ TypeStore.add("DropListenerAddedEvent", DropListenerAddedEvent);
286
288
  TypeStore.add("Duplicatable", Duplicatable);
287
289
  TypeStore.add("EffectWrapper", EffectWrapper);
288
290
  TypeStore.add("EmissionModule", EmissionModule);
src/engine-components/SceneSwitcher.ts CHANGED
@@ -229,7 +229,7 @@
229
229
  * The sceneLoaded event is called when a scene/glTF is loaded and added to the scene
230
230
  */
231
231
  @serializable(EventList)
232
- sceneLoaded: EventList = new EventList();
232
+ sceneLoaded: EventList<SceneSwitcher> = new EventList();
233
233
 
234
234
  private _currentIndex: number = -1;
235
235
  private _currentScene: AssetReference | undefined = undefined;
src/engine-components/timeline/SignalAsset.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  @serializable(SignalAsset)
16
16
  signal!: SignalAsset;
17
17
  @serializable(EventList)
18
- reaction!: EventList;
18
+ reaction!: EventList<void>;
19
19
  }
20
20
 
21
21
  /** SignalReceiver is a component that listens for signals and invokes a reaction when a signal is received.
src/engine-components/SpatialTrigger.ts CHANGED
@@ -26,11 +26,11 @@
26
26
  @serializable()
27
27
  triggerMask: number = 0;
28
28
  @serializable(EventList)
29
- onEnter?: EventList;
29
+ onEnter?: EventList<any>;
30
30
  @serializable(EventList)
31
- onStay?: EventList;
31
+ onStay?: EventList<any>;
32
32
  @serializable(EventList)
33
- onExit?: EventList;
33
+ onExit?: EventList<any>;
34
34
 
35
35
  start() {
36
36
  if (debug) console.log(this.name, this.triggerMask, this);
plugins/types/userconfig.d.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  webpack: object | undefined
6
6
  }
7
7
 
8
+ /**
9
+ * Settings for the Needle plugin
10
+ */
8
11
  export type userSettings = {
9
12
 
10
13
  /** disable needle asap plugin */
@@ -20,6 +23,8 @@
20
23
 
21
24
  /** disable automatic copying of files to include and output directory (dist) */
22
25
  noCopy?: boolean;
26
+ /** When enabled the needle-engine include directory will be copied */
27
+ copyIncludesFromEngine?: boolean;
23
28
  /** set to false to tree-shake rapier physics engine to the reduce bundle size */
24
29
  useRapier?: boolean;
25
30
  noDependencyWatcher?: boolean;
@@ -34,6 +39,9 @@
34
39
  /** Set to false to disable hot reload for the needle plugin. */
35
40
  allowHotReload?: boolean;
36
41
 
42
+ /** When enabled the Vite drop plugin will be enabled. @default false */
43
+ useDrop?: boolean;
44
+
37
45
  noCodegenTransform?: boolean;
38
46
  noFacebookInstantGames?: boolean;
39
47
  /** Set to true to create an imports.log file that shows all module imports. The file is generated when stopping the server. */
@@ -44,12 +52,25 @@
44
52
 
45
53
  /** Set to true to disable the needle build pipeline (running compression and optimization as a postprocessing step on the exported glTF files) */
46
54
  noBuildPipeline?: boolean;
47
- /** Set to a specific version of the Needle Build Pipeline.
48
- * @default "latest"
49
- * @example "2.2.0-alpha"
50
- */
51
- buildPipelineVersion?: string;
52
55
 
56
+ /**
57
+ * Use to configure optimized builds
58
+ */
59
+ buildPipeline?: {
60
+ /** Set to false to prevent the build pipeline from running */
61
+ enabled?: boolean,
62
+ /** Set a project name (cloud only) */
63
+ projectName?: string,
64
+ /** Enable for verbose log output */
65
+ verbose?: boolean,
66
+
67
+ /** Set to a specific version of the Needle Build Pipeline.
68
+ * @default "latest"
69
+ * @example "2.2.0-alpha"
70
+ */
71
+ version?: string;
72
+ }
73
+
53
74
  /** required for @serializable https://github.com/vitejs/vite/issues/13736 */
54
75
  vite44Hack?: boolean;
55
76
 
src/engine-components/webxr/WebXR.ts CHANGED
@@ -339,7 +339,7 @@
339
339
  }
340
340
  }
341
341
 
342
- private onAvatarSpawned = (instance: GameObject) => {
342
+ private onAvatarSpawned = (instance: Object3D) => {
343
343
  // spawned webxr avatars must have a avatar component
344
344
  if (debug) console.log("WebXR.onAvatarSpawned", instance);
345
345
  let avatar = GameObject.getComponentInChildren(instance, Avatar);
plugins/common/files.js ADDED
@@ -0,0 +1,32 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * @param {string} src
6
+ * @param {string} dest
7
+ * @param {boolean} override
8
+ * @param {{count:number} | undefined} ctx
9
+ */
10
+ export function copyFilesSync(src, dest, override = true, ctx = undefined) {
11
+ if (dest === null) {
12
+ console.log(`[needle-copy] - Copy ${src} to ${dest} - dest is null`)
13
+ return;
14
+ }
15
+ let exists = existsSync(src);
16
+ let stats = exists && statSync(src);
17
+ let isDirectory = exists && typeof stats != "boolean" && stats.isDirectory();
18
+ if (isDirectory) {
19
+ if (!existsSync(dest))
20
+ mkdirSync(dest, { recursive: true });
21
+ readdirSync(src).forEach(function (childItemName) {
22
+ // recurse
23
+ copyFilesSync(join(src, childItemName), join(dest, childItemName), override, ctx);
24
+ });
25
+ }
26
+ else if (override || !existsSync(dest)) {
27
+ if (ctx) {
28
+ ctx.count += 1;
29
+ }
30
+ copyFileSync(src, dest);
31
+ }
32
+ };
plugins/common/license.js ADDED
@@ -0,0 +1,191 @@
1
+ import { spawn } from "child_process";
2
+ import https from 'https';
3
+ import http from 'http';
4
+
5
+ const port = 8424;
6
+ const licenseServerUrl = `http://localhost:${port}/api/license`;
7
+ const projectIdentifierUrl = `http://localhost:${port}/api/public_key`;
8
+
9
+ /**
10
+ * Replace license string - used for webpack
11
+ * @param {string} code
12
+ * @param {{team:string|undefined}} opts
13
+ */
14
+ export async function replaceLicense(code, opts) {
15
+ const index = code.indexOf("NEEDLE_ENGINE_LICENSE_TYPE");
16
+ if (index >= 0) {
17
+ const licenseType = await resolveLicense(opts);
18
+ if (!licenseType) {
19
+ return code;
20
+ }
21
+ const end = code.indexOf(";", index);
22
+ if (end >= 0) {
23
+ const line = code.substring(index, end);
24
+ const replaced = "NEEDLE_ENGINE_LICENSE_TYPE = \"" + licenseType + "\"";
25
+ code = code.replace(line, replaced);
26
+ return code;
27
+ }
28
+ }
29
+ return code;
30
+ }
31
+
32
+
33
+ /**
34
+ * Resolve the license using the needle engine licensing server
35
+ * @param {{accessToken?:string, team?:string} | null} args
36
+ * @returns {Promise<string | null>}
37
+ */
38
+ export async function resolveLicense(args = null) {
39
+ // Wait for the server to start
40
+ await waitForLicenseServer();
41
+
42
+ let accessToken = args?.accessToken;
43
+ if (process.env.NEEDLE_CLOUD_TOKEN) {
44
+ console.log("INFO: Using Needle Cloud access token from environment variable");
45
+ accessToken = process.env.NEEDLE_CLOUD_TOKEN;
46
+ }
47
+
48
+ const url = new URL(licenseServerUrl);
49
+ if (args?.team)
50
+ url.searchParams.append("org", args.team);
51
+ if (accessToken) {
52
+ url.searchParams.append("token", accessToken);
53
+ }
54
+
55
+ console.log(`INFO: Fetching license...`);
56
+ const licenseResponse = await fetch(url.toString(), {
57
+ method: "GET",
58
+ }).catch(console.error);
59
+ if (!licenseResponse) {
60
+ console.warn("WARN: Failed to fetch license");
61
+ return null;
62
+ }
63
+
64
+ try {
65
+ /** @type {{needle_engine_license:string}} */
66
+ const licenseJson = JSON.parse(licenseResponse);
67
+ console.log("\n");
68
+ if (licenseJson.needle_engine_license) {
69
+ console.log(`INFO: Successfully received \"${licenseJson.needle_engine_license?.toUpperCase()}\" license`)
70
+ return licenseJson.needle_engine_license;
71
+ }
72
+ if ("error" in licenseJson) {
73
+ console.error(`ERROR in license check: \"${licenseJson.error}\"`);
74
+ }
75
+ else if (licenseJson.needle_engine_license == null) {
76
+ return null;
77
+ }
78
+ else {
79
+ console.warn("WARN: Received invalid license.", licenseJson);
80
+ }
81
+ return null;
82
+ }
83
+ catch (err) {
84
+ console.error("ERROR: Failed to parse license response");
85
+ return null;
86
+ }
87
+ }
88
+
89
+
90
+
91
+ /**
92
+ * @param {string | undefined} project_id
93
+ */
94
+ export async function getPublicIdentifier(project_id) {
95
+ // Wait for the server to start
96
+ await waitForLicenseServer();
97
+
98
+ const res = await fetch(projectIdentifierUrl, {
99
+ method: "GET",
100
+ });
101
+
102
+ if (!res) {
103
+ console.warn("WARN: Failed to fetch project identifier");
104
+ return null;
105
+ }
106
+
107
+ try {
108
+ /** @type {{public_key:string}} */
109
+ const json = JSON.parse(res);
110
+ return json.public_key;
111
+ }
112
+ catch (err) {
113
+ // TODO: report error to backend
114
+ return null;
115
+ }
116
+ };
117
+
118
+
119
+
120
+ // If we run the build command without an editor and the license server is just being started
121
+ // we need to to wait for the root URL to return a response
122
+ async function waitForLicenseServer() {
123
+ // Make sure the licensing server is running
124
+ runCommand("npx", ["--yes", "needle-cloud@main", "start-server"]);
125
+
126
+ let attempts = 0;
127
+ while (attempts < 10) {
128
+ const response = await fetch(licenseServerUrl, {
129
+ method: "GET",
130
+ }).catch(() => { /** ignore errors */ });
131
+ if (response) {
132
+ return true;
133
+ }
134
+ if (attempts === 0) {
135
+ console.log("INFO: Waiting for license server to start...");
136
+ }
137
+ attempts++;
138
+ await new Promise(res => setTimeout(res, 1000));
139
+ }
140
+ return false;
141
+ }
142
+
143
+
144
+ /**
145
+ * @param {string} processName
146
+ * @param {string[]} args
147
+ */
148
+ async function runCommand(processName, args) {
149
+ const process = spawn(processName, [...args], { shell: true, timeout: 30_000 });
150
+ return new Promise((resolve, reject) => {
151
+ process.on('close', (code) => {
152
+ if (code === 0) {
153
+ resolve(true);
154
+ } else {
155
+ console.warn(`WARN: ${processName} exited with code ${code}`);
156
+ resolve(false);
157
+ }
158
+ });
159
+ process.on('error', (err) => {
160
+ resolve(err);
161
+ });
162
+ });
163
+ }
164
+
165
+
166
+ /**
167
+ * @param {string} str
168
+ */
169
+ function obscure(str) {
170
+ const start = str.substring(0, 3);
171
+ return start + "******";
172
+ }
173
+
174
+
175
+ // NODE 16 doesn't support fetch yet
176
+ function fetch(url, options) {
177
+ const module = url.startsWith("https") ? https : http;
178
+ return new Promise((resolve, reject) => {
179
+ module.get(url, options, (res) => {
180
+ let data = '';
181
+ res.on('data', (chunk) => {
182
+ data += chunk;
183
+ });
184
+ res.on('end', () => {
185
+ resolve(data);
186
+ });
187
+ }).on("error", (err) => {
188
+ reject(err);
189
+ });
190
+ });
191
+ }
plugins/common/timers.js ADDED
@@ -0,0 +1,8 @@
1
+
2
+
3
+ /**
4
+ * @param {number} ms
5
+ */
6
+ export function delay(ms) {
7
+ return new Promise(res => setTimeout(res, ms));
8
+ }
src/engine/engine_networking_blob.ts ADDED
@@ -0,0 +1,254 @@
1
+ // workaround for this is not a function when deployed
2
+ // see https://github.com/pvorb/node-md5/issues/52
3
+ import md5 from "md5";
4
+
5
+ import { showBalloonWarning } from "./debug/index.js";
6
+ import { hasCommercialLicense } from "./engine_license.js";
7
+ import { delay } from "./engine_utils.js";
8
+
9
+
10
+ export namespace BlobStorage {
11
+
12
+ const maxSizeInMB = 50;
13
+ const maxFreeSizeInMB = 5;
14
+
15
+ /** The base url for the blob storage.
16
+ * The expected endpoints are:
17
+ * - POST `/api/needle/blob` - to request a new upload url
18
+ */
19
+ export const baseUrl: string | undefined = "https://networking.needle.tools";
20
+
21
+ /**
22
+ * Generates an md5 hash from a given buffer
23
+ * @param buffer The buffer to hash
24
+ * @returns The md5 hash
25
+ */
26
+ export function hashMD5(buffer: ArrayBuffer): string {
27
+ return md5(new Uint8Array(buffer))
28
+ }
29
+ export function hashMD5_Base64(buffer: ArrayBuffer): string {
30
+ const bytes = md5(new Uint8Array(buffer), { encoding: "binary", asBytes: true });
31
+ return btoa(String.fromCharCode(...bytes));
32
+ }
33
+ export function hashSha256(buffer: ArrayBuffer): Promise<string> {
34
+ const bytes = new Uint8Array(buffer);
35
+ const hash = crypto.subtle.digest('SHA-256', bytes).then(res => {
36
+ return btoa(String.fromCharCode(...new Uint8Array(res)));
37
+ })
38
+ return hash;
39
+ }
40
+
41
+ export type Upload_Result = {
42
+ readonly key: string | null;
43
+ readonly success: boolean;
44
+ readonly download_url: string | null;
45
+ }
46
+
47
+ /**
48
+ * Checks if the current user can upload a file of the given size
49
+ * @param info The file info
50
+ */
51
+ export function canUpload(info: { filesize: number }) {
52
+ const sizeInMB = info.filesize / 1024 / 1024;
53
+ if (hasCommercialLicense()) {
54
+ return sizeInMB < maxSizeInMB;
55
+ }
56
+ return sizeInMB < maxFreeSizeInMB;
57
+ }
58
+
59
+ declare type UploadResponse = {
60
+ error: string
61
+ } | {
62
+ key: string,
63
+ download: string,
64
+ upload?: string,
65
+ }
66
+ declare type CustomFile = {
67
+ name: string;
68
+ data: ArrayBuffer;
69
+ type?: string;
70
+ }
71
+ declare type UploadOptions = {
72
+ /** Allows to abort the upload. See AbortController */
73
+ abort?: AbortSignal;
74
+ /** When set to `true` no balloon messages will be displayed on screen */
75
+ silent?: boolean;
76
+ /** Called when the upload starts and is finished */
77
+ onProgress?: (progress: { progress01: number, state: "inprogress" | "finished" }) => void;
78
+ }
79
+ export async function upload(file: CustomFile, opts?: UploadOptions): Promise<Upload_Result | null>;
80
+ export async function upload(file: File, opts?: UploadOptions): Promise<Upload_Result | null>;
81
+ export async function upload(file: File | CustomFile, opts?: UploadOptions): Promise<Upload_Result | null> {
82
+
83
+ const _baseUrl = baseUrl;
84
+ if (!_baseUrl) {
85
+ console.error("Blob storage base url is not set");
86
+ return null;
87
+ }
88
+ else if (!file.name) {
89
+ console.error("Upload: file name is missing");
90
+ return null;
91
+ }
92
+
93
+ let arrayBuffer: ArrayBuffer | null = null;
94
+ if (file instanceof File) {
95
+ arrayBuffer = await file.arrayBuffer();
96
+ } else {
97
+ arrayBuffer = file.data;
98
+ }
99
+
100
+ const filesize = arrayBuffer.byteLength;
101
+ const filesizeInMB = filesize / 1024 / 1024;
102
+
103
+ if (filesizeInMB > maxSizeInMB) {
104
+ if (opts?.silent !== true) showBalloonWarning(`File (${filesizeInMB.toFixed(1)}MB) is too large for uploading (see console for details)`);
105
+ console.warn(`Your file is too large for uploading (${filesizeInMB.toFixed(1)}MB). Max allowed size is ${maxSizeInMB}MB`);
106
+ return null;
107
+ }
108
+ else if (!hasCommercialLicense() && filesizeInMB > maxFreeSizeInMB) {
109
+ if (opts?.silent !== true) showBalloonWarning(`File is too large for uploading. Please get a <a href=\"https://needle.tools/pricing\" target=\"_blank\">commercial license</a> to upload files larger than 5MB`);
110
+ console.warn(`Your file is too large for uploading (${filesizeInMB.toFixed(1)}MB). Max size is 5MB for non-commercial users. Please get a commercial license at https://needle.tools/pricing for larger files (up to 50MB)`);
111
+ return null;
112
+ }
113
+ else if (filesize < 1) {
114
+ console.warn(`Your file is too small for uploading (${filesizeInMB.toFixed(1)}MB). Min size is 1 byte`);
115
+ return null;
116
+ }
117
+
118
+ const hash = hashMD5_Base64(arrayBuffer);
119
+
120
+ const headers = {
121
+ filename: file.name,
122
+ "Content-Md5": hash,
123
+ // "x-amz-checksum-sha256": checksum,
124
+ // "X-Amz-Content-Sha256": checksum,
125
+ "Content-Type": file.type || "application/octet-stream",
126
+ "FileSize": filesize.toString(),
127
+ // enforced by the server
128
+ "Content-Disposition": `attachment; filename=\"${file.name}\"`,
129
+ // enforced by the server
130
+ "x-amz-server-side-encryption": "AES256",
131
+ }
132
+
133
+ const uploadResult = await fetch(_baseUrl + "/api/needle/blob", {
134
+ method: "POST",
135
+ headers,
136
+ signal: opts?.abort,
137
+ })
138
+ .then(res => res.json())
139
+ .catch(err => {
140
+ console.error(err);
141
+ return null;
142
+ }) as UploadResponse | null;
143
+
144
+ if (uploadResult == null) {
145
+ console.warn("Upload failed...");
146
+ return null;
147
+ }
148
+ else if ("error" in uploadResult) {
149
+ console.error(uploadResult.error);
150
+ return null;
151
+ }
152
+ // If the server responded with a upload url, we can now upload the file
153
+ else if ("upload" in uploadResult && uploadResult.upload) {
154
+ console.debug("Uploading file", uploadResult.upload);
155
+ let didUpload = false;
156
+ let error: Error | null = null;
157
+ // try uploading the file 5 times
158
+ for (let i = 0; i < 3; i++) {
159
+ try {
160
+ if (didUpload) break;
161
+ if (opts?.abort?.aborted) {
162
+ console.debug("Aborted upload");
163
+ return null;
164
+ }
165
+ const res = await tryUpload(uploadResult.upload);
166
+ if (res instanceof Error) {
167
+ error = res;
168
+ await delay(1000 * i);
169
+ }
170
+ else if (res.ok) {
171
+ console.debug("File uploaded successfully");
172
+ didUpload = true;
173
+ }
174
+ } catch (err) {
175
+ console.error(err);
176
+ }
177
+ }
178
+ if (!didUpload) {
179
+ console.error(error?.message || "Failed to upload file");
180
+ return null;
181
+ }
182
+
183
+ function tryUpload(url: string): Promise<Response | Error> {
184
+ opts?.onProgress?.call(null, { progress01: 0, state: "inprogress" });
185
+ const uploadRes = fetch(url, {
186
+ method: "PUT",
187
+ headers,
188
+ body: arrayBuffer,
189
+ signal: opts?.abort,
190
+ })
191
+ .then(res => {
192
+ opts?.onProgress?.call(null, { progress01: 1, state: "finished" });
193
+ return res;
194
+ })
195
+ .catch(err => {
196
+ return err as Error;
197
+ });
198
+ return uploadRes;
199
+ }
200
+ }
201
+
202
+ // Provide the download url to the caller
203
+ if ("download" in uploadResult) {
204
+ const downloadUrl = _baseUrl + uploadResult.download;
205
+ console.debug("File found in blob storage", downloadUrl);
206
+ return {
207
+ key: uploadResult.key,
208
+ success: true,
209
+ download_url: downloadUrl,
210
+ }
211
+ }
212
+
213
+
214
+ return null;
215
+ }
216
+
217
+ export function getBlobUrlForKey(key: string) {
218
+ return `${baseUrl}/api/needle/blob/${key}`;
219
+ }
220
+
221
+ export async function download(url: string, progressCallback?: (prog: ProgressEvent) => void): Promise<Uint8Array | null> {
222
+ const response = await fetch(url);
223
+
224
+ const reader = response.body?.getReader();
225
+ const contentLength = response.headers.get('Content-Length');
226
+ const total = contentLength ? parseInt(contentLength) : 0;
227
+
228
+ if (!reader) return null;
229
+
230
+ let received: number = 0;
231
+ const chunks: Uint8Array[] = [];
232
+ while (true) {
233
+ const { done, value } = await reader.read();
234
+ if (value) {
235
+ chunks.push(value);
236
+ received += value.length;
237
+ progressCallback?.call(null, new ProgressEvent('progress', { loaded: received, total: total }));
238
+ }
239
+
240
+ if (done) {
241
+ break;
242
+ }
243
+ }
244
+ const final = new Uint8Array(received);
245
+ let position = 0;
246
+ for (const chunk of chunks) {
247
+ final.set(chunk, position);
248
+ position += chunk.length;
249
+ }
250
+ return final;
251
+ }
252
+ }
253
+
254
+
src/engine/analytics/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { LCP } from "./lcp.js";
2
+
3
+ /**
4
+ * @param args - The arguments to initialize the performance analytics with.
5
+ */
6
+ export module NeedleEnginePerformanceAnalytics {
7
+ export function init(...args: Array<"lcp">) {
8
+ if (args.includes("lcp"))
9
+ LCP.observe();
10
+ }
11
+ }
src/engine/analytics/lcp.ts ADDED
@@ -0,0 +1,35 @@
1
+
2
+ // https://web.dev/articles/lcp
3
+ export namespace LCP {
4
+ export function observe() {
5
+ const observer = new PerformanceObserver((list) => {
6
+ const perfEntries = list.getEntries();
7
+ const lastEntry = perfEntries[perfEntries.length - 1] as PerformanceEntry & { url?: string };
8
+ // Process the latest candidate for largest contentful paint
9
+ console.log('LCP candidate:', lastEntry);
10
+
11
+ const el = createElementFromUrl(lastEntry.url);
12
+ if (el) document.body.appendChild(el);
13
+ });
14
+ observer.observe({ entryTypes: ['largest-contentful-paint'] });
15
+ }
16
+
17
+
18
+ function createElementFromUrl(str: string | null | undefined) {
19
+ if (!str) return null;
20
+
21
+ if (!str.startsWith('data:image/')) {
22
+ return null;
23
+ }
24
+ else if (!str.includes("base64")) {
25
+ return null;
26
+ }
27
+ const img = document.createElement('img');
28
+ img.src = str;
29
+ img.onerror = err => {
30
+ console.error(err);
31
+ img.remove();
32
+ };
33
+ return img;
34
+ }
35
+ }