dsh-3d-viewer 0.1.0 → 0.1.1

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/index.js CHANGED
@@ -1,7 +1,167 @@
1
1
  /**
2
2
  * Host half of the 3D viewer bundle.
3
3
  *
4
- * The viewer is entirely browser-side: it extends the right Sidebar's
5
- * document-preview registry, so it registers nothing in the Host tree.
4
+ * The viewer itself runs in the browser, but its four HDRI environments are
5
+ * packaged as `.hdr` files instead of base64 inside the client bundle: a web
6
+ * page has no URL for a file inside an installed plugin package. This half
7
+ * registers one read-only route that serves those files from the package the
8
+ * host already loaded, so the browser can fetch them like any other asset.
9
+ *
10
+ * The route is public by design. The HDRIs are CC0 images, the route answers
11
+ * only GET/HEAD, and it maps a fixed whitelist of names to files — it never
12
+ * accepts a caller-chosen path, so there is nothing to traverse and nothing
13
+ * secret to leak. This mirrors how other Host plugins serve their own assets.
14
+ */
15
+ import { readFile } from 'node:fs/promises';
16
+
17
+ /**
18
+ * Route prefix registered with the host web server.
19
+ *
20
+ * No trailing slash: `webServer.match` accepts a prefix only when the request
21
+ * pathname equals it or starts with `${prefix}/`. A registered `/hdri/` would
22
+ * therefore never match `/hdri/nature.hdr`.
23
+ */
24
+ export const HDRI_ROUTE = '/dsh-3d-viewer/hdri';
25
+
26
+ /** Request prefix, with the slash the route matcher expects after the prefix. */
27
+ const HDRI_PREFIX = `${HDRI_ROUTE}/`;
28
+
29
+ /** Environment id to packaged file name. Mirrors `ENVIRONMENT_IDS` client-side. */
30
+ const HDRI_FILES = Object.freeze({
31
+ nature: 'nature.hdr',
32
+ city: 'city.hdr',
33
+ indoor: 'indoor.hdr',
34
+ studio: 'studio.hdr',
35
+ });
36
+
37
+ /** Request name (`<id>.hdr`) to packaged file name. */
38
+ const HDRI_PATHS = new Map(
39
+ Object.entries(HDRI_FILES).map(([id, file]) => [`${id}.hdr`, file]),
40
+ );
41
+
42
+ const HDRI_TYPE = 'image/vnd.radiance';
43
+ // The client appends `?v=<package version>`, so a long immutable lifetime is
44
+ // safe: an upgrade changes the URL instead of reusing a stale cached response.
45
+ const HDRI_CACHE_CONTROL = 'public, max-age=31536000, immutable';
46
+
47
+ /**
48
+ * Packaged HDRIs read once per host process.
49
+ *
50
+ * The files belong to an installed (immutable) package, so caching decoded
51
+ * query results is safe; a rebuilt package restarts the host.
52
+ *
53
+ * @type {Map<string, Promise<Buffer>>}
54
+ */
55
+ const fileCache = new Map();
56
+
57
+ /**
58
+ * Read one packaged HDRI, reusing an earlier read.
59
+ *
60
+ * @param {string} file Packaged file name.
61
+ * @returns {Promise<Buffer>} File contents.
62
+ */
63
+ function readHdriFile(file) {
64
+ const cached = fileCache.get(file);
65
+ if (cached !== undefined) return cached;
66
+
67
+ const task = readFile(new URL(`./assets/hdri/${file}`, import.meta.url));
68
+ task.catch(() => fileCache.delete(file));
69
+ fileCache.set(file, task);
70
+ return task;
71
+ }
72
+
73
+ /**
74
+ * Answer one request with a short plain-text response.
75
+ *
76
+ * @param {import('node:http').ServerResponse} response Response to finish.
77
+ * @param {number} status HTTP status.
78
+ * @param {string} message Body text.
79
+ * @param {Record<string, string | number>} [headers] Extra headers.
80
+ * @param {boolean} [headOnly] Omit the body for a HEAD request.
81
+ */
82
+ function sendText(response, status, message, headers = {}, headOnly = false) {
83
+ response.writeHead(status, {
84
+ 'content-type': 'text/plain; charset=utf-8',
85
+ 'content-length': Buffer.byteLength(message),
86
+ ...headers,
87
+ });
88
+ response.end(headOnly ? undefined : message);
89
+ }
90
+
91
+ /**
92
+ * Create the read-only route that serves the packaged HDRIs.
93
+ *
94
+ * @returns {{
95
+ * kind: 'prefix',
96
+ * path: string,
97
+ * handler: (request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>,
98
+ * }} A WebRoute for the host `webServer` service.
99
+ */
100
+ export function createHdriRoute() {
101
+ return {
102
+ kind: 'prefix',
103
+ path: HDRI_ROUTE,
104
+
105
+ async handler(request, response) {
106
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
107
+ sendText(response, 405, 'Method not allowed', { allow: 'GET, HEAD' });
108
+ return;
109
+ }
110
+
111
+ const headOnly = request.method === 'HEAD';
112
+ let pathname;
113
+ try {
114
+ pathname = new URL(request.url ?? '', 'http://localhost').pathname;
115
+ } catch {
116
+ sendText(response, 400, 'Malformed request URL', {}, headOnly);
117
+ return;
118
+ }
119
+
120
+ // Only the exact `<id>.hdr` names in the whitelist resolve. Percent
121
+ // escapes are never decoded, so `..%2fpackage.json` stays a miss.
122
+ const name = pathname.startsWith(HDRI_PREFIX) ? pathname.slice(HDRI_PREFIX.length) : '';
123
+ const file = HDRI_PATHS.get(name);
124
+ if (file === undefined) {
125
+ sendText(response, 404, 'Unknown HDRI', {}, headOnly);
126
+ return;
127
+ }
128
+
129
+ let body;
130
+ try {
131
+ body = await readHdriFile(file);
132
+ } catch (error) {
133
+ console.warn(`[dsh-3d-viewer] Could not read the packaged HDRI "${file}".`, error);
134
+ sendText(response, 500, 'The packaged HDRI could not be read', {}, headOnly);
135
+ return;
136
+ }
137
+
138
+ response.writeHead(200, {
139
+ 'content-type': HDRI_TYPE,
140
+ 'content-length': body.length,
141
+ 'cache-control': HDRI_CACHE_CONTROL,
142
+ });
143
+ if (headOnly) response.end();
144
+ else response.end(body);
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Register the HDRI route once the composition provides a web server.
151
+ *
152
+ * The service is acquired through `ctx.inject`, not `ctx.get`: a Host plugin
153
+ * applies before sibling rows finish loading, so a direct probe can run while
154
+ * `webServer` is still absent. `inject` keeps the callback dormant until the
155
+ * service exists and disposes the route with the plugin. A non-web runtime has
156
+ * no `webServer`, and this half never registers anything there.
157
+ *
158
+ * @param {import('@deepseek-ai/cordis').Context} ctx Owning Host context.
6
159
  */
7
- export function apply() {}
160
+ export function apply(ctx) {
161
+ ctx.inject(['webServer'], (hostCtx) => {
162
+ hostCtx.effect(
163
+ () => hostCtx.webServer.register(createHdriRoute()),
164
+ 'dsh-3d-viewer: HDRI route',
165
+ );
166
+ });
167
+ }
package/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "dsh-3d-viewer",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Render FBX, OBJ, glTF, GLB, and STL models from the right Sidebar's workspace file tree in an orbitable 3D view.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "3d-viewer"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ybniaobu/dsh-3d-viewer.git"
14
+ },
15
+ "homepage": "https://github.com/ybniaobu/dsh-3d-viewer#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/ybniaobu/dsh-3d-viewer/issues"
18
+ },
5
19
  "type": "module",
