@@ -37,6 +37,4 @@
|
|
37
37
|
}
|
38
38
|
}
|
39
39
|
|
40
|
-
console.log(config.resolve.alias);
|
41
|
-
|
42
40
|
}
|
@@ -7,7 +7,7 @@
|
|
7
7
|
if (needleConfig.codegenDirectory) {
|
8
8
|
const metaPath = workingDirectory + "/" + needleConfig.codegenDirectory + "/meta.json";
|
9
9
|
|
10
|
-
/**@type {import("../types").
|
10
|
+
/**@type {import("../types").needleMeta} */
|
11
11
|
const meta = require(metaPath);
|
12
12
|
return meta;
|
13
13
|
}
|
@@ -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
|
-
}
|
@@ -1,5 +1,21 @@
|
|
1
|
-
const {
|
1
|
+
const { getMeta } = require("../common/config.cjs");
|
2
2
|
|
3
|
-
|
4
|
-
|
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
|
};
|
@@ -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
|
-
|
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
|
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
|
-
|
150
|
+
|
151
|
+
if (existsSync(fullpath)) {
|
141
152
|
aliasDict[name] = (packageName, index, path) => {
|
142
153
|
if (cb !== null && !isDevEnvironment) {
|
143
|
-
const overrideResult = cb(
|
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
|
-
|
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
|
}
|
@@ -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: {
|
@@ -1,21 +1,85 @@
|
|
1
|
-
import { ChildProcess, exec
|
2
|
-
import { getOutputDirectory, loadConfig
|
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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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:
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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 (
|
108
|
-
|
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
|
225
|
+
await delay(500);
|
113
226
|
const outputDirectory = getOutputDirectory() + "/assets";
|
114
|
-
|
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 (
|
236
|
+
if (maxOutputDirectoryCreatedWaitTime != 0 && Date.now() > maxOutputDirectoryCreatedWaitTime) {
|
118
237
|
return Promise.resolve(false);
|
119
238
|
}
|
120
|
-
if (
|
121
|
-
|
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(
|
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
|
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
|
256
|
+
let proc = null;
|
134
257
|
|
135
|
-
|
136
|
-
|
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
|
-
|
316
|
+
proc = exec(cmd, { cwd: installPath });
|
139
317
|
}
|
140
318
|
else {
|
141
|
-
const version = opts.
|
142
|
-
const cmd = `npx --yes @needle-tools/gltf-build-pipeline@${version} transform "${outputDirectory}"`;
|
143
|
-
log(
|
144
|
-
|
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
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
-
|
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
|
-
}
|
@@ -16,11 +16,11 @@
|
|
16
16
|
totalsize: 0,
|
17
17
|
files: []
|
18
18
|
};
|
19
|
-
console.log(
|
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).
|
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(
|
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));
|
@@ -1,32 +1,38 @@
|
|
1
1
|
import { createBuildInfoFile } from '../common/buildinfo.js';
|
2
|
-
import {
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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
|
}
|
@@ -4,7 +4,8 @@
|
|
4
4
|
let didLogCanNotFindConfig = false;
|
5
5
|
|
6
6
|
/** the codegen meta file
|
7
|
-
* @
|
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
|
|
@@ -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(
|
25
|
+
return run("start", config);
|
20
26
|
},
|
21
27
|
closeBundle() {
|
22
|
-
return run(
|
28
|
+
return run("end", config);
|
23
29
|
},
|
24
30
|
}
|
25
31
|
}
|
26
32
|
|
27
|
-
|
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
|
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
|
-
|
62
|
+
copyFilesSync(engineIncludeDir, projectIncludeDir);
|
52
63
|
}
|
53
64
|
}
|
54
65
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
}
|
66
|
+
const outDir = resolve(baseDir, outdirName);
|
67
|
+
if (!existsSync(outDir)) {
|
68
|
+
mkdirSync(outDir);
|
69
|
+
}
|
60
70
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
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
|
-
|
92
|
-
|
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
|
+
}
|
@@ -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
|
}
|
@@ -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
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
+
}
|
@@ -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",
|
@@ -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
|
|
@@ -1,11 +1,11 @@
|
|
1
|
-
import { resolveLicense } from '../common/license.
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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");
|
@@ -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;
|
@@ -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:
|
83
|
+
children: content,
|
83
84
|
injectTo: 'body',
|
84
85
|
},
|
85
86
|
]
|
@@ -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";
|
@@ -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";
|
@@ -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
|
104
|
-
|
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);
|
@@ -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
|
|
@@ -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";
|
@@ -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
|
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
|
-
|
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("
|
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("
|
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
|
-
|
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)
|
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)
|
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 =
|
202
|
+
private handlePaste = (evt: Event) => {
|
138
203
|
if (this.context.connection.allowEditing === false) return;
|
139
|
-
|
140
|
-
const
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
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
|
-
|
225
|
-
|
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
|
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
|
-
|
246
|
-
const res = await
|
247
|
-
|
248
|
-
|
249
|
-
|
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
|
-
|
257
|
-
|
258
|
-
if (
|
259
|
-
|
260
|
-
|
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
|
-
|
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(
|
424
|
+
AnimationUtils.assignAnimationsFromFile(model, {
|
305
425
|
createAnimationComponent: obj => addComponent(obj, Animation)
|
306
426
|
});
|
307
427
|
|
308
|
-
|
309
|
-
|
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
|
-
|
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
|
316
|
-
point: obj.
|
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
|
}
|
@@ -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;
|
@@ -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<
|
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;
|
@@ -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:
|
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;
|
@@ -1,6 +1,6 @@
|
|
1
1
|
import { Object3D, Scene } from "three";
|
2
2
|
|
3
|
-
import {
|
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(
|
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(
|
70
|
+
ComponentLifecycleEvents.dispatchComponentLifecycleEvent("component-added", componentInstance);
|
71
71
|
}
|
72
72
|
catch (err) {
|
73
73
|
console.error(err);
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { type IComponent, type IContext, type
|
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?:
|
29
|
+
files?: LoadedModel[]
|
30
30
|
}
|
31
31
|
|
32
32
|
export type ContextCallback = (evt: ContextEventArgs) => void | Promise<any> | IComponent;
|
@@ -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,
|
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:
|
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<
|
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<
|
1015
|
-
const results = new Array<
|
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);
|
@@ -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 {
|
12
|
-
import { Context, ContextCreateArgs } from "./engine_setup.js";
|
13
|
-
import { type INeedleEngineComponent, type
|
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(
|
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,
|
283
|
-
if (debug) console.log("attributeChangedCallback", name,
|
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 \"",
|
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<
|
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 (
|
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
|
*/
|
@@ -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 {
|
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(
|
483
|
+
ComponentLifecycleEvents.dispatchComponentLifecycleEvent("component-added", copy);
|
484
484
|
}
|
485
485
|
}
|
486
486
|
|
@@ -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<
|
13
|
-
loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (prog: ProgressEvent) => void): Promise<
|
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;
|
@@ -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() {
|
@@ -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)
|
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);
|
@@ -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
|
-
|
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
|
}
|
@@ -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
|
-
|
98
|
-
|
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
|
-
|
111
|
-
if (guid
|
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
|
-
|
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
|
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
|
-
|
265
|
+
const res = fn?.call(this, value, oldValue);
|
266
|
+
if (res !== false) {
|
267
267
|
getSyncer(this)?.notifyChanged(propertyKey, value);
|
268
268
|
}
|
269
269
|
}
|
@@ -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
|
-
|
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
|
|
@@ -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
|
-
|
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
|
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.
|
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
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
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
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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(
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
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
|
+
}
|
@@ -556,7 +556,7 @@
|
|
556
556
|
}
|
557
557
|
}
|
558
558
|
|
559
|
-
private _tickIntervalId?:
|
559
|
+
private _tickIntervalId?: any; /* for webpack */
|
560
560
|
|
561
561
|
private tick = () => {
|
562
562
|
this.updateSendingCalls();
|
@@ -1,5 +1,5 @@
|
|
1
1
|
const defaultNetworkingBackendUrlProvider = "https://urls.needle.tools/default-networking-backend/index";
|
2
|
-
let
|
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
|
492
|
-
|
493
|
-
|
494
|
-
|
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
|
-
|
514
|
+
networkingServerUrl = overrideUrl;
|
497
515
|
}
|
498
516
|
else if (isHostedOnGlitch()) {
|
499
|
-
|
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 (
|
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
|
-
|
561
|
+
networkingServerUrl = await defaultUrlResponse.text();
|
543
562
|
if (failed) return;
|
544
563
|
}
|
545
564
|
|
546
|
-
if (
|
565
|
+
if (networkingServerUrl === undefined) {
|
547
566
|
resolve(false);
|
548
567
|
return;
|
549
568
|
}
|
550
569
|
|
551
|
-
console.debug("⊡ Connecting to networking backend on\n" +
|
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(
|
557
|
-
.withMaxRetries(
|
558
|
-
.withBackoff(new ExponentialBackoff(2000,
|
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" +
|
564
|
-
else console.debug("⊞ Connected to networking backend",
|
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
|
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);
|
@@ -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 {
|
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
|
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<
|
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<
|
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(
|
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
|
-
|
109
|
-
|
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("
|
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
|
-
|
126
|
-
|
127
|
-
|
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<
|
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
|
-
|
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<
|
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
|
}
|
@@ -263,12 +263,12 @@
|
|
263
263
|
super([EventList]);
|
264
264
|
}
|
265
265
|
|
266
|
-
onSerialize(_data: EventList
|
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
|
358
|
+
const evt = new EventList(fns);
|
359
359
|
|
360
360
|
if (debugExtension)
|
361
361
|
console.log(evt);
|
@@ -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
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
41
|
+
/** A loaded model */
|
42
|
+
export type LoadedModel = {
|
35
43
|
src: string;
|
36
|
-
file:
|
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;
|
@@ -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
|
-
}
|
@@ -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 }
|
@@ -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:
|
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:
|
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
|
+
|
@@ -8,7 +8,7 @@
|
|
8
8
|
@serializable()
|
9
9
|
eventID!: EventType;
|
10
10
|
@serializable(EventList)
|
11
|
-
callback
|
11
|
+
callback: EventList<any> = new EventList();
|
12
12
|
}
|
13
13
|
|
14
14
|
/**
|
@@ -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;
|
@@ -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
|
-
|
9
|
-
|
10
|
-
|
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
|
}
|
@@ -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.
|
95
|
-
img.style.width = "33px";
|
104
|
+
img.style.width = "140px";
|
96
105
|
img.style.height = "auto";
|
97
|
-
img.style.
|
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:
|
105
|
-
{ opacity: .
|
106
|
-
{ opacity:
|
116
|
+
{ opacity: .2 },
|
117
|
+
{ opacity: .7 },
|
118
|
+
{ opacity: .2 }
|
107
119
|
], {
|
108
120
|
duration: 3000,
|
109
121
|
iterations: Infinity
|
@@ -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%;
|
@@ -9,7 +9,7 @@
|
|
9
9
|
generator: string | null,
|
10
10
|
gzip: boolean,
|
11
11
|
allowHotReload: boolean,
|
12
|
-
license: License |
|
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
|
}
|
@@ -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.
|
@@ -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
|
125
|
+
* @default false
|
126
126
|
*/
|
127
127
|
@serializable()
|
128
|
-
enableKeys: boolean =
|
128
|
+
enableKeys: boolean = false;
|
129
129
|
/** When enabled the camera movement will be damped
|
130
130
|
* @default true
|
131
131
|
*/
|
@@ -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>;
|
@@ -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);
|
@@ -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;
|
@@ -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.
|
@@ -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);
|
@@ -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
|
|
@@ -339,7 +339,7 @@
|
|
339
339
|
}
|
340
340
|
}
|
341
341
|
|
342
|
-
private onAvatarSpawned = (instance:
|
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);
|
@@ -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
|
+
};
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|
@@ -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
|
+
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|