dsh-3d-viewer 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -107
- package/README.zh.md +180 -107
- package/THIRD_PARTY_NOTICES.md +54 -52
- package/assets/hdri/LICENSE.md +17 -16
- package/dist/client.fbx.js +14 -0
- package/dist/client.gltf.js +14 -0
- package/dist/client.js +4472 -0
- package/dist/client.obj.js +19 -0
- package/dist/client.stl.js +14 -0
- package/dist/client.tex-exr.js +14 -0
- package/dist/client.tex-hdr.js +18 -0
- package/dist/client.tex-tga.js +14 -0
- package/index.js +167 -7
- package/package.json +72 -48
- package/assets/preview.png +0 -0
- package/build.mjs +0 -49
- package/client.js +0 -4462
- package/scripts/embed-hdri.mjs +0 -70
- package/src/bytes.js +0 -158
- package/src/client.js +0 -57
- package/src/formats.js +0 -100
- package/src/generated/hdri-data.js +0 -8
- package/src/loaders/gltf.js +0 -251
- package/src/loaders/model-loader.js +0 -232
- package/src/loaders/workspace.js +0 -295
- package/src/model.js +0 -417
- package/src/scene/animation.js +0 -149
- package/src/scene/blue-noise.js +0 -48
- package/src/scene/camera.js +0 -83
- package/src/scene/createSceneView.js +0 -375
- package/src/scene/environment.js +0 -187
- package/src/scene/ground.js +0 -286
- package/src/scene/lighting.js +0 -41
- package/src/scene/materials.js +0 -263
- package/src/scene/postprocessing.js +0 -155
- package/src/scene/render-loop.js +0 -98
- package/src/scene/viewer-error.js +0 -16
- package/src/ui/AnimationBar.js +0 -95
- package/src/ui/ModelViewer.js +0 -278
- package/src/ui/ViewerSurface.js +0 -87
- package/src/ui/chrome.js +0 -220
- package/src/ui/dictionaries.js +0 -99
- package/src/ui/icons.js +0 -88
- package/src/ui/options.js +0 -23
- package/src/ui/primitives.js +0 -88
- package/src/ui/styles.js +0 -89
- package/src/vendor/FBXLoaderPbr.js +0 -4772
package/src/loaders/workspace.js
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import { Cache, ImageLoader, Loader, LoadingManager, Texture } from 'three';
|
|
2
|
-
import {
|
|
3
|
-
ABSOLUTE_PATH,
|
|
4
|
-
abortError,
|
|
5
|
-
decodeLocalPath,
|
|
6
|
-
isLocalPath,
|
|
7
|
-
mimeOf,
|
|
8
|
-
warn,
|
|
9
|
-
} from '../bytes.js';
|
|
10
|
-
|
|
11
|
-
const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
|
|
12
|
-
// Any relative/absolute local path can be an MTL/FBX texture. Schemes are
|
|
13
|
-
// excluded so data/blob/http URLs keep using Three's normal loaders.
|
|
14
|
-
const WORKSPACE_TEXTURE_PATH = /^(?!data:|blob:|https?:|file:|\/\/).+$/i;
|
|
15
|
-
const TEXTURE_TIMEOUT_MS = 5000;
|
|
16
|
-
|
|
17
|
-
// 1x1 opaque white PNG, used when a texture exists in the MTL/OBJ but its
|
|
18
|
-
// bytes cannot be read. White keeps the material color visible instead of
|
|
19
|
-
// turning the whole surface black.
|
|
20
|
-
export const WHITE_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==';
|
|
21
|
-
|
|
22
|
-
/** Shared image decoder; a LoadingManager per texture would be wasteful. */
|
|
23
|
-
const IMAGE_LOADER = new ImageLoader(new LoadingManager());
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Parse a `dsh-resource://file/...` address into its session and path.
|
|
27
|
-
*
|
|
28
|
-
* Mirrors the DSH workspace-path grammar. Query and fragment suffixes are
|
|
29
|
-
* ignored and each path segment is decoded.
|
|
30
|
-
*
|
|
31
|
-
* @param {unknown} address Candidate resource address.
|
|
32
|
-
* @returns {{ scope: 'session', sessionId: string, path: string } | undefined} Parsed session file address.
|
|
33
|
-
*/
|
|
34
|
-
export function parseFileAddress(address) {
|
|
35
|
-
if (typeof address !== 'string' || !address.startsWith(FILE_ADDRESS_PREFIX)) return undefined;
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const end = address.search(/[?#]/);
|
|
39
|
-
const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/');
|
|
40
|
-
if (scope !== 'session') return undefined;
|
|
41
|
-
const [id, ...segments] = rest;
|
|
42
|
-
if (id === undefined || id === '' || segments.length === 0) return undefined;
|
|
43
|
-
return {
|
|
44
|
-
scope: 'session',
|
|
45
|
-
sessionId: decodeURIComponent(id),
|
|
46
|
-
path: segments.map(decodeURIComponent).join('/'),
|
|
47
|
-
};
|
|
48
|
-
} catch {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Read a sibling workspace file through the DSH workspace-files Remote.
|
|
55
|
-
*
|
|
56
|
-
* @param {object | undefined} remote Client Remote face.
|
|
57
|
-
* @param {string} address Original document resource address.
|
|
58
|
-
* @param {string} path Workspace-relative or absolute target path.
|
|
59
|
-
* @param {{ baseFile?: string, signal?: AbortSignal }} [options] Base file for relative resolution and cancellation.
|
|
60
|
-
* @returns {Promise<{ ok: true, value: object } | { ok: false, error: { message: string } }>} Remote result.
|
|
61
|
-
*/
|
|
62
|
-
export async function readWorkspaceFile(remote, address, path, options = {}) {
|
|
63
|
-
const parsed = parseFileAddress(address);
|
|
64
|
-
if (parsed === undefined) {
|
|
65
|
-
return { ok: false, error: { message: `Not a session file address: ${String(address)}` } };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const workspaceFiles = remote?.workspaceFiles;
|
|
69
|
-
if (workspaceFiles === undefined || typeof workspaceFiles.readBytes !== 'function') {
|
|
70
|
-
return { ok: false, error: { message: 'The workspaceFiles Remote is unavailable.' } };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const request = {};
|
|
74
|
-
const baseFile = typeof options.baseFile === 'string' && options.baseFile !== ''
|
|
75
|
-
? options.baseFile
|
|
76
|
-
: parsed.path;
|
|
77
|
-
if (baseFile !== '') request.baseFile = baseFile;
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
return await workspaceFiles.readBytes(parsed.sessionId, path.replaceAll('\\', '/'), request, options.signal);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
return {
|
|
83
|
-
ok: false,
|
|
84
|
-
error: { message: error instanceof Error ? error.message : String(error) },
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Read a workspace asset, retrying the basename of an unreadable absolute path.
|
|
91
|
-
*
|
|
92
|
-
* FBX/OBJ/MTL exporters commonly bake in the authoring machine's absolute
|
|
93
|
-
* path. When that path does not exist in the session, the file usually sits
|
|
94
|
-
* next to the model, so the basename is tried before giving up.
|
|
95
|
-
*
|
|
96
|
-
* @param {(path: string) => Promise<object | undefined>} readFile Reads one workspace path.
|
|
97
|
-
* @param {string} path Raw or normalized asset path.
|
|
98
|
-
* @returns {Promise<object | undefined>} The first successful read result.
|
|
99
|
-
*/
|
|
100
|
-
export async function readWithBasenameFallback(readFile, path) {
|
|
101
|
-
const result = await readFile(path);
|
|
102
|
-
if (result !== undefined && result.ok === true) return result;
|
|
103
|
-
|
|
104
|
-
const normalized = String(path).replaceAll('\\', '/');
|
|
105
|
-
if (!ABSOLUTE_PATH.test(normalized)) return result;
|
|
106
|
-
|
|
107
|
-
const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
108
|
-
if (basename === '' || basename === normalized) return result;
|
|
109
|
-
return readFile(basename);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Load one image URL into a Texture placeholder.
|
|
114
|
-
*
|
|
115
|
-
* @param {Texture} texture Placeholder returned synchronously to Three.js.
|
|
116
|
-
* @param {string} url Image URL.
|
|
117
|
-
* @param {AbortSignal | undefined} signal Cancellation signal.
|
|
118
|
-
* @returns {Promise<void>} Resolves when the image is assigned.
|
|
119
|
-
*/
|
|
120
|
-
function loadImage(texture, url, signal) {
|
|
121
|
-
return new Promise((resolve, reject) => {
|
|
122
|
-
if (signal?.aborted === true) {
|
|
123
|
-
reject(abortError('Texture loading was cancelled.'));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
IMAGE_LOADER.load(url, (image) => {
|
|
128
|
-
texture.image = image;
|
|
129
|
-
texture.needsUpdate = true;
|
|
130
|
-
resolve();
|
|
131
|
-
}, undefined, reject);
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* A Three.js loader that resolves MTL/OBJ/FBX texture paths through the DSH
|
|
137
|
-
* workspace instead of HTTP.
|
|
138
|
-
*
|
|
139
|
-
* It returns a Texture immediately, like TextureLoader, and assigns the image
|
|
140
|
-
* when the workspace read and browser decode finish. Missing files fall back
|
|
141
|
-
* to a white pixel so the material color remains visible.
|
|
142
|
-
*/
|
|
143
|
-
class WorkspaceTextureLoader extends Loader {
|
|
144
|
-
constructor(manager, options) {
|
|
145
|
-
super(manager);
|
|
146
|
-
this.readFile = options.readFile;
|
|
147
|
-
this.signal = options.signal;
|
|
148
|
-
this.onWarn = options.onWarn;
|
|
149
|
-
this.pending = options.pending;
|
|
150
|
-
this.blobUrls = options.blobUrls;
|
|
151
|
-
this.listeners = options.listeners;
|
|
152
|
-
this.disposed = options.isDisposed;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
load(url, onLoad, onProgress, onError) {
|
|
156
|
-
const texture = new Texture();
|
|
157
|
-
texture.name = url;
|
|
158
|
-
|
|
159
|
-
const task = (async () => {
|
|
160
|
-
let blobUrl = null;
|
|
161
|
-
try {
|
|
162
|
-
if (this.signal?.aborted === true || this.disposed?.() === true) {
|
|
163
|
-
throw abortError('Texture loading was cancelled.');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let imageUrl = url;
|
|
167
|
-
if (isLocalPath(url)) {
|
|
168
|
-
const path = decodeLocalPath(url);
|
|
169
|
-
const result = await readWithBasenameFallback(this.readFile, path);
|
|
170
|
-
if (result === undefined || result.ok !== true) {
|
|
171
|
-
throw new Error(result?.error?.message ?? `Could not read texture ${url}`);
|
|
172
|
-
}
|
|
173
|
-
if (this.signal?.aborted === true || this.disposed?.() === true) {
|
|
174
|
-
throw abortError('Texture loading was cancelled.');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
blobUrl = URL.createObjectURL(new Blob([result.value.data], { type: mimeOf(path) }));
|
|
178
|
-
this.blobUrls.add(blobUrl);
|
|
179
|
-
imageUrl = blobUrl;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
await loadImage(texture, imageUrl, this.signal);
|
|
183
|
-
onLoad?.(texture);
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (blobUrl !== null && this.blobUrls.has(blobUrl)) {
|
|
186
|
-
URL.revokeObjectURL(blobUrl);
|
|
187
|
-
this.blobUrls.delete(blobUrl);
|
|
188
|
-
}
|
|
189
|
-
if (this.signal?.aborted !== true && this.disposed?.() !== true) {
|
|
190
|
-
this.onWarn?.(`[dsh-3d-viewer] Could not load texture "${url}": ${String(error)}`);
|
|
191
|
-
try {
|
|
192
|
-
await loadImage(texture, WHITE_PIXEL, this.signal);
|
|
193
|
-
} catch {
|
|
194
|
-
// Keep the empty placeholder when even the fallback cannot decode.
|
|
195
|
-
}
|
|
196
|
-
onLoad?.(texture);
|
|
197
|
-
onError?.(error);
|
|
198
|
-
}
|
|
199
|
-
} finally {
|
|
200
|
-
for (const listener of this.listeners) {
|
|
201
|
-
try {
|
|
202
|
-
listener(texture);
|
|
203
|
-
} catch {
|
|
204
|
-
// A render request must never break texture loading.
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
})();
|
|
209
|
-
|
|
210
|
-
this.pending.add(task);
|
|
211
|
-
task.then(
|
|
212
|
-
() => this.pending.delete(task),
|
|
213
|
-
() => this.pending.delete(task),
|
|
214
|
-
);
|
|
215
|
-
return texture;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Create a workspace-backed asset loader and its Three.js LoadingManager.
|
|
221
|
-
*
|
|
222
|
-
* @param {{
|
|
223
|
-
* readFile: (path: string) => Promise<object | undefined>,
|
|
224
|
-
* signal?: AbortSignal,
|
|
225
|
-
* onWarn?: (message: string) => void,
|
|
226
|
-
* }} options Workspace reader, cancellation, and diagnostics.
|
|
227
|
-
* @returns {{
|
|
228
|
-
* manager: LoadingManager,
|
|
229
|
-
* settle: (timeout?: number) => Promise<void>,
|
|
230
|
-
* onTextureLoad: (listener: (texture: Texture) => void) => () => void,
|
|
231
|
-
* dispose: () => void,
|
|
232
|
-
* }} Asset loader controls.
|
|
233
|
-
*/
|
|
234
|
-
export function createWorkspaceAssetLoader({ readFile, signal, onWarn = warn }) {
|
|
235
|
-
const pending = new Set();
|
|
236
|
-
const blobUrls = new Set();
|
|
237
|
-
const listeners = new Set();
|
|
238
|
-
let disposed = false;
|
|
239
|
-
|
|
240
|
-
const manager = new LoadingManager();
|
|
241
|
-
const textureLoader = new WorkspaceTextureLoader(manager, {
|
|
242
|
-
readFile,
|
|
243
|
-
signal,
|
|
244
|
-
onWarn,
|
|
245
|
-
pending,
|
|
246
|
-
blobUrls,
|
|
247
|
-
listeners,
|
|
248
|
-
isDisposed: () => disposed,
|
|
249
|
-
});
|
|
250
|
-
manager.addHandler(WORKSPACE_TEXTURE_PATH, textureLoader);
|
|
251
|
-
|
|
252
|
-
async function settle(timeout = TEXTURE_TIMEOUT_MS) {
|
|
253
|
-
if (pending.size === 0) return;
|
|
254
|
-
if (signal?.aborted === true) return;
|
|
255
|
-
|
|
256
|
-
let timer;
|
|
257
|
-
let stopWaiting;
|
|
258
|
-
const timeoutPromise = new Promise((resolve) => {
|
|
259
|
-
timer = setTimeout(resolve, timeout);
|
|
260
|
-
});
|
|
261
|
-
const abortPromise = new Promise((resolve) => {
|
|
262
|
-
stopWaiting = resolve;
|
|
263
|
-
signal?.addEventListener('abort', resolve, { once: true });
|
|
264
|
-
});
|
|
265
|
-
try {
|
|
266
|
-
await Promise.race([Promise.allSettled([...pending]), timeoutPromise, abortPromise]);
|
|
267
|
-
} finally {
|
|
268
|
-
clearTimeout(timer);
|
|
269
|
-
signal?.removeEventListener('abort', stopWaiting);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function onTextureLoad(listener) {
|
|
274
|
-
listeners.add(listener);
|
|
275
|
-
return () => listeners.delete(listener);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function dispose() {
|
|
279
|
-
if (disposed) return;
|
|
280
|
-
disposed = true;
|
|
281
|
-
for (const url of blobUrls) {
|
|
282
|
-
Cache.remove(`image:${url}`);
|
|
283
|
-
URL.revokeObjectURL(url);
|
|
284
|
-
}
|
|
285
|
-
blobUrls.clear();
|
|
286
|
-
listeners.clear();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return {
|
|
290
|
-
manager,
|
|
291
|
-
settle,
|
|
292
|
-
onTextureLoad,
|
|
293
|
-
dispose,
|
|
294
|
-
};
|
|
295
|
-
}
|
package/src/model.js
DELETED
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Box3,
|
|
3
|
-
DoubleSide,
|
|
4
|
-
Mesh,
|
|
5
|
-
MeshStandardMaterial,
|
|
6
|
-
Vector3,
|
|
7
|
-
} from 'three';
|
|
8
|
-
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
9
|
-
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
10
|
-
import { decodeText, toArrayBuffer, warn } from './bytes.js';
|
|
11
|
-
import { FBXLoader } from './vendor/FBXLoaderPbr.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Parse one complete FBX file with Three.js.
|
|
15
|
-
*
|
|
16
|
-
* Texture loads are asynchronous and optional. A missing sidecar texture
|
|
17
|
-
* must not reject a model whose geometry is perfectly readable, so the
|
|
18
|
-
* loading manager only logs those failures and the scene is returned as
|
|
19
|
-
* soon as its graph exists.
|
|
20
|
-
*
|
|
21
|
-
* @param {Uint8Array | ArrayBuffer} data Complete file bytes.
|
|
22
|
-
* @returns {import('three').Object3D} The parsed FBX scene graph.
|
|
23
|
-
*/
|
|
24
|
-
export function parseFbxBytes(data) {
|
|
25
|
-
const loader = new FBXLoader();
|
|
26
|
-
loader.manager.onError = (url) => {
|
|
27
|
-
// Deliberately non-fatal: embedded materials and geometry still render.
|
|
28
|
-
warn(`[dsh-3d-viewer] Could not load a referenced FBX texture: ${url}`);
|
|
29
|
-
};
|
|
30
|
-
return loader.parse(toArrayBuffer(data), '');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Parse one complete OBJ file with Three.js.
|
|
35
|
-
*
|
|
36
|
-
* OBJ is a text format, so the preview bytes are decoded as UTF-8 first.
|
|
37
|
-
* `.mtl` sidecars and external textures are not available to the document
|
|
38
|
-
* preview; the loader falls back to default materials while preserving
|
|
39
|
-
* `usemtl` names and vertex colors.
|
|
40
|
-
*
|
|
41
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete file bytes or decoded text.
|
|
42
|
-
* @returns {import('three').Group} The parsed OBJ scene graph.
|
|
43
|
-
*/
|
|
44
|
-
export function parseObjBytes(data) {
|
|
45
|
-
const loader = new OBJLoader();
|
|
46
|
-
loader.manager.onError = (url) => {
|
|
47
|
-
warn(`[dsh-3d-viewer] Could not load a referenced OBJ resource: ${url}`);
|
|
48
|
-
};
|
|
49
|
-
return loader.parse(decodeText(data));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Parse one complete STL file (ASCII or binary) into a single mesh.
|
|
54
|
-
*
|
|
55
|
-
* STL has no material library or scene hierarchy, so a neutral double-sided
|
|
56
|
-
* PBR material is assigned. Binary STL vertex colors are retained when present.
|
|
57
|
-
*
|
|
58
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete STL bytes or text.
|
|
59
|
-
* @returns {import('three').Mesh} The parsed STL mesh.
|
|
60
|
-
*/
|
|
61
|
-
export function parseStlBytes(data) {
|
|
62
|
-
const geometry = new STLLoader().parse(toArrayBuffer(data));
|
|
63
|
-
if (geometry.getAttribute('normal') === undefined) geometry.computeVertexNormals();
|
|
64
|
-
const hasColors = geometry.hasColors === true;
|
|
65
|
-
const material = new MeshStandardMaterial({
|
|
66
|
-
name: 'STL',
|
|
67
|
-
color: hasColors ? 0xffffff : 0xc9c9c9,
|
|
68
|
-
metalness: 0,
|
|
69
|
-
roughness: 0.8,
|
|
70
|
-
side: DoubleSide,
|
|
71
|
-
vertexColors: hasColors,
|
|
72
|
-
});
|
|
73
|
-
const mesh = new Mesh(geometry, material);
|
|
74
|
-
mesh.name = 'STL';
|
|
75
|
-
mesh.animations = [];
|
|
76
|
-
return mesh;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Synchronous parsers for the formats that have no external resources.
|
|
81
|
-
*
|
|
82
|
-
* glTF/GLB is deliberately absent: it needs the asynchronous workspace loader
|
|
83
|
-
* from `loaders/model-loader.js`, so it cannot be parsed here.
|
|
84
|
-
*/
|
|
85
|
-
const SYNC_PARSERS = Object.freeze({
|
|
86
|
-
fbx: parseFbxBytes,
|
|
87
|
-
obj: parseObjBytes,
|
|
88
|
-
stl: parseStlBytes,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Parse a preview according to the registered document format.
|
|
93
|
-
*
|
|
94
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete file bytes or decoded text.
|
|
95
|
-
* @param {'fbx' | 'obj' | 'stl'} format Document format.
|
|
96
|
-
* @returns {import('three').Object3D} Parsed scene graph.
|
|
97
|
-
*/
|
|
98
|
-
export function parseModelBytes(data, format) {
|
|
99
|
-
if (!Object.hasOwn(SYNC_PARSERS, format)) {
|
|
100
|
-
throw new TypeError(`Unsupported 3D model format: ${String(format)}`);
|
|
101
|
-
}
|
|
102
|
-
return SYNC_PARSERS[format](data);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Normalized bits used when a position component is NaN. */
|
|
106
|
-
const NAN_BITS = 0x7fc00000;
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Hash three IEEE-754 float bit patterns into one 32-bit bucket key.
|
|
110
|
-
*
|
|
111
|
-
* @param {number} x X bits.
|
|
112
|
-
* @param {number} y Y bits.
|
|
113
|
-
* @param {number} z Z bits.
|
|
114
|
-
* @returns {number} 32-bit hash.
|
|
115
|
-
*/
|
|
116
|
-
function hashPosition(x, y, z) {
|
|
117
|
-
let hash = Math.imul(x ^ 0x9e3779b9, 0x85ebca6b);
|
|
118
|
-
hash = Math.imul(hash ^ y, 0xc2b2ae35);
|
|
119
|
-
hash = Math.imul(hash ^ z, 0x27d4eb2f);
|
|
120
|
-
return hash ^ (hash >>> 15);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Compare two vertices of a non-interleaved Float32 position attribute.
|
|
125
|
-
*
|
|
126
|
-
* @param {Float32Array} array Position storage.
|
|
127
|
-
* @param {number} a First vertex index.
|
|
128
|
-
* @param {number} b Second vertex index.
|
|
129
|
-
* @returns {boolean} True when the positions are identical (NaN equals NaN).
|
|
130
|
-
*/
|
|
131
|
-
function samePosition(array, a, b) {
|
|
132
|
-
const ao = a * 3;
|
|
133
|
-
const bo = b * 3;
|
|
134
|
-
for (let component = 0; component < 3; component += 1) {
|
|
135
|
-
const left = array[ao + component];
|
|
136
|
-
const right = array[bo + component];
|
|
137
|
-
if (left === right) continue;
|
|
138
|
-
if (left !== left && right !== right) continue;
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Count distinct positions without allocating one string per vertex.
|
|
146
|
-
*
|
|
147
|
-
* FBX exporters commonly split a shared vertex by normal or UV seam, so
|
|
148
|
-
* deduplicating by exact position keeps the HUD count meaningful.
|
|
149
|
-
*
|
|
150
|
-
* @param {Float32Array} array Position storage.
|
|
151
|
-
* @param {number} count Number of vertices.
|
|
152
|
-
* @returns {number} Distinct position count.
|
|
153
|
-
*/
|
|
154
|
-
function countUniqueFloat32(array, count) {
|
|
155
|
-
const bits = new Uint32Array(array.buffer, array.byteOffset, array.length);
|
|
156
|
-
const buckets = new Map();
|
|
157
|
-
let unique = 0;
|
|
158
|
-
|
|
159
|
-
for (let index = 0; index < count; index += 1) {
|
|
160
|
-
const offset = index * 3;
|
|
161
|
-
const x = array[offset];
|
|
162
|
-
const y = array[offset + 1];
|
|
163
|
-
const z = array[offset + 2];
|
|
164
|
-
const xBits = x === 0 ? 0 : (x !== x ? NAN_BITS : bits[offset]);
|
|
165
|
-
const yBits = y === 0 ? 0 : (y !== y ? NAN_BITS : bits[offset + 1]);
|
|
166
|
-
const zBits = z === 0 ? 0 : (z !== z ? NAN_BITS : bits[offset + 2]);
|
|
167
|
-
const key = hashPosition(xBits, yBits, zBits);
|
|
168
|
-
const bucket = buckets.get(key);
|
|
169
|
-
|
|
170
|
-
if (bucket === undefined) {
|
|
171
|
-
buckets.set(key, index);
|
|
172
|
-
unique += 1;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (typeof bucket === 'number') {
|
|
177
|
-
if (samePosition(array, bucket, index)) continue;
|
|
178
|
-
buckets.set(key, [bucket, index]);
|
|
179
|
-
unique += 1;
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
let found = false;
|
|
184
|
-
for (let candidate = 0; candidate < bucket.length; candidate += 1) {
|
|
185
|
-
if (samePosition(array, bucket[candidate], index)) {
|
|
186
|
-
found = true;
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
if (!found) {
|
|
191
|
-
bucket.push(index);
|
|
192
|
-
unique += 1;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return unique;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Count distinct positions for any BufferAttribute.
|
|
201
|
-
*
|
|
202
|
-
* The fast path avoids string allocation for the common non-interleaved
|
|
203
|
-
* Float32 position attribute; interleaved or non-float attributes fall back
|
|
204
|
-
* to exact key strings.
|
|
205
|
-
*
|
|
206
|
-
* @param {import('three').BufferAttribute | import('three').InterleavedBufferAttribute} position Position attribute.
|
|
207
|
-
* @returns {number} Distinct position count.
|
|
208
|
-
*/
|
|
209
|
-
function countUniquePositions(position) {
|
|
210
|
-
const count = position.count;
|
|
211
|
-
if (count <= 0) return 0;
|
|
212
|
-
|
|
213
|
-
if (position.itemSize === 3
|
|
214
|
-
&& position.array instanceof Float32Array
|
|
215
|
-
&& position.isInterleavedBufferAttribute !== true) {
|
|
216
|
-
return countUniqueFloat32(position.array, count);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const seen = new Set();
|
|
220
|
-
for (let index = 0; index < count; index += 1) {
|
|
221
|
-
seen.add(`${position.getX(index)},${position.getY(index)},${position.getZ(index)}`);
|
|
222
|
-
}
|
|
223
|
-
return seen.size;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Add every material reachable from one mesh to the shared set.
|
|
228
|
-
*
|
|
229
|
-
* @param {unknown} material Mesh material or material array.
|
|
230
|
-
* @param {Set<object>} materials Accumulating material set.
|
|
231
|
-
*/
|
|
232
|
-
function collectMaterials(material, materials) {
|
|
233
|
-
if (Array.isArray(material)) {
|
|
234
|
-
for (const entry of material) {
|
|
235
|
-
if (entry !== undefined && entry !== null) materials.add(entry);
|
|
236
|
-
}
|
|
237
|
-
} else if (material !== undefined && material !== null) {
|
|
238
|
-
materials.add(material);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Collect the values the HUD and the camera fit need from one parsed graph.
|
|
244
|
-
*
|
|
245
|
-
* Vertices are measured per mesh by distinct position, then summed across
|
|
246
|
-
* meshes. This keeps the count meaningful for FBX exports that split vertices
|
|
247
|
-
* by normal or UV seam instead of sharing an index buffer.
|
|
248
|
-
*
|
|
249
|
-
* The graph is walked once, carrying visibility down the tree, so an invisible
|
|
250
|
-
* branch costs one check instead of one ancestor scan per descendant.
|
|
251
|
-
*
|
|
252
|
-
* @param {import('three').Object3D} object Parsed model graph.
|
|
253
|
-
* @returns {{
|
|
254
|
-
* meshes: number,
|
|
255
|
-
* renderables: number,
|
|
256
|
-
* vertices: number,
|
|
257
|
-
* triangles: number,
|
|
258
|
-
* materials: number,
|
|
259
|
-
* box: Box3,
|
|
260
|
-
* size: Vector3,
|
|
261
|
-
* center: Vector3,
|
|
262
|
-
* maxDimension: number,
|
|
263
|
-
* }} Mesh counts and a world-space bounding volume.
|
|
264
|
-
*/
|
|
265
|
-
export function collectModelStats(object) {
|
|
266
|
-
object.updateMatrixWorld(true);
|
|
267
|
-
const box = new Box3();
|
|
268
|
-
const size = new Vector3();
|
|
269
|
-
const center = new Vector3();
|
|
270
|
-
const scratch = new Box3();
|
|
271
|
-
|
|
272
|
-
let meshes = 0;
|
|
273
|
-
let renderables = 0;
|
|
274
|
-
let vertices = 0;
|
|
275
|
-
let triangles = 0;
|
|
276
|
-
const materials = new Set();
|
|
277
|
-
|
|
278
|
-
/** @param {import('three').Object3D} node */
|
|
279
|
-
const visit = (node) => {
|
|
280
|
-
if (node.visible === false) return;
|
|
281
|
-
|
|
282
|
-
const geometry = node.geometry;
|
|
283
|
-
if (geometry !== undefined && geometry !== null) {
|
|
284
|
-
const isMesh = node.isMesh === true;
|
|
285
|
-
const isLine = node.isLine === true;
|
|
286
|
-
const isPoints = node.isPoints === true;
|
|
287
|
-
|
|
288
|
-
if (isMesh || isLine || isPoints) {
|
|
289
|
-
renderables += 1;
|
|
290
|
-
geometry.computeBoundingBox();
|
|
291
|
-
if (geometry.boundingBox !== null) {
|
|
292
|
-
box.union(scratch.copy(geometry.boundingBox).applyMatrix4(node.matrixWorld));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (isMesh) {
|
|
296
|
-
meshes += 1;
|
|
297
|
-
const position = geometry.getAttribute('position');
|
|
298
|
-
if (position !== undefined && position.count > 0) {
|
|
299
|
-
vertices += countUniquePositions(position);
|
|
300
|
-
const index = geometry.getIndex();
|
|
301
|
-
triangles += Math.floor((index === null ? position.count : index.count) / 3);
|
|
302
|
-
}
|
|
303
|
-
collectMaterials(node.material, materials);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const children = node.children;
|
|
309
|
-
for (let index = 0; index < children.length; index += 1) visit(children[index]);
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
visit(object);
|
|
313
|
-
|
|
314
|
-
box.getSize(size);
|
|
315
|
-
box.getCenter(center);
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
meshes,
|
|
319
|
-
renderables,
|
|
320
|
-
vertices,
|
|
321
|
-
triangles,
|
|
322
|
-
materials: materials.size,
|
|
323
|
-
box,
|
|
324
|
-
size,
|
|
325
|
-
center,
|
|
326
|
-
maxDimension: Math.max(size.x, size.y, size.z),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Remove lights, cameras, and audio nodes that belong to the source file.
|
|
332
|
-
*
|
|
333
|
-
* FBX and glTF documents can carry their own lights and cameras. The preview
|
|
334
|
-
* supplies its own lighting and camera, so embedded ones would double-light
|
|
335
|
-
* the model or leave stray nodes in the graph.
|
|
336
|
-
*
|
|
337
|
-
* @param {import('three').Object3D} root Parsed scene graph.
|
|
338
|
-
* @returns {number} Number of removed nodes.
|
|
339
|
-
*/
|
|
340
|
-
export function stripEmbeddedSceneNodes(root) {
|
|
341
|
-
const embedded = [];
|
|
342
|
-
root.traverse((child) => {
|
|
343
|
-
if (child.isLight === true || child.isCamera === true || child.isAudio === true) {
|
|
344
|
-
embedded.push(child);
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
for (const node of embedded) {
|
|
349
|
-
node.removeFromParent();
|
|
350
|
-
node.dispose?.();
|
|
351
|
-
}
|
|
352
|
-
return embedded.length;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Dispose every geometry, material, texture, skeleton, and embedded Blob URL
|
|
357
|
-
* reachable from one graph. Shared resources are released once.
|
|
358
|
-
*
|
|
359
|
-
* @param {import('three').Object3D} root Parsed graph or whole scene.
|
|
360
|
-
*/
|
|
361
|
-
export function disposeObject(root) {
|
|
362
|
-
const geometries = new Set();
|
|
363
|
-
const materials = new Set();
|
|
364
|
-
const textures = new Set();
|
|
365
|
-
const skeletons = new Set();
|
|
366
|
-
const blobUrls = new Set();
|
|
367
|
-
|
|
368
|
-
root.traverse((child) => {
|
|
369
|
-
if (child.geometry !== undefined && child.geometry !== null) {
|
|
370
|
-
geometries.add(child.geometry);
|
|
371
|
-
}
|
|
372
|
-
if (child.isSkinnedMesh === true && child.skeleton !== undefined && child.skeleton !== null) {
|
|
373
|
-
skeletons.add(child.skeleton);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const material = child.material;
|
|
377
|
-
if (material === undefined || material === null) return;
|
|
378
|
-
|
|
379
|
-
for (const entry of Array.isArray(material) ? material : [material]) {
|
|
380
|
-
if (entry === undefined || entry === null) continue;
|
|
381
|
-
materials.add(entry);
|
|
382
|
-
|
|
383
|
-
for (const value of Object.values(entry)) {
|
|
384
|
-
if (value === null || typeof value !== 'object') continue;
|
|
385
|
-
if (value.isTexture === true) {
|
|
386
|
-
textures.add(value);
|
|
387
|
-
} else if (Array.isArray(value)) {
|
|
388
|
-
for (const nested of value) {
|
|
389
|
-
if (nested !== null && typeof nested === 'object' && nested.isTexture === true) {
|
|
390
|
-
textures.add(nested);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
for (const texture of textures) {
|
|
399
|
-
const source = texture.image?.src;
|
|
400
|
-
if (typeof source === 'string' && source.startsWith('blob:')) blobUrls.add(source);
|
|
401
|
-
texture.dispose();
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (typeof URL.revokeObjectURL === 'function') {
|
|
405
|
-
for (const url of blobUrls) {
|
|
406
|
-
try {
|
|
407
|
-
URL.revokeObjectURL(url);
|
|
408
|
-
} catch {
|
|
409
|
-
// A URL already released by the browser is harmless.
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
for (const skeleton of skeletons) skeleton.dispose();
|
|
415
|
-
for (const material of materials) material.dispose();
|
|
416
|
-
for (const geometry of geometries) geometry.dispose();
|
|
417
|
-
}
|