6
20
  "icon": "./icon.svg",
7
21
  "exports": {
@@ -21,10 +35,7 @@
21
35
  "LICENSE",
22
36
  "THIRD_PARTY_NOTICES.md",
23
37
  "icon.svg",
24
- "src",
25
- "assets",
26
- "scripts",
27
- "build.mjs"
38
+ "assets/hdri"
28
39
  ],
29
40
  "dsh": {
30
41
  "bundle": {
@@ -41,8 +52,16 @@
41
52
  },
42
53
  "scripts": {
43
54
  "build": "node build.mjs",
44
- "embed:hdri": "node scripts/embed-hdri.mjs --force",
45
55
  "test": "node tests/run.mjs"
46
56
  },
57
+ "devDependencies": {
58
+ "esbuild": "^0.28.2",
59
+ "react": "^18.3.1",
60
+ "react-dom": "^18.3.1",
61
+ "three": "^0.187.0"
62
+ },
63
+ "engines": {
64
+ "dsh": ">=0.2.2-rc.1"
65
+ },
47
66
  "license": "MIT"
48
67
  }
Binary file
package/build.mjs DELETED
@@ -1,49 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { dirname, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { embedHdri } from './scripts/embed-hdri.mjs';
6
-
7
- const root = dirname(fileURLToPath(import.meta.url));
8
- const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
9
- const esbuildBin = resolve(root, 'node_modules/esbuild/bin/esbuild');
10
- const bundlePath = resolve(root, '.build/client.bundle.cjs');
11
-
12
- await embedHdri();
13
- await mkdir(dirname(bundlePath), { recursive: true });
14
-
15
- const result = spawnSync(process.execPath, [
16
- esbuildBin,
17
- resolve(root, 'src/client.js'),
18
- '--bundle',
19
- '--format=cjs',
20
- '--platform=browser',
21
- '--target=chrome120,edge120',
22
- '--external:react',
23
- '--minify',
24
- '--legal-comments=none',
25
- '--log-level=warning',
26
- `--outfile=${bundlePath}`,
27
- ], {
28
- cwd: root,
29
- stdio: 'inherit',
30
- });
31
-
32
- if (result.error !== undefined) throw result.error;
33
- if (result.status !== 0) process.exit(result.status ?? 1);
34
-
35
- const code = (await readFile(bundlePath, 'utf8')).trimEnd();
36
- const wrapped = `/*! dsh-3d-viewer | bundles Three.js (MIT), FBXLoader, OBJLoader, MTLLoader, GLTFLoader, STLLoader, OrbitControls and HDRLoader (MIT), plus embedded Poly Haven CC0 HDRIs. See THIRD_PARTY_NOTICES.md for third-party licenses. */
37
- window.__ModuleLoader__.load({
38
- id: ${JSON.stringify(pkg.name)},
39
- factory(require) {
40
- var module = { exports: {} };
41
- var exports = module.exports;
42
- ${code}
43
- return module.exports;
44
- },
45
- });
46
- `;
47
-
48
- await writeFile(resolve(root, 'client.js'), wrapped);
49
- console.log(`Built client.js (${Buffer.byteLength(wrapped)} bytes) for ${pkg.name}.`);
@@ -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
- }