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/scripts/embed-hdri.mjs
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
6
|
-
const files = {
|
|
7
|
-
nature: 'nature.hdr',
|
|
8
|
-
city: 'city.hdr',
|
|
9
|
-
indoor: 'indoor.hdr',
|
|
10
|
-
studio: 'studio.hdr',
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const targetPath = resolve(root, 'src', 'generated', 'hdri-data.js');
|
|
14
|
-
const scriptPath = fileURLToPath(import.meta.url);
|
|
15
|
-
|
|
16
|
-
async function mtime(path) {
|
|
17
|
-
try {
|
|
18
|
-
return (await stat(path)).mtimeMs;
|
|
19
|
-
} catch {
|
|
20
|
-
return 0;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function isUpToDate() {
|
|
25
|
-
const targetInfo = await stat(targetPath).catch(() => null);
|
|
26
|
-
if (targetInfo === null || targetInfo.size === 0) return false;
|
|
27
|
-
|
|
28
|
-
const assets = Object.values(files).map((file) => resolve(root, 'assets', 'hdri', file));
|
|
29
|
-
const newestSource = Math.max(await mtime(scriptPath), ...(await Promise.all(assets.map(mtime))));
|
|
30
|
-
return targetInfo.mtimeMs >= newestSource;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Embed the four CC0 HDRIs into a generated ES module.
|
|
35
|
-
*
|
|
36
|
-
* The generated module is reused when it is newer than every source asset, so
|
|
37
|
-
* repeated builds do not re-read and re-encode ~8.7 MB of base64 text.
|
|
38
|
-
*
|
|
39
|
-
* @param {{ force?: boolean }} [options] Regenerate even when the cache is fresh.
|
|
40
|
-
* @returns {Promise<boolean>} Whether the file was rewritten.
|
|
41
|
-
*/
|
|
42
|
-
export async function embedHdri({ force = false } = {}) {
|
|
43
|
-
if (!force && await isUpToDate()) {
|
|
44
|
-
console.log('Embedded HDRIs are up to date; skipped regeneration.');
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const entries = [];
|
|
49
|
-
for (const [id, file] of Object.entries(files)) {
|
|
50
|
-
const data = await readFile(resolve(root, 'assets', 'hdri', file));
|
|
51
|
-
const dataUrl = `data:application/octet-stream;base64,${data.toString('base64')}`;
|
|
52
|
-
entries.push(` ${id}: ${JSON.stringify(dataUrl)},`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const source = `// Generated by scripts/embed-hdri.mjs from assets/hdri/*.hdr. Do not edit.
|
|
56
|
-
// HDRIs are Poly Haven CC0 assets; see assets/hdri/LICENSE.md.
|
|
57
|
-
export const HDRI_DATA_URLS = {
|
|
58
|
-
${entries.join('\n')}
|
|
59
|
-
};
|
|
60
|
-
`;
|
|
61
|
-
await mkdir(dirname(targetPath), { recursive: true });
|
|
62
|
-
await writeFile(targetPath, source);
|
|
63
|
-
console.log(`Embedded ${entries.length} HDRIs into src/generated/hdri-data.js.`);
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const invokedDirectly = process.argv[1] !== undefined
|
|
68
|
-
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
69
|
-
|
|
70
|
-
if (invokedDirectly) await embedHdri({ force: process.argv.includes('--force') });
|
package/src/bytes.js
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Byte, text, and path helpers shared by the model loaders.
|
|
3
|
-
*
|
|
4
|
-
* The loaders previously carried their own copies of these conversions
|
|
5
|
-
* (`toArrayBuffer`, `decodeText`, `localPath`, `mimeOf`, `abortError`), which
|
|
6
|
-
* drifted apart over time. One module keeps their edge cases consistent.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** URLs Three.js resolves itself instead of the DSH workspace. */
|
|
10
|
-
export const REMOTE_URL = /^(?:data:|blob:|https?:|file:|\/\/)/iu;
|
|
11
|
-
|
|
12
|
-
/** Local paths anchored at a drive letter or a POSIX root. */
|
|
13
|
-
export const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|\/)/u;
|
|
14
|
-
|
|
15
|
-
const MIME_TYPES = new Map([
|
|
16
|
-
['bin', 'application/octet-stream'],
|
|
17
|
-
['png', 'image/png'],
|
|
18
|
-
['jpg', 'image/jpeg'],
|
|
19
|
-
['jpeg', 'image/jpeg'],
|
|
20
|
-
['webp', 'image/webp'],
|
|
21
|
-
['gif', 'image/gif'],
|
|
22
|
-
['bmp', 'image/bmp'],
|
|
23
|
-
['tga', 'image/x-tga'],
|
|
24
|
-
['dds', 'image/vnd-ms.dds'],
|
|
25
|
-
['exr', 'image/x-exr'],
|
|
26
|
-
['hdr', 'image/vnd.radiance'],
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
const UTF8 = new TextDecoder('utf-8');
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Copy preview bytes into a standalone ArrayBuffer.
|
|
33
|
-
*
|
|
34
|
-
* A view into a larger buffer is copied, so the returned payload never keeps
|
|
35
|
-
* the document-preview store's memory alive. Text is encoded as UTF-8 for the
|
|
36
|
-
* loaders that also accept a decoded document.
|
|
37
|
-
*
|
|
38
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete file bytes or text.
|
|
39
|
-
* @returns {ArrayBuffer} A standalone payload.
|
|
40
|
-
*/
|
|
41
|
-
export function toArrayBuffer(data) {
|
|
42
|
-
if (data instanceof ArrayBuffer) return data;
|
|
43
|
-
if (ArrayBuffer.isView(data)) {
|
|
44
|
-
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
45
|
-
}
|
|
46
|
-
if (typeof data === 'string') return new TextEncoder().encode(data).buffer;
|
|
47
|
-
throw new TypeError('The 3D preview was not given binary data');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* View preview bytes as a Uint8Array without copying, encoding text inputs.
|
|
52
|
-
*
|
|
53
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete file bytes or text.
|
|
54
|
-
* @returns {Uint8Array} Byte view of the payload.
|
|
55
|
-
*/
|
|
56
|
-
export function toBytes(data) {
|
|
57
|
-
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
58
|
-
if (ArrayBuffer.isView(data)) {
|
|
59
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
60
|
-
}
|
|
61
|
-
if (typeof data === 'string') return new TextEncoder().encode(data);
|
|
62
|
-
throw new TypeError('The 3D preview was not given model data');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Decode text-format model data, decoding bytes as UTF-8.
|
|
67
|
-
*
|
|
68
|
-
* @param {Uint8Array | ArrayBuffer | string} data Complete file bytes or text.
|
|
69
|
-
* @returns {string} Decoded text.
|
|
70
|
-
*/
|
|
71
|
-
export function decodeText(data) {
|
|
72
|
-
if (typeof data === 'string') return data;
|
|
73
|
-
if (data instanceof ArrayBuffer) return UTF8.decode(data);
|
|
74
|
-
if (ArrayBuffer.isView(data)) return UTF8.decode(data);
|
|
75
|
-
throw new TypeError('The 3D preview was not given model data');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Turn a loader URL into a workspace-relative path.
|
|
80
|
-
*
|
|
81
|
-
* Query and fragment suffixes are dropped and percent escapes are decoded;
|
|
82
|
-
* backslashes are normalized so Windows-authored MTL/FBX/glTF references
|
|
83
|
-
* resolve on every platform.
|
|
84
|
-
*
|
|
85
|
-
* @param {string} value Loader URL or asset path.
|
|
86
|
-
* @returns {string} Normalized forward-slash path.
|
|
87
|
-
*/
|
|
88
|
-
export function decodeLocalPath(value) {
|
|
89
|
-
const withoutSuffix = String(value).split(/[?#]/u, 1)[0];
|
|
90
|
-
try {
|
|
91
|
-
return decodeURIComponent(withoutSuffix).replaceAll('\\', '/');
|
|
92
|
-
} catch {
|
|
93
|
-
return withoutSuffix.replaceAll('\\', '/');
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Whether a loader URL must be read from the session workspace.
|
|
99
|
-
*
|
|
100
|
-
* @param {unknown} value Loader URL or asset path.
|
|
101
|
-
* @returns {boolean} True for relative or absolute local paths.
|
|
102
|
-
*/
|
|
103
|
-
export function isLocalPath(value) {
|
|
104
|
-
return typeof value === 'string' && value !== '' && REMOTE_URL.test(value) === false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Guess a blob MIME type from a file name.
|
|
109
|
-
*
|
|
110
|
-
* @param {unknown} value File name, path, or loader URL.
|
|
111
|
-
* @returns {string} A MIME type, defaulting to application/octet-stream.
|
|
112
|
-
*/
|
|
113
|
-
export function mimeOf(value) {
|
|
114
|
-
const path = decodeLocalPath(value);
|
|
115
|
-
const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
116
|
-
return MIME_TYPES.get(extension) ?? 'application/octet-stream';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Decode a base64 payload into bytes.
|
|
121
|
-
*
|
|
122
|
-
* `Uint8Array.fromBase64` is used when the runtime provides it; the manual
|
|
123
|
-
* fallback keeps older browsers working.
|
|
124
|
-
*
|
|
125
|
-
* @param {string} value Base64 payload without a data-URL prefix.
|
|
126
|
-
* @returns {Uint8Array} Decoded bytes.
|
|
127
|
-
*/
|
|
128
|
-
export function decodeBase64(value) {
|
|
129
|
-
if (typeof Uint8Array.fromBase64 === 'function') return Uint8Array.fromBase64(value);
|
|
130
|
-
|
|
131
|
-
const binary = atob(value);
|
|
132
|
-
const bytes = new Uint8Array(binary.length);
|
|
133
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
134
|
-
bytes[index] = binary.charCodeAt(index);
|
|
135
|
-
}
|
|
136
|
-
return bytes;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Create the error a cancelled load rejects with.
|
|
141
|
-
*
|
|
142
|
-
* @param {string} [message] Human-readable reason.
|
|
143
|
-
* @returns {Error} Error named `AbortError`.
|
|
144
|
-
*/
|
|
145
|
-
export function abortError(message = 'Model loading was cancelled.') {
|
|
146
|
-
const error = new Error(message);
|
|
147
|
-
error.name = 'AbortError';
|
|
148
|
-
return error;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Log a recoverable loading problem without failing the whole preview.
|
|
153
|
-
*
|
|
154
|
-
* @param {string} message Diagnostic message.
|
|
155
|
-
*/
|
|
156
|
-
export function warn(message) {
|
|
157
|
-
if (typeof console !== 'undefined') console.warn(message);
|
|
158
|
-
}
|
package/src/client.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { FORMAT_ENTRIES } from './formats.js';
|
|
2
|
-
import { readWorkspaceFile } from './loaders/workspace.js';
|
|
3
|
-
import { VIEWER_COMPONENTS } from './ui/ModelViewer.js';
|
|
4
|
-
import { en, zh } from './ui/dictionaries.js';
|
|
5
|
-
|
|
6
|
-
const NS = 'dsh3dViewer';
|
|
7
|
-
const BODY_SLOT = 'sidebar.right.tab.document';
|
|
8
|
-
|
|
9
|
-
export const inject = ['documentPreviews', 'slots', 'locale', 'remote', 'remote.workspaceFiles'];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Register one document format and its right-Sidebar view body.
|
|
13
|
-
*
|
|
14
|
-
* @param {import('@deepseek-ai/cordis').Context} ctx Owning Client context.
|
|
15
|
-
* @param {(key: string) => string} t Namespace-bound translator.
|
|
16
|
-
* @param {import('./formats.js').FormatDefinition} entry Format definition.
|
|
17
|
-
*/
|
|
18
|
-
function registerPreview(ctx, t, entry) {
|
|
19
|
-
const component = VIEWER_COMPONENTS[entry.format];
|
|
20
|
-
|
|
21
|
-
ctx.effect(() => ctx.documentPreviews.register({
|
|
22
|
-
id: entry.id,
|
|
23
|
-
extensions: [...entry.extensions],
|
|
24
|
-
binaryExtensions: [...entry.extensions],
|
|
25
|
-
priority: 'extension',
|
|
26
|
-
title: () => t(entry.titleKey),
|
|
27
|
-
loading: 'bytes-complete',
|
|
28
|
-
wrap: false,
|
|
29
|
-
}), `dsh-3d-viewer: ${entry.label} document preview`);
|
|
30
|
-
|
|
31
|
-
ctx.effect(() => ctx.slots.inject(BODY_SLOT, () => ctx.slots.register({
|
|
32
|
-
name: BODY_SLOT,
|
|
33
|
-
key: entry.id,
|
|
34
|
-
locale: NS,
|
|
35
|
-
inject: () => ({
|
|
36
|
-
fallbackT: t,
|
|
37
|
-
readWorkspaceFile: (address, path, options) => readWorkspaceFile(ctx.remote, address, path, options),
|
|
38
|
-
}),
|
|
39
|
-
}, component)), `dsh-3d-viewer: ${entry.label} viewer body`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Register the FBX, OBJ, glTF/GLB, and STL document renderers and their view
|
|
44
|
-
* bodies in the right Sidebar.
|
|
45
|
-
*
|
|
46
|
-
* Formats come from the shared registry, so registration, badges, and viewer
|
|
47
|
-
* components cannot drift apart.
|
|
48
|
-
*
|
|
49
|
-
* @param {import('@deepseek-ai/cordis').Context} ctx Owning Client context.
|
|
50
|
-
*/
|
|
51
|
-
export function apply(ctx) {
|
|
52
|
-
const t = ctx.locale.bind(NS);
|
|
53
|
-
|
|
54
|
-
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-3d-viewer: dictionaries');
|
|
55
|
-
|
|
56
|
-
for (const entry of FORMAT_ENTRIES) registerPreview(ctx, t, entry);
|
|
57
|
-
}
|
package/src/formats.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The single registry of document formats this plugin renders.
|
|
3
|
-
*
|
|
4
|
-
* Registration (client.js), the viewer factories (ui/ModelViewer.js), the HUD
|
|
5
|
-
* badge (ui/chrome.js), and format normalization all derive from this table,
|
|
6
|
-
* so a format is declared exactly once instead of in four parallel lists.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {{
|
|
11
|
-
* id: string,
|
|
12
|
-
* format: 'fbx' | 'obj' | 'gltf' | 'stl',
|
|
13
|
-
* extensions: readonly string[],
|
|
14
|
-
* titleKey: string,
|
|
15
|
-
* badgeKey: string,
|
|
16
|
-
* glbBadgeKey?: string,
|
|
17
|
-
* label: string,
|
|
18
|
-
* }} FormatDefinition
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** @type {Readonly<Record<string, FormatDefinition>>} */
|
|
22
|
-
export const FORMATS = Object.freeze({
|
|
23
|
-
fbx: Object.freeze({
|
|
24
|
-
id: 'dsh-3d-viewer/fbx',
|
|
25
|
-
format: 'fbx',
|
|
26
|
-
extensions: Object.freeze(['fbx']),
|
|
27
|
-
titleKey: 'rendererTitle',
|
|
28
|
-
badgeKey: 'badge',
|
|
29
|
-
label: 'FBX',
|
|
30
|
-
}),
|
|
31
|
-
obj: Object.freeze({
|
|
32
|
-
id: 'dsh-3d-viewer/obj',
|
|
33
|
-
format: 'obj',
|
|
34
|
-
extensions: Object.freeze(['obj']),
|
|
35
|
-
titleKey: 'objRendererTitle',
|
|
36
|
-
badgeKey: 'objBadge',
|
|
37
|
-
label: 'OBJ',
|
|
38
|
-
}),
|
|
39
|
-
gltf: Object.freeze({
|
|
40
|
-
id: 'dsh-3d-viewer/gltf',
|
|
41
|
-
format: 'gltf',
|
|
42
|
-
extensions: Object.freeze(['gltf', 'glb']),
|
|
43
|
-
titleKey: 'gltfRendererTitle',
|
|
44
|
-
badgeKey: 'gltfBadge',
|
|
45
|
-
glbBadgeKey: 'glbBadge',
|
|
46
|
-
label: 'glTF / GLB',
|
|
47
|
-
}),
|
|
48
|
-
stl: Object.freeze({
|
|
49
|
-
id: 'dsh-3d-viewer/stl',
|
|
50
|
-
format: 'stl',
|
|
51
|
-
extensions: Object.freeze(['stl']),
|
|
52
|
-
titleKey: 'stlRendererTitle',
|
|
53
|
-
badgeKey: 'stlBadge',
|
|
54
|
-
label: 'STL',
|
|
55
|
-
}),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
/** Registration order, which is also the order of the sidebar preview list. */
|
|
59
|
-
export const FORMAT_IDS = Object.freeze(Object.keys(FORMATS));
|
|
60
|
-
|
|
61
|
-
/** @type {readonly FormatDefinition[]} */
|
|
62
|
-
export const FORMAT_ENTRIES = Object.freeze(FORMAT_IDS.map((id) => FORMATS[id]));
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Normalize a slot-provided format name.
|
|
66
|
-
*
|
|
67
|
-
* `glb` is an alias of `gltf`; unknown values fall back to the FBX viewer,
|
|
68
|
-
* matching the previous default of the single viewer component.
|
|
69
|
-
*
|
|
70
|
-
* @param {unknown} value Requested format.
|
|
71
|
-
* @returns {'fbx' | 'obj' | 'gltf' | 'stl'} A registered format.
|
|
72
|
-
*/
|
|
73
|
-
export function normalizeFormat(value) {
|
|
74
|
-
if (value === 'glb') return 'gltf';
|
|
75
|
-
return typeof value === 'string' && Object.hasOwn(FORMATS, value)
|
|
76
|
-
? /** @type {'fbx' | 'obj' | 'gltf' | 'stl'} */ (value)
|
|
77
|
-
: 'fbx';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Resolve the dictionary key for a preview badge.
|
|
82
|
-
*
|
|
83
|
-
* @param {unknown} format Document format.
|
|
84
|
-
* @param {boolean} [glb] Whether a glTF document is a binary GLB.
|
|
85
|
-
* @returns {string} Dictionary key.
|
|
86
|
-
*/
|
|
87
|
-
export function badgeKeyFor(format, glb = false) {
|
|
88
|
-
const entry = FORMATS[normalizeFormat(format)];
|
|
89
|
-
return glb === true && entry.glbBadgeKey !== undefined ? entry.glbBadgeKey : entry.badgeKey;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Whether a document address points at a binary GLB container.
|
|
94
|
-
*
|
|
95
|
-
* @param {unknown} address Document resource address.
|
|
96
|
-
* @returns {boolean} True for `.glb` paths, ignoring query and fragment suffixes.
|
|
97
|
-
*/
|
|
98
|
-
export function isGlbAddress(address) {
|
|
99
|
-
return typeof address === 'string' && /\.glb(?:[?#]|$)/iu.test(address);
|
|
100
|
-
}
|