@addozhang/dsh-3d-model-viewer 0.3.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/LICENSE +21 -0
- package/README.md +89 -0
- package/README.zh-CN.md +58 -0
- package/lib/client.js +1022 -0
- package/lib/index.js +66 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 addozhang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# DSH 3D Model Viewer
|
|
2
|
+
|
|
3
|
+
[中文文档](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
An interactive STL / 3MF viewer plugin for the DeepSeek Harness Web GUI.
|
|
6
|
+
Models can be inspected directly in the sidebar document preview — no
|
|
7
|
+
external tool needed — and the plugin doubles as a live preview drawer for
|
|
8
|
+
active sessions.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Native sidebar document preview** — clicking an `.stl` / `.3mf` in the
|
|
13
|
+
Files panel opens it in the right-hand document tab, exactly like the
|
|
14
|
+
builtin Markdown / PDF / image viewers (registered through the public
|
|
15
|
+
`documentPreviews` + `sidebar.right.tab.document` extension points).
|
|
16
|
+
- **Live session drawer** — when the current session references models, a
|
|
17
|
+
`3D 预览 (N)` button appears in the session header and opens a drawer with
|
|
18
|
+
a searchable model list; files are re-parsed automatically ~1.5 s after
|
|
19
|
+
they change on disk.
|
|
20
|
+
- **CAD-style navigation** — left-drag rotates (true trackball, no gimbal
|
|
21
|
+
limits), right / middle / Shift-drag pans, wheel zooms (perspective and
|
|
22
|
+
orthographic), double-click resets.
|
|
23
|
+
- **View presets** — front/back/left/right/top/bottom use an orthographic
|
|
24
|
+
camera and show labelled bounding-box dimensions (mm); iso is the classic
|
|
25
|
+
perspective view. Keyboard: `1-7` presets, `R` reset (when the canvas is
|
|
26
|
+
focused).
|
|
27
|
+
- **Model color** — seven preset swatches (incl. white) plus a native color
|
|
28
|
+
picker; STL stays single-color, 3MF part colors (`basematerials`) are
|
|
29
|
+
rendered per-vertex when present.
|
|
30
|
+
- **Print checks** (currently disabled behind `PRINT_INSIGHTS_ENABLED`) —
|
|
31
|
+
build-volume fit against printer profiles (X2D default), closed-mesh
|
|
32
|
+
volume, and a filament weight estimate.
|
|
33
|
+
- **PNG screenshot export** — one click saves the current view.
|
|
34
|
+
- **System-aware theme** — flat light/dark background following
|
|
35
|
+
`prefers-color-scheme`, with a subtle grid.
|
|
36
|
+
|
|
37
|
+
Everything is parsed in the browser; model files never leave the machine
|
|
38
|
+
(the drawer fetches files from a local plugin endpoint only).
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @addozhang/dsh-3d-model-viewer
|
|
44
|
+
# or from source: npm install github:addozhang/dsh-3d-model-viewer
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then add to `$DSH_HOME/profiles/web/package.json` dependencies and insert into
|
|
48
|
+
`$DSH_HOME/profiles/web/cordis.patch.yml`:
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
- insert:
|
|
52
|
+
- id: 3d-model-viewer
|
|
53
|
+
name: '@addozhang/dsh-3d-model-viewer'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Refresh the Web GUI; if the client roster did not update, restart
|
|
57
|
+
`dsh web` and refresh again.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install
|
|
63
|
+
npm run build # esbuild: src/ -> lib/client.js
|
|
64
|
+
npm test # unit + bundle smoke tests (node --test)
|
|
65
|
+
npm run test:e2e # boots a private dsh web + drives the real GUI (local only)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Source layout under `src/`: `core` (React holder, stores), `styles`,
|
|
69
|
+
`parse` (STL binary/ASCII, 3MF zip + basematerials, mesh volume),
|
|
70
|
+
`viewer` (WebGL renderer, presets, picking math), `ui` (document body,
|
|
71
|
+
drawer, print-insights panel), `app` (registration), `entry-client`.
|
|
72
|
+
|
|
73
|
+
The e2e script needs python3 + playwright + system Chrome and reads the
|
|
74
|
+
cookie-signing secret from `~/.dsh/.credentials.yaml` to mint a session
|
|
75
|
+
cookie for its private server instance. Env knobs: `E2E_WORKSPACE`,
|
|
76
|
+
`E2E_WORKSPACE_DIR`, `E2E_SESSION_SUBSTR`.
|
|
77
|
+
|
|
78
|
+
## Current limitations
|
|
79
|
+
|
|
80
|
+
- 3MF support covers mesh geometry + `basematerials` colors; build-item
|
|
81
|
+
transforms, per-component overrides, and slicer-specific metadata are not
|
|
82
|
+
reconstructed.
|
|
83
|
+
- Files are capped at 250 MB / 5 M triangles; meshes above 400 k triangles
|
|
84
|
+
fall back to bounding-box-only picking (moot with button-based drag modes).
|
|
85
|
+
- This is a geometry previewer: no slicing, no printability analysis.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# DSH 3D Model Viewer
|
|
2
|
+
|
|
3
|
+
**默认英文文档:[README.md](README.md)**
|
|
4
|
+
|
|
5
|
+
DeepSeek Harness Web GUI 的本地交互式 STL / 3MF 预览插件。模型可直接在侧边栏文档预览中查看,无需外部工具;同时为活动会话提供实时预览抽屉。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **侧边栏原生文档预览**:在「文件」面板点击 `.stl` / `.3mf`,右侧文档标签页像打开 Markdown / PDF / 图片一样打开 3D 视图(通过公开的 `documentPreviews` + `sidebar.right.tab.document` 扩展点接入)
|
|
10
|
+
- **会话实时抽屉**:当前会话引用了模型时,会话标题栏出现 `3D 预览 (N)` 按钮,打开带搜索列表的预览抽屉;文件在磁盘上变化后约 1.5 秒自动重新解析
|
|
11
|
+
- **CAD 式导航**:左键拖动旋转(真轨迹球,无万向锁)、右键 / 中键 / Shift 拖动平移、滚轮缩放(透视与正交通用)、双击复位
|
|
12
|
+
- **视角预设**:前/后/左/右/顶/底使用正交相机并显示包围盒三向尺寸标签(毫米);等轴测为经典透视。画布聚焦时快捷键 `1-7` 切视角、`R` 复位
|
|
13
|
+
- **模型颜色**:七个预置色板(含白色)+ 原生取色器;STL 单色渲染,3MF 的部件颜色(`basematerials`)按顶点多色显示
|
|
14
|
+
- **打印检查**(当前由 `PRINT_INSIGHTS_ENABLED` 开关关闭):打印机体积适配(默认 X2D)、封闭网格体积、耗材重量估算
|
|
15
|
+
- **截图导出**:一键保存当前视图为 PNG
|
|
16
|
+
- **跟随系统主题**:浅色/深色平面背景(`prefers-color-scheme`),附淡网格线
|
|
17
|
+
|
|
18
|
+
所有解析都在浏览器本地完成,模型文件不会离开本机(抽屉仅从本地插件端点拉取文件)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @addozhang/dsh-3d-model-viewer
|
|
24
|
+
# 或从源码安装:npm install github:addozhang/dsh-3d-model-viewer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
然后把包加入 `$DSH_HOME/profiles/web/package.json` 依赖,并在 `$DSH_HOME/profiles/web/cordis.patch.yml` 中插入:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- insert:
|
|
31
|
+
- id: 3d-model-viewer
|
|
32
|
+
name: '@addozhang/dsh-3d-model-viewer'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
刷新 Web GUI;若客户端 roster 未更新,重启 `dsh web` 后再刷新。
|
|
36
|
+
|
|
37
|
+
## 开发
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install
|
|
41
|
+
npm run build # esbuild:src/ -> lib/client.js
|
|
42
|
+
npm test # 单测 + 构建产物冒烟测试(node --test)
|
|
43
|
+
npm run test:e2e # 起私有 dsh web 实例 + Playwright 驱动真实 GUI(仅本机)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`src/` 模块划分:`core`(React 注入与状态存储)、`styles`、`parse`(STL 二进制/ASCII、3MF zip + basematerials、网格体积)、`viewer`(WebGL 渲染器、预设、拾取数学)、`ui`(文档预览体、抽屉、打印检查面板)、`app`(注册)、`entry-client`。
|
|
47
|
+
|
|
48
|
+
e2e 脚本依赖本机 python3 + playwright + Chrome,从 `~/.dsh/.credentials.yaml` 读取签名密钥为私有实例铸造会话 cookie。环境变量:`E2E_WORKSPACE`、`E2E_WORKSPACE_DIR`、`E2E_SESSION_SUBSTR`。
|
|
49
|
+
|
|
50
|
+
## 当前限制
|
|
51
|
+
|
|
52
|
+
- 3MF 支持网格几何 + `basematerials` 颜色;build-item 变换、组件级覆盖与切片器私有元数据不还原
|
|
53
|
+
- 单文件限制 250 MB / 500 万三角面;超过 40 万三角面的网格拾取只用包围盒(按键式拖动模式下影响甚微)
|
|
54
|
+
- 这是几何预览器:不做切片,不做可制造性分析
|
|
55
|
+
|
|
56
|
+
## 许可
|
|
57
|
+
|
|
58
|
+
MIT
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
// generated from src/ — run `npm run build` after editing
|
|
2
|
+
(() => {
|
|
3
|
+
// src/core.js
|
|
4
|
+
var React = null;
|
|
5
|
+
var h = null;
|
|
6
|
+
function setReact(react) {
|
|
7
|
+
React = react;
|
|
8
|
+
h = react.createElement;
|
|
9
|
+
}
|
|
10
|
+
function makeStore() {
|
|
11
|
+
let state = { open: false, name: "", mesh: null, error: "", loading: false };
|
|
12
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
13
|
+
return {
|
|
14
|
+
getSnapshot: () => state,
|
|
15
|
+
subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)),
|
|
16
|
+
set: (patch) => {
|
|
17
|
+
state = { ...state, ...patch };
|
|
18
|
+
listeners.forEach((fn) => fn());
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function makeSessionStore() {
|
|
23
|
+
let state = { files: [], selected: "", mesh: null, error: "", loading: false, mtime: 0 };
|
|
24
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
25
|
+
return {
|
|
26
|
+
getSnapshot: () => state,
|
|
27
|
+
subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)),
|
|
28
|
+
set: (patch) => {
|
|
29
|
+
state = { ...state, ...patch };
|
|
30
|
+
listeners.forEach((fn) => fn());
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
var useStore = (store) => React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
35
|
+
|
|
36
|
+
// src/styles.js
|
|
37
|
+
var CSS = `
|
|
38
|
+
.d3v-backdrop{position:fixed;inset:0;z-index:1000;background:rgba(5,10,20,.62);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;padding:20px}
|
|
39
|
+
.d3v-dialog{width:min(1080px,calc(100vw - 40px));height:min(780px,calc(100vh - 40px));background:var(--dsw-specific-menu,#17191d);color:var(--dsw-alias-label-primary,#eee);border:1px solid var(--dsw-alias-border-l1,#444);border-radius:16px;box-shadow:0 22px 80px rgba(0,0,0,.45);display:grid;grid-template-rows:auto 1fr auto;overflow:hidden}
|
|
40
|
+
.d3v-header{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;border-bottom:1px solid var(--dsw-alias-border-l1,#333)}.d3v-title{font-size:16px;font-weight:600}.d3v-sub{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.d3v-spacer{flex:1}
|
|
41
|
+
.d3v-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2,#555);background:var(--dsw-alias-interactive-bg-hover,#292c31);color:inherit;border-radius:8px;padding:7px 11px;cursor:pointer;font:inherit;font-size:13px}.d3v-btn:hover{filter:brightness(1.12)}.d3v-close{font-size:20px;padding:3px 9px}
|
|
42
|
+
.d3v-stage{position:relative;min-height:0}
|
|
43
|
+
.d3v-stage,.d3v-doc{background-color:#e9ecef;background-image:linear-gradient(rgba(15,23,42,.07) 1px,transparent 1px),linear-gradient(90deg,rgba(15,23,42,.07) 1px,transparent 1px);background-size:22px 22px}
|
|
44
|
+
@media(prefers-color-scheme:dark){.d3v-stage,.d3v-doc{background-color:#14171d;background-image:linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px)}}
|
|
45
|
+
.d3v-canvas{width:100%;height:100%;display:block;touch-action:none;cursor:grab}.d3v-canvas:active{cursor:grabbing}
|
|
46
|
+
.d3v-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}.d3v-empty-card{text-align:center;color:#4b5563}.d3v-empty-icon{font-size:52px;opacity:.7;margin-bottom:14px}.d3v-empty-note{font-size:13px;color:#6b7280;margin-top:7px}
|
|
47
|
+
@media(prefers-color-scheme:dark){.d3v-empty-card{color:#c8d0dc}.d3v-empty-note{color:#8993a3}}
|
|
48
|
+
.d3v-drop{outline:2px dashed #60a5fa;outline-offset:-10px}
|
|
49
|
+
.d3v-error{position:absolute;left:14px;right:14px;top:14px;padding:10px 12px;border-radius:9px;background:#591d25;color:#ffd6da;font-size:13px}
|
|
50
|
+
.d3v-help{position:absolute;right:13px;bottom:11px;color:#5b6472;background:rgba(129,138,150,.18);border-radius:7px;padding:5px 8px;font-size:11px;pointer-events:none}
|
|
51
|
+
@media(prefers-color-scheme:dark){.d3v-help{color:#9da8b8;background:rgba(10,12,17,.65)}}
|
|
52
|
+
.d3v-footer{min-height:44px;display:flex;align-items:center;gap:18px;padding:0 16px;border-top:1px solid var(--dsw-alias-border-l1,#333);font-size:12px;color:var(--dsw-alias-label-secondary,#bbb);flex-wrap:wrap}.d3v-stat b{color:var(--dsw-alias-label-primary,#eee);font-weight:500}.d3v-input{display:none}
|
|
53
|
+
.d3v-session-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2,#555);background:var(--dsw-alias-interactive-bg-hover,#292c31);color:var(--dsw-alias-label-primary,#eee);border-radius:8px;padding:5px 10px;cursor:pointer;font:inherit;font-size:12px}.d3v-session-btn:hover{filter:brightness(1.12)}
|
|
54
|
+
.d3v-drawer-wrap{position:fixed;inset:0;z-index:900;pointer-events:none}.d3v-drawer-backdrop{position:absolute;inset:0;background:rgba(5,10,20,.18);pointer-events:auto}.d3v-drawer{position:absolute;right:0;top:0;bottom:0;width:min(880px,72vw);min-width:560px;background:var(--dsw-specific-menu,#17191d);color:var(--dsw-alias-label-primary,#eee);border-left:1px solid var(--dsw-alias-border-l1,#444);box-shadow:-18px 0 55px rgba(0,0,0,.28);pointer-events:auto;display:grid;grid-template-rows:auto 1fr;overflow:hidden}.d3v-drawer-head{height:54px;display:flex;align-items:center;gap:10px;padding:0 13px;border-bottom:1px solid var(--dsw-alias-border-l1,#333)}.d3v-drawer-body{min-height:0;display:grid;grid-template-columns:240px minmax(0,1fr)}.d3v-browser{min-width:0;border-right:1px solid var(--dsw-alias-border-l1,#333);display:grid;grid-template-rows:auto 1fr auto;background:rgba(10,12,17,.16)}.d3v-search-wrap{padding:10px}.d3v-search{box-sizing:border-box;width:100%;height:34px;border:1px solid var(--dsw-alias-border-l2,#555);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,#292c31);color:inherit;font:inherit;padding:0 10px;outline:none}.d3v-search:focus{border-color:#60a5fa}.d3v-files{min-height:0;overflow-y:auto;padding:0 7px 8px;display:flex;flex-direction:column;gap:3px}.d3v-file{appearance:none;width:100%;border:0;border-radius:8px;background:transparent;color:inherit;text-align:left;padding:8px;cursor:pointer;display:grid;grid-template-columns:32px minmax(0,1fr);gap:8px}.d3v-file:hover{background:var(--dsw-alias-interactive-bg-hover,#292c31)}.d3v-file.active{background:rgba(59,130,246,.16);box-shadow:inset 2px 0 #60a5fa}.d3v-file-icon{width:32px;height:32px;border-radius:7px;background:rgba(96,165,250,.13);color:#93c5fd;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.d3v-file-main{min-width:0}.d3v-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:17px}.d3v-file-meta{color:var(--dsw-alias-label-tertiary,#8993a3);font-size:10px;line-height:15px;display:flex;gap:7px}.d3v-browser-foot{border-top:1px solid var(--dsw-alias-border-l1,#333);padding:8px 10px;color:var(--dsw-alias-label-tertiary,#8993a3);font-size:11px}.d3v-preview-column{min-width:0;min-height:0;display:grid;grid-template-rows:1fr auto}.d3v-live{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:#86efac}.d3v-live:before{content:'';width:6px;height:6px;border-radius:50%;background:#22c55e}.d3v-loading{position:absolute;left:12px;top:12px;color:#cbd5e1;background:rgba(10,12,17,.7);padding:6px 9px;border-radius:7px;font-size:12px}
|
|
55
|
+
@media(max-width:760px){.d3v-drawer{width:100vw;min-width:0}.d3v-drawer-body{grid-template-columns:150px minmax(0,1fr)}}
|
|
56
|
+
.d3v-doc{position:relative;box-sizing:border-box;width:100%;height:100%;min-height:420px;overflow:hidden}
|
|
57
|
+
.d3v-doc .d3v-canvas{position:absolute;inset:0}
|
|
58
|
+
.d3v-doc-status{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#4b5563;font-size:13px}
|
|
59
|
+
@media(prefers-color-scheme:dark){.d3v-doc-status{color:#c8d0dc}}
|
|
60
|
+
.d3v-doc-foot{position:absolute;left:12px;bottom:11px;display:flex;flex-direction:column;gap:4px;align-items:flex-start;z-index:2;max-width:calc(100% - 130px)}
|
|
61
|
+
.d3v-doc-stats{color:#3f4854;background:rgba(129,138,150,.18);border-radius:7px;padding:5px 9px;font-size:11px;pointer-events:none}
|
|
62
|
+
@media(prefers-color-scheme:dark){.d3v-doc-stats{color:#c8d0dc;background:rgba(10,12,17,.65)}}
|
|
63
|
+
.d3v-viewtools{position:absolute;top:11px;right:11px;display:flex;flex-direction:column;gap:4px;z-index:2;align-items:flex-end}
|
|
64
|
+
.d3v-views{display:flex;gap:3px;background:rgba(129,138,150,.18);border-radius:8px;padding:4px;flex-wrap:wrap;max-width:calc(100% - 20px);justify-content:flex-end}
|
|
65
|
+
@media(prefers-color-scheme:dark){.d3v-views{background:rgba(10,12,17,.65)}}
|
|
66
|
+
.d3v-view-btn{appearance:none;border:0;background:transparent;color:#3f4854;border-radius:6px;padding:4px 8px;cursor:pointer;font:inherit;font-size:11px;line-height:1.2;white-space:nowrap}
|
|
67
|
+
.d3v-view-btn:hover{background:rgba(96,165,250,.22);color:#173a63}
|
|
68
|
+
@media(prefers-color-scheme:dark){.d3v-view-btn{color:#c8d0dc}.d3v-view-btn:hover{color:#e8eef7}}
|
|
69
|
+
.d3v-colors{display:flex;gap:5px;align-items:center;background:rgba(129,138,150,.18);border-radius:8px;padding:5px}
|
|
70
|
+
@media(prefers-color-scheme:dark){.d3v-colors{background:rgba(10,12,17,.65)}}
|
|
71
|
+
.d3v-swatch{appearance:none;width:16px;height:16px;border-radius:50%;border:2px solid rgba(255,255,255,.18);cursor:pointer;padding:0}
|
|
72
|
+
.d3v-swatch:hover{transform:scale(1.15)}
|
|
73
|
+
.d3v-swatch.active{border-color:#e8eef7;box-shadow:0 0 0 1px rgba(0,0,0,.55)}
|
|
74
|
+
.d3v-color-native{width:22px;height:20px;padding:0;border:0;background:transparent;cursor:pointer}
|
|
75
|
+
.d3v-labels{position:absolute;inset:0;pointer-events:none;z-index:1;overflow:hidden}
|
|
76
|
+
.d3v-dim{position:absolute;transform:translate(-50%,-50%);font-size:11px;font-weight:600;color:#f1f5f9;background:rgba(10,12,17,.72);border-radius:5px;padding:2px 6px;white-space:nowrap;box-shadow:inset 0 -2px 0 transparent}
|
|
77
|
+
.d3v-dim-x{box-shadow:inset 0 -2px 0 #ef4444}
|
|
78
|
+
.d3v-dim-y{box-shadow:inset 0 -2px 0 #22c55e}
|
|
79
|
+
.d3v-dim-z{box-shadow:inset 0 -2px 0 #3b82f6}
|
|
80
|
+
.d3v-check{display:flex;gap:10px;align-items:center;background:rgba(129,138,150,.18);border-radius:7px;padding:4px 9px;font-size:11px;color:#3f4854;flex-wrap:wrap}
|
|
81
|
+
@media(prefers-color-scheme:dark){.d3v-check{background:rgba(10,12,17,.65);color:#cbd5e1}}
|
|
82
|
+
.d3v-check select{appearance:none;border:1px solid rgba(63,72,84,.4);background:rgba(255,255,255,.75);color:inherit;border-radius:5px;font:inherit;font-size:11px;padding:1px 4px}
|
|
83
|
+
@media(prefers-color-scheme:dark){.d3v-check select{border-color:#555;background:rgba(20,23,29,.9)}}
|
|
84
|
+
.d3v-check-warn{color:#dc2626;font-weight:600}
|
|
85
|
+
@media(prefers-color-scheme:dark){.d3v-check-warn{color:#fca5a5}}
|
|
86
|
+
.d3v-check-ok{color:#16a34a;font-weight:500}
|
|
87
|
+
@media(prefers-color-scheme:dark){.d3v-check-ok{color:#86efac}}
|
|
88
|
+
`;
|
|
89
|
+
function injectStyles() {
|
|
90
|
+
if (typeof document === "undefined") return;
|
|
91
|
+
if (document.querySelector('style[data-plugin-css="dsh-3d-model-viewer"]')) return;
|
|
92
|
+
const tag = document.createElement("style");
|
|
93
|
+
tag.dataset.pluginCss = "dsh-3d-model-viewer";
|
|
94
|
+
tag.textContent = CSS;
|
|
95
|
+
document.head.appendChild(tag);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/parse.js
|
|
99
|
+
function toArrayBuffer(data) {
|
|
100
|
+
if (data instanceof ArrayBuffer) return data;
|
|
101
|
+
if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
102
|
+
if (data && typeof data === "object" && typeof data.byteLength === "number" && data.buffer) {
|
|
103
|
+
return toArrayBuffer(data.buffer.slice(data.byteOffset ?? 0, (data.byteOffset ?? 0) + data.byteLength));
|
|
104
|
+
}
|
|
105
|
+
throw Error("\u65E0\u6CD5\u8BC6\u522B\u7684\u6587\u4EF6\u5185\u5BB9\u7C7B\u578B");
|
|
106
|
+
}
|
|
107
|
+
function finishMesh(data, triangles) {
|
|
108
|
+
let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
109
|
+
for (let i = 0; i < data.length; i += 6) {
|
|
110
|
+
for (let j = 0; j < 3; j++) {
|
|
111
|
+
lo[j] = Math.min(lo[j], data[i + j]);
|
|
112
|
+
hi[j] = Math.max(hi[j], data[i + j]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isFinite(lo[0])) throw Error("\u6A21\u578B\u5750\u6807\u65E0\u6548");
|
|
116
|
+
const center = lo.map((x, i) => (x + hi[i]) / 2);
|
|
117
|
+
const size = lo.map((x, i) => hi[i] - x);
|
|
118
|
+
const radius = Math.hypot(...size) / 2 || 1;
|
|
119
|
+
const volume = meshVolume(data);
|
|
120
|
+
return { data, triangles, lo, hi, size, center, radius, volume };
|
|
121
|
+
}
|
|
122
|
+
function meshVolume(data) {
|
|
123
|
+
let v = 0;
|
|
124
|
+
for (let i = 0; i < data.length; i += 18) {
|
|
125
|
+
const ax = data[i], ay = data[i + 1], az = data[i + 2];
|
|
126
|
+
const bx = data[i + 6], by = data[i + 7], bz = data[i + 8];
|
|
127
|
+
const cx = data[i + 12], cy = data[i + 13], cz = data[i + 14];
|
|
128
|
+
v += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx);
|
|
129
|
+
}
|
|
130
|
+
return Math.abs(v / 6);
|
|
131
|
+
}
|
|
132
|
+
function parseBinaryStl(buf) {
|
|
133
|
+
const dv = new DataView(buf);
|
|
134
|
+
if (buf.byteLength < 84) throw Error("STL \u6587\u4EF6\u8FC7\u77ED");
|
|
135
|
+
const n = dv.getUint32(80, true);
|
|
136
|
+
if (n > 5e6) throw Error("\u6A21\u578B\u8D85\u8FC7 500 \u4E07\u4E09\u89D2\u9762\u9650\u5236");
|
|
137
|
+
if (84 + n * 50 > buf.byteLength) throw Error("STL \u4E09\u89D2\u9762\u6570\u636E\u4E0D\u5B8C\u6574");
|
|
138
|
+
const out = new Float32Array(n * 18);
|
|
139
|
+
let p = 0;
|
|
140
|
+
for (let i = 0, o = 84; i < n; i++, o += 50) {
|
|
141
|
+
let nx = dv.getFloat32(o, true), ny = dv.getFloat32(o + 4, true), nz = dv.getFloat32(o + 8, true);
|
|
142
|
+
const ax = dv.getFloat32(o + 12, true), ay = dv.getFloat32(o + 16, true), az = dv.getFloat32(o + 20, true);
|
|
143
|
+
const bx = dv.getFloat32(o + 24, true), by = dv.getFloat32(o + 28, true), bz = dv.getFloat32(o + 32, true);
|
|
144
|
+
const cx = dv.getFloat32(o + 36, true), cy = dv.getFloat32(o + 40, true), cz = dv.getFloat32(o + 44, true);
|
|
145
|
+
if (!Number.isFinite(nx + ny + nz) || Math.hypot(nx, ny, nz) < 1e-8) {
|
|
146
|
+
const ux = bx - ax, uy = by - ay, uz = bz - az, vx = cx - ax, vy = cy - ay, vz = cz - az;
|
|
147
|
+
nx = uy * vz - uz * vy;
|
|
148
|
+
ny = uz * vx - ux * vz;
|
|
149
|
+
nz = ux * vy - uy * vx;
|
|
150
|
+
}
|
|
151
|
+
const l = Math.hypot(nx, ny, nz) || 1;
|
|
152
|
+
nx /= l;
|
|
153
|
+
ny /= l;
|
|
154
|
+
nz /= l;
|
|
155
|
+
for (const v of [[ax, ay, az], [bx, by, bz], [cx, cy, cz]]) {
|
|
156
|
+
out[p++] = v[0];
|
|
157
|
+
out[p++] = v[1];
|
|
158
|
+
out[p++] = v[2];
|
|
159
|
+
out[p++] = nx;
|
|
160
|
+
out[p++] = ny;
|
|
161
|
+
out[p++] = nz;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return finishMesh(out, n);
|
|
165
|
+
}
|
|
166
|
+
function parseAsciiStl(buf) {
|
|
167
|
+
const text = new TextDecoder().decode(buf);
|
|
168
|
+
const re = /facet\s+normal\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)[\s\S]*?outer\s+loop([\s\S]*?)endloop/gi;
|
|
169
|
+
const a = [];
|
|
170
|
+
let m;
|
|
171
|
+
while (m = re.exec(text)) {
|
|
172
|
+
const n = [+m[1], +m[2], +m[3]];
|
|
173
|
+
const verts = [...m[4].matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)].slice(0, 3);
|
|
174
|
+
if (verts.length === 3) for (const v of verts) a.push(+v[1], +v[2], +v[3], ...n);
|
|
175
|
+
}
|
|
176
|
+
if (!a.length) throw Error("\u65E0\u6CD5\u8BC6\u522B ASCII STL");
|
|
177
|
+
return finishMesh(new Float32Array(a), a.length / 18);
|
|
178
|
+
}
|
|
179
|
+
function parseStl(buf) {
|
|
180
|
+
if (buf.byteLength >= 84) {
|
|
181
|
+
const n = new DataView(buf).getUint32(80, true);
|
|
182
|
+
if (n > 0 && 84 + n * 50 === buf.byteLength) return parseBinaryStl(buf);
|
|
183
|
+
const head = new TextDecoder().decode(buf.slice(0, 512));
|
|
184
|
+
if (/^\s*solid/i.test(head) && /facet/i.test(head)) return parseAsciiStl(buf);
|
|
185
|
+
if (n > 0 && 84 + n * 50 <= buf.byteLength) return parseBinaryStl(buf);
|
|
186
|
+
}
|
|
187
|
+
return parseAsciiStl(buf);
|
|
188
|
+
}
|
|
189
|
+
async function unzip(buf) {
|
|
190
|
+
const u = new Uint8Array(buf), dv = new DataView(buf), u64 = (o) => Number(dv.getBigUint64(o, true));
|
|
191
|
+
let e = -1;
|
|
192
|
+
for (let i = u.length - 22; i >= Math.max(0, u.length - 65557); i--) if (dv.getUint32(i, true) === 101010256) {
|
|
193
|
+
e = i;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (e < 0) throw Error("3MF ZIP \u76EE\u5F55\u7F3A\u5931");
|
|
197
|
+
let count = dv.getUint16(e + 10, true), cd = dv.getUint32(e + 16, true);
|
|
198
|
+
if (cd === 4294967295 || count === 65535) {
|
|
199
|
+
const loc = e - 20;
|
|
200
|
+
if (loc < 0 || dv.getUint32(loc, true) !== 117853008) throw Error("3MF ZIP64 \u5B9A\u4F4D\u5668\u7F3A\u5931");
|
|
201
|
+
const z = u64(loc + 8);
|
|
202
|
+
if (dv.getUint32(z, true) !== 101075792) throw Error("3MF ZIP64 \u76EE\u5F55\u635F\u574F");
|
|
203
|
+
count = u64(z + 32);
|
|
204
|
+
cd = u64(z + 48);
|
|
205
|
+
}
|
|
206
|
+
const files = [];
|
|
207
|
+
let p = cd;
|
|
208
|
+
for (let i = 0; i < count; i++) {
|
|
209
|
+
if (dv.getUint32(p, true) !== 33639248) throw Error("3MF ZIP \u76EE\u5F55\u635F\u574F");
|
|
210
|
+
const method = dv.getUint16(p + 10, true), rawSize = dv.getUint32(p + 20, true), unSize = dv.getUint32(p + 24, true);
|
|
211
|
+
const nl = dv.getUint16(p + 28, true), xl = dv.getUint16(p + 30, true), cl = dv.getUint16(p + 32, true);
|
|
212
|
+
const rawLo = dv.getUint32(p + 42, true);
|
|
213
|
+
const name = new TextDecoder().decode(u.slice(p + 46, p + 46 + nl));
|
|
214
|
+
let size = rawSize, lo = rawLo, ep = p + 46 + nl, end = ep + xl;
|
|
215
|
+
while (ep + 4 <= end) {
|
|
216
|
+
const tag = dv.getUint16(ep, true), len = dv.getUint16(ep + 2, true), q = ep + 4;
|
|
217
|
+
if (tag === 1) {
|
|
218
|
+
let k = q;
|
|
219
|
+
if (unSize === 4294967295) k += 8;
|
|
220
|
+
if (rawSize === 4294967295) {
|
|
221
|
+
size = u64(k);
|
|
222
|
+
k += 8;
|
|
223
|
+
}
|
|
224
|
+
if (rawLo === 4294967295) lo = u64(k);
|
|
225
|
+
}
|
|
226
|
+
ep = q + len;
|
|
227
|
+
}
|
|
228
|
+
const lnl = dv.getUint16(lo + 26, true), lxl = dv.getUint16(lo + 28, true), start = lo + 30 + lnl + lxl;
|
|
229
|
+
const raw = u.slice(start, start + size);
|
|
230
|
+
let data;
|
|
231
|
+
if (method === 0) data = raw;
|
|
232
|
+
else if (method === 8) {
|
|
233
|
+
if (typeof DecompressionStream === "undefined") throw Error("\u6D4F\u89C8\u5668\u4E0D\u652F\u6301 3MF \u89E3\u538B");
|
|
234
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
|
235
|
+
data = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
236
|
+
} else throw Error("\u4E0D\u652F\u6301\u7684 ZIP \u538B\u7F29\u65B9\u5F0F " + method);
|
|
237
|
+
files.push({ name, data });
|
|
238
|
+
p += 46 + nl + xl + cl;
|
|
239
|
+
}
|
|
240
|
+
return files;
|
|
241
|
+
}
|
|
242
|
+
function parseHexColor(value) {
|
|
243
|
+
if (typeof value !== "string") return null;
|
|
244
|
+
const m = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})([0-9a-f]{2})?$/i);
|
|
245
|
+
if (!m) return null;
|
|
246
|
+
let hex = m[1];
|
|
247
|
+
if (hex.length === 3) hex = [...hex].map((c) => c + c).join("");
|
|
248
|
+
return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
249
|
+
}
|
|
250
|
+
async function parse3mf(buf) {
|
|
251
|
+
const files = await unzip(buf);
|
|
252
|
+
const models = files.filter((f) => /\.model$/i.test(f.name));
|
|
253
|
+
if (!models.length) throw Error("3MF \u4E2D\u6CA1\u6709 .model \u7F51\u683C");
|
|
254
|
+
const vals = [], cols = [];
|
|
255
|
+
let triangles = 0, sawColor = false;
|
|
256
|
+
for (const f of models) {
|
|
257
|
+
const doc = new DOMParser().parseFromString(new TextDecoder().decode(f.data), "application/xml");
|
|
258
|
+
if (doc.querySelector("parsererror")) continue;
|
|
259
|
+
const factor = { micron: 1e-3, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1e3 }[doc.documentElement.getAttribute("unit")] || 1;
|
|
260
|
+
const materialMap = {};
|
|
261
|
+
for (const bm of doc.getElementsByTagNameNS("*", "basematerials")) {
|
|
262
|
+
materialMap[bm.getAttribute("id")] = [...bm.getElementsByTagNameNS("*", "base")].map((base) => parseHexColor(base.getAttribute("color")));
|
|
263
|
+
}
|
|
264
|
+
const emit = (mesh2, defaultColor, materialColors) => {
|
|
265
|
+
const verts = [...mesh2.getElementsByTagNameNS("*", "vertex")].map((v) => [+v.getAttribute("x") * factor, +v.getAttribute("y") * factor, +v.getAttribute("z") * factor]);
|
|
266
|
+
for (const t of mesh2.getElementsByTagNameNS("*", "triangle")) {
|
|
267
|
+
const ids = [+t.getAttribute("v1"), +t.getAttribute("v2"), +t.getAttribute("v3")];
|
|
268
|
+
const a = verts[ids[0]], b = verts[ids[1]], c = verts[ids[2]];
|
|
269
|
+
if (!a || !b || !c) continue;
|
|
270
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2], vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
271
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, l = Math.hypot(nx, ny, nz) || 1;
|
|
272
|
+
for (const v of [a, b, c]) vals.push(v[0], v[1], v[2], nx / l, ny / l, nz / l);
|
|
273
|
+
let color = defaultColor;
|
|
274
|
+
const matid = t.getAttribute("matid");
|
|
275
|
+
if (materialColors && matid !== null && materialColors[+matid - 1]) color = materialColors[+matid - 1];
|
|
276
|
+
if (color) sawColor = true;
|
|
277
|
+
if (color) cols.push(color[0], color[1], color[2], color[0], color[1], color[2], color[0], color[1], color[2]);
|
|
278
|
+
else cols.push(0, 0, 0, 0, 0, 0, 0, 0, 0);
|
|
279
|
+
triangles++;
|
|
280
|
+
if (triangles > 5e6) throw Error("\u6A21\u578B\u8D85\u8FC7 500 \u4E07\u4E09\u89D2\u9762\u9650\u5236");
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const objects = [...doc.getElementsByTagNameNS("*", "object")];
|
|
284
|
+
for (const obj of objects) {
|
|
285
|
+
const materialColors = materialMap[obj.getAttribute("pid")] || null;
|
|
286
|
+
const objColor = materialColors ? materialColors[+(obj.getAttribute("pindex") || 0)] || null : null;
|
|
287
|
+
for (const mesh2 of obj.getElementsByTagNameNS("*", "mesh")) emit(mesh2, objColor, materialColors);
|
|
288
|
+
}
|
|
289
|
+
if (!objects.length) for (const mesh2 of doc.getElementsByTagNameNS("*", "mesh")) emit(mesh2, null, null);
|
|
290
|
+
}
|
|
291
|
+
if (!triangles) throw Error("3MF \u4E2D\u6CA1\u6709\u53EF\u663E\u793A\u7684\u4E09\u89D2\u7F51\u683C");
|
|
292
|
+
const mesh = finishMesh(new Float32Array(vals), triangles);
|
|
293
|
+
if (sawColor) mesh.colors = new Float32Array(cols);
|
|
294
|
+
return mesh;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/viewer.js
|
|
298
|
+
var VS = `attribute vec3 p;attribute vec3 n;attribute vec3 col;uniform mat4 mvp;uniform mat4 model;varying vec3 vn;varying vec3 vc;void main(){gl_Position=mvp*vec4(p,1.);vn=mat3(model)*n;vc=col;}`;
|
|
299
|
+
var FS = `precision mediump float;varying vec3 vn;varying vec3 vc;uniform vec3 uColor;uniform float uUseVColor;void main(){vec3 N=normalize(vn);vec3 L=normalize(vec3(.5,.8,1.));float d=max(dot(N,L),0.);float rim=pow(1.-abs(N.z),2.);vec3 base=mix(uColor,vc,uUseVColor);vec3 c=base*(.28+.72*d)+rim*vec3(.08,.18,.28);gl_FragColor=vec4(c,1.);}`;
|
|
300
|
+
var DEFAULT_COLOR = [0.18, 0.62, 0.95];
|
|
301
|
+
var COLOR_PRESETS = [["\u84DD", [0.18, 0.62, 0.95]], ["\u6A59", [0.95, 0.55, 0.18]], ["\u7EFF", [0.25, 0.7, 0.4]], ["\u7EA2", [0.9, 0.3, 0.3]], ["\u7D2B", [0.6, 0.4, 0.9]], ["\u7070", [0.62, 0.65, 0.68]], ["\u767D", [0.93, 0.94, 0.96]]];
|
|
302
|
+
var VIEW_PRESETS = [
|
|
303
|
+
["front", "\u524D", [0, Math.PI / 2, true]],
|
|
304
|
+
["back", "\u540E", [Math.PI, Math.PI / 2, true]],
|
|
305
|
+
["left", "\u5DE6", [-Math.PI / 2, 0, true]],
|
|
306
|
+
["right", "\u53F3", [Math.PI / 2, 0, true]],
|
|
307
|
+
["top", "\u9876", [0, 0, true]],
|
|
308
|
+
["bottom", "\u5E95", [0, Math.PI, true]],
|
|
309
|
+
["iso", "\u7B49\u8F74\u6D4B", [Math.PI / 4, -0.6155, false]]
|
|
310
|
+
];
|
|
311
|
+
var PRINTER_PROFILES = [
|
|
312
|
+
["X2D", 256, 256, 260],
|
|
313
|
+
["H2D", 350, 320, 325],
|
|
314
|
+
["X1C / P1", 256, 256, 256],
|
|
315
|
+
["A1", 256, 210, 210],
|
|
316
|
+
["A1 mini", 180, 180, 180]
|
|
317
|
+
];
|
|
318
|
+
var MATERIALS = [["PLA", 1.24], ["PETG", 1.27], ["PC", 1.2], ["ABS", 1.04], ["TPU", 1.21]];
|
|
319
|
+
var INFILL_OPTIONS = [10, 15, 20, 30, 50, 100];
|
|
320
|
+
var rgbCss = (c) => `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
|
|
321
|
+
var hexCss = (c) => "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("");
|
|
322
|
+
var sameColor = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === 3 && a.every((v, i) => Math.abs(v - b[i]) < 1e-3);
|
|
323
|
+
var SCREENSHOT_EVENT = "dsh-3d-model-shot";
|
|
324
|
+
function shader(gl, type, src) {
|
|
325
|
+
const s = gl.createShader(type);
|
|
326
|
+
gl.shaderSource(s, src);
|
|
327
|
+
gl.compileShader(s);
|
|
328
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error(gl.getShaderInfoLog(s) || "\u7740\u8272\u5668\u7F16\u8BD1\u5931\u8D25");
|
|
329
|
+
return s;
|
|
330
|
+
}
|
|
331
|
+
var mul = (a, b) => {
|
|
332
|
+
const o = new Float32Array(16);
|
|
333
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++)
|
|
334
|
+
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
|
335
|
+
return o;
|
|
336
|
+
};
|
|
337
|
+
var rx = (a) => new Float32Array([1, 0, 0, 0, 0, Math.cos(a), Math.sin(a), 0, 0, -Math.sin(a), Math.cos(a), 0, 0, 0, 0, 1]);
|
|
338
|
+
var ry = (a) => new Float32Array([Math.cos(a), 0, -Math.sin(a), 0, 0, 1, 0, 0, Math.sin(a), 0, Math.cos(a), 0, 0, 0, 0, 1]);
|
|
339
|
+
var buildRot = (yaw, pitch) => mul(ry(yaw), rx(pitch));
|
|
340
|
+
var DEFAULT_ROT = () => buildRot(0.65, -0.45);
|
|
341
|
+
var orthoHalfSize = (zoom) => 1.15 * (zoom / 5.5);
|
|
342
|
+
function perspective(aspect) {
|
|
343
|
+
const f = 1 / Math.tan(Math.PI / 8), near = 0.01, far = 100;
|
|
344
|
+
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, 2 * far * near / (near - far), 0]);
|
|
345
|
+
}
|
|
346
|
+
function orthographic(aspect, k) {
|
|
347
|
+
const near = 0.01, far = 100;
|
|
348
|
+
return new Float32Array([1 / (k * aspect), 0, 0, 0, 0, 1 / k, 0, 0, 0, 0, -2 / (far - near), 0, 0, 0, -(far + near) / (far - near), 1]);
|
|
349
|
+
}
|
|
350
|
+
function projectPoint(mvp, p, w, hh) {
|
|
351
|
+
const x = p[0], y = p[1], z = p[2];
|
|
352
|
+
const cx = mvp[0] * x + mvp[4] * y + mvp[8] * z + mvp[12];
|
|
353
|
+
const cy = mvp[1] * x + mvp[5] * y + mvp[9] * z + mvp[13];
|
|
354
|
+
const cw = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15] || 1;
|
|
355
|
+
return [(cx / cw * 0.5 + 0.5) * w, (0.5 - cy / cw * 0.5) * hh];
|
|
356
|
+
}
|
|
357
|
+
function ViewPresetBar({ onPick, color, onPickColor }) {
|
|
358
|
+
return h(
|
|
359
|
+
"div",
|
|
360
|
+
{ className: "d3v-viewtools", "data-dsh-3d-views": "" },
|
|
361
|
+
h(
|
|
362
|
+
"div",
|
|
363
|
+
{ className: "d3v-views" },
|
|
364
|
+
VIEW_PRESETS.map(([k, label, ang]) => h("button", {
|
|
365
|
+
key: k,
|
|
366
|
+
type: "button",
|
|
367
|
+
className: "d3v-view-btn",
|
|
368
|
+
title: `${label}\uFF08${ang[2] ? "\u6B63\u4EA4" : "\u900F\u89C6"}\uFF0C\u5FEB\u6377\u952E ${VIEW_PRESETS.findIndex((p) => p[0] === k) + 1}\uFF09`,
|
|
369
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
370
|
+
onClick: () => onPick(ang[0], ang[1], ang[2])
|
|
371
|
+
}, label)),
|
|
372
|
+
h("button", {
|
|
373
|
+
type: "button",
|
|
374
|
+
className: "d3v-view-btn",
|
|
375
|
+
title: "\u5BFC\u51FA\u5F53\u524D\u89C6\u56FE\u4E3A PNG",
|
|
376
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
377
|
+
onClick: () => {
|
|
378
|
+
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(SCREENSHOT_EVENT));
|
|
379
|
+
}
|
|
380
|
+
}, "\u622A\u56FE")
|
|
381
|
+
),
|
|
382
|
+
onPickColor && h(
|
|
383
|
+
"div",
|
|
384
|
+
{ className: "d3v-colors" },
|
|
385
|
+
COLOR_PRESETS.map(([label, c]) => h("button", {
|
|
386
|
+
key: label,
|
|
387
|
+
type: "button",
|
|
388
|
+
className: "d3v-swatch" + (sameColor(color, c) ? " active" : ""),
|
|
389
|
+
title: label,
|
|
390
|
+
style: { background: rgbCss(c) },
|
|
391
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
392
|
+
onClick: () => onPickColor(c)
|
|
393
|
+
})),
|
|
394
|
+
h("input", {
|
|
395
|
+
type: "color",
|
|
396
|
+
className: "d3v-color-native",
|
|
397
|
+
title: "\u81EA\u5B9A\u4E49\u989C\u8272",
|
|
398
|
+
"aria-label": "\u81EA\u5B9A\u4E49\u6A21\u578B\u989C\u8272",
|
|
399
|
+
value: hexCss(color),
|
|
400
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
401
|
+
onChange: (e) => {
|
|
402
|
+
const m = e.target.value.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
403
|
+
if (m) onPickColor([parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255]);
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
var DIM_ANCHORS = (lo, hi, margin) => [
|
|
410
|
+
["x", [(lo[0] + hi[0]) / 2, lo[1] - margin, lo[2]]],
|
|
411
|
+
["y", [hi[0] + margin, (lo[1] + hi[1]) / 2, lo[2]]],
|
|
412
|
+
["z", [hi[0] + margin, lo[1], (lo[2] + hi[2]) / 2]]
|
|
413
|
+
];
|
|
414
|
+
function Viewer({ mesh, resetToken, view, color }) {
|
|
415
|
+
const ref = React.useRef(null);
|
|
416
|
+
const orient = React.useRef({ rot: DEFAULT_ROT(), ortho: false });
|
|
417
|
+
const pan = React.useRef([0, 0]);
|
|
418
|
+
const redraw = React.useRef(() => {
|
|
419
|
+
});
|
|
420
|
+
const colorRef = React.useRef(null);
|
|
421
|
+
React.useEffect(() => {
|
|
422
|
+
if (!view) return;
|
|
423
|
+
orient.current = { rot: buildRot(view.yaw, view.pitch), ortho: !!view.ortho };
|
|
424
|
+
pan.current = [0, 0];
|
|
425
|
+
redraw.current();
|
|
426
|
+
}, [view]);
|
|
427
|
+
React.useEffect(() => {
|
|
428
|
+
if (!color) return;
|
|
429
|
+
colorRef.current = color;
|
|
430
|
+
redraw.current();
|
|
431
|
+
}, [color]);
|
|
432
|
+
React.useEffect(() => {
|
|
433
|
+
const canvas = ref.current;
|
|
434
|
+
if (!canvas || !mesh) return;
|
|
435
|
+
const gl = canvas.getContext("webgl", { antialias: true, alpha: true });
|
|
436
|
+
if (!gl) return;
|
|
437
|
+
let program, buff, colBuff = null, raf = 0, down = false, mode = "rotate", lx = 0, ly = 0, zoom = 5.5, shotPending = false;
|
|
438
|
+
let lastModel = null;
|
|
439
|
+
pan.current = [0, 0];
|
|
440
|
+
const host = canvas.parentElement;
|
|
441
|
+
const labelsBox = document.createElement("div");
|
|
442
|
+
labelsBox.className = "d3v-labels";
|
|
443
|
+
const labelEls = {};
|
|
444
|
+
for (const axis of ["x", "y", "z"]) {
|
|
445
|
+
const el = document.createElement("span");
|
|
446
|
+
el.className = "d3v-dim d3v-dim-" + axis;
|
|
447
|
+
el.style.display = "none";
|
|
448
|
+
labelsBox.appendChild(el);
|
|
449
|
+
labelEls[axis] = el;
|
|
450
|
+
}
|
|
451
|
+
if (host) host.appendChild(labelsBox);
|
|
452
|
+
try {
|
|
453
|
+
program = gl.createProgram();
|
|
454
|
+
gl.attachShader(program, shader(gl, gl.VERTEX_SHADER, VS));
|
|
455
|
+
gl.attachShader(program, shader(gl, gl.FRAGMENT_SHADER, FS));
|
|
456
|
+
gl.linkProgram(program);
|
|
457
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error(gl.getProgramInfoLog(program) || "\u7740\u8272\u5668\u94FE\u63A5\u5931\u8D25");
|
|
458
|
+
buff = gl.createBuffer();
|
|
459
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buff);
|
|
460
|
+
gl.bufferData(gl.ARRAY_BUFFER, mesh.data, gl.STATIC_DRAW);
|
|
461
|
+
const ps = gl.getAttribLocation(program, "p"), ns = gl.getAttribLocation(program, "n"), cs = gl.getAttribLocation(program, "col");
|
|
462
|
+
gl.enableVertexAttribArray(ps);
|
|
463
|
+
gl.vertexAttribPointer(ps, 3, gl.FLOAT, false, 24, 0);
|
|
464
|
+
gl.enableVertexAttribArray(ns);
|
|
465
|
+
gl.vertexAttribPointer(ns, 3, gl.FLOAT, false, 24, 12);
|
|
466
|
+
if (mesh.colors) {
|
|
467
|
+
colBuff = gl.createBuffer();
|
|
468
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, colBuff);
|
|
469
|
+
gl.bufferData(gl.ARRAY_BUFFER, mesh.colors, gl.STATIC_DRAW);
|
|
470
|
+
gl.enableVertexAttribArray(cs);
|
|
471
|
+
gl.vertexAttribPointer(cs, 3, gl.FLOAT, false, 12, 0);
|
|
472
|
+
}
|
|
473
|
+
} catch (e) {
|
|
474
|
+
console.error(e);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const savePng = () => {
|
|
478
|
+
canvas.toBlob((blob) => {
|
|
479
|
+
if (!blob) return;
|
|
480
|
+
const a = document.createElement("a");
|
|
481
|
+
a.href = URL.createObjectURL(blob);
|
|
482
|
+
a.download = `${mesh.name || "model"}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "")}.png`;
|
|
483
|
+
a.click();
|
|
484
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5e3);
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
const draw = () => {
|
|
488
|
+
raf = 0;
|
|
489
|
+
const o = orient.current, rot = o.rot;
|
|
490
|
+
const d = Math.min(devicePixelRatio || 1, 2);
|
|
491
|
+
const w = Math.max(1, Math.round(canvas.clientWidth * d)), hh = Math.max(1, Math.round(canvas.clientHeight * d));
|
|
492
|
+
if (canvas.width !== w || canvas.height !== hh) {
|
|
493
|
+
canvas.width = w;
|
|
494
|
+
canvas.height = hh;
|
|
495
|
+
}
|
|
496
|
+
gl.viewport(0, 0, w, hh);
|
|
497
|
+
gl.enable(gl.DEPTH_TEST);
|
|
498
|
+
gl.enable(gl.CULL_FACE);
|
|
499
|
+
gl.clearColor(0, 0, 0, 0);
|
|
500
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
501
|
+
const aspect = w / hh;
|
|
502
|
+
const proj = o.ortho ? orthographic(aspect, orthoHalfSize(zoom)) : perspective(aspect);
|
|
503
|
+
const scale = 1 / mesh.radius;
|
|
504
|
+
const model = new Float32Array(rot);
|
|
505
|
+
for (const idx of [0, 1, 2, 4, 5, 6, 8, 9, 10]) model[idx] *= scale;
|
|
506
|
+
model[12] = -(model[0] * mesh.center[0] + model[4] * mesh.center[1] + model[8] * mesh.center[2]) + pan.current[0];
|
|
507
|
+
model[13] = -(model[1] * mesh.center[0] + model[5] * mesh.center[1] + model[9] * mesh.center[2]) + pan.current[1];
|
|
508
|
+
model[14] = -(model[2] * mesh.center[0] + model[6] * mesh.center[1] + model[10] * mesh.center[2]) - zoom;
|
|
509
|
+
lastModel = model;
|
|
510
|
+
gl.useProgram(program);
|
|
511
|
+
const col = colorRef.current || DEFAULT_COLOR;
|
|
512
|
+
gl.uniform3f(gl.getUniformLocation(program, "uColor"), col[0], col[1], col[2]);
|
|
513
|
+
gl.uniform1f(gl.getUniformLocation(program, "uUseVColor"), mesh.colors ? 1 : 0);
|
|
514
|
+
gl.uniformMatrix4fv(gl.getUniformLocation(program, "model"), false, model);
|
|
515
|
+
const mvp = mul(proj, model);
|
|
516
|
+
gl.uniformMatrix4fv(gl.getUniformLocation(program, "mvp"), false, mvp);
|
|
517
|
+
gl.drawArrays(gl.TRIANGLES, 0, mesh.data.length / 6);
|
|
518
|
+
if (o.ortho) {
|
|
519
|
+
const m = mesh.radius * 0.18;
|
|
520
|
+
const inPlane = {
|
|
521
|
+
x: Math.abs(rot[2]) < 0.9,
|
|
522
|
+
y: Math.abs(rot[6]) < 0.9,
|
|
523
|
+
z: Math.abs(rot[10]) < 0.9
|
|
524
|
+
};
|
|
525
|
+
const anchors = DIM_ANCHORS(mesh.lo, mesh.hi, m);
|
|
526
|
+
for (const [axis, point] of anchors) {
|
|
527
|
+
const el = labelEls[axis];
|
|
528
|
+
if (!inPlane[axis]) {
|
|
529
|
+
el.style.display = "none";
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const [sx, sy] = projectPoint(mvp, point, canvas.clientWidth, canvas.clientHeight);
|
|
533
|
+
el.style.display = "";
|
|
534
|
+
el.style.left = sx + "px";
|
|
535
|
+
el.style.top = sy + "px";
|
|
536
|
+
el.textContent = mesh.size[axis === "x" ? 0 : axis === "y" ? 1 : 2].toFixed(2);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
for (const el of Object.values(labelEls)) el.style.display = "none";
|
|
540
|
+
}
|
|
541
|
+
if (shotPending) {
|
|
542
|
+
shotPending = false;
|
|
543
|
+
savePng();
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
const request = () => {
|
|
547
|
+
if (!raf) raf = requestAnimationFrame(draw);
|
|
548
|
+
};
|
|
549
|
+
redraw.current = request;
|
|
550
|
+
const panScale = () => {
|
|
551
|
+
const rect = canvas.getBoundingClientRect();
|
|
552
|
+
const hpx = rect.height || 1;
|
|
553
|
+
return orient.current.ortho ? 2 * orthoHalfSize(zoom) / hpx : 2 * Math.tan(Math.PI / 8) * zoom / hpx;
|
|
554
|
+
};
|
|
555
|
+
const pd = (e) => {
|
|
556
|
+
down = true;
|
|
557
|
+
lx = e.clientX;
|
|
558
|
+
ly = e.clientY;
|
|
559
|
+
mode = e.button === 2 || e.button === 1 || e.shiftKey ? "pan" : "rotate";
|
|
560
|
+
canvas.style.cursor = mode === "rotate" ? "grabbing" : "move";
|
|
561
|
+
canvas.setPointerCapture(e.pointerId);
|
|
562
|
+
};
|
|
563
|
+
const pm = (e) => {
|
|
564
|
+
if (!down) return;
|
|
565
|
+
const dx = e.clientX - lx, dy = e.clientY - ly;
|
|
566
|
+
lx = e.clientX;
|
|
567
|
+
ly = e.clientY;
|
|
568
|
+
if (mode === "rotate") {
|
|
569
|
+
const k = 0.01;
|
|
570
|
+
orient.current.rot = mul(ry(dx * k), mul(rx(dy * k), orient.current.rot));
|
|
571
|
+
orient.current.ortho = false;
|
|
572
|
+
} else {
|
|
573
|
+
const s = panScale();
|
|
574
|
+
pan.current[0] += dx * s;
|
|
575
|
+
pan.current[1] -= dy * s;
|
|
576
|
+
}
|
|
577
|
+
request();
|
|
578
|
+
};
|
|
579
|
+
const pu = () => {
|
|
580
|
+
down = false;
|
|
581
|
+
canvas.style.cursor = "";
|
|
582
|
+
};
|
|
583
|
+
const wh = (e) => {
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
zoom = Math.max(1.2, Math.min(20, zoom * Math.exp(e.deltaY * 1e-3)));
|
|
586
|
+
request();
|
|
587
|
+
};
|
|
588
|
+
const applyPreset = ([, , [yaw, pitch, ortho]]) => {
|
|
589
|
+
orient.current = { rot: buildRot(yaw, pitch), ortho };
|
|
590
|
+
pan.current = [0, 0];
|
|
591
|
+
request();
|
|
592
|
+
};
|
|
593
|
+
const reset = () => {
|
|
594
|
+
orient.current = { rot: DEFAULT_ROT(), ortho: false };
|
|
595
|
+
pan.current = [0, 0];
|
|
596
|
+
request();
|
|
597
|
+
};
|
|
598
|
+
const key = (e) => {
|
|
599
|
+
const idx = parseInt(e.key, 10);
|
|
600
|
+
if (idx >= 1 && idx <= VIEW_PRESETS.length) {
|
|
601
|
+
e.preventDefault();
|
|
602
|
+
applyPreset(VIEW_PRESETS[idx - 1]);
|
|
603
|
+
} else if (e.key === "r" || e.key === "R") {
|
|
604
|
+
e.preventDefault();
|
|
605
|
+
reset();
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const shot = () => {
|
|
609
|
+
if (canvas.clientWidth === 0) return;
|
|
610
|
+
shotPending = true;
|
|
611
|
+
request();
|
|
612
|
+
};
|
|
613
|
+
const noMenu = (e) => e.preventDefault();
|
|
614
|
+
const dbl = () => {
|
|
615
|
+
orient.current = { rot: DEFAULT_ROT(), ortho: false };
|
|
616
|
+
pan.current = [0, 0];
|
|
617
|
+
request();
|
|
618
|
+
};
|
|
619
|
+
canvas.addEventListener("contextmenu", noMenu);
|
|
620
|
+
canvas.addEventListener("dblclick", dbl);
|
|
621
|
+
canvas.addEventListener("pointerdown", pd);
|
|
622
|
+
canvas.addEventListener("pointermove", pm);
|
|
623
|
+
canvas.addEventListener("pointerup", pu);
|
|
624
|
+
canvas.addEventListener("pointercancel", pu);
|
|
625
|
+
canvas.addEventListener("wheel", wh, { passive: false });
|
|
626
|
+
canvas.addEventListener("keydown", key);
|
|
627
|
+
window.addEventListener(SCREENSHOT_EVENT, shot);
|
|
628
|
+
const ro = new ResizeObserver(request);
|
|
629
|
+
ro.observe(canvas);
|
|
630
|
+
request();
|
|
631
|
+
return () => {
|
|
632
|
+
redraw.current = () => {
|
|
633
|
+
};
|
|
634
|
+
cancelAnimationFrame(raf);
|
|
635
|
+
ro.disconnect();
|
|
636
|
+
labelsBox.remove();
|
|
637
|
+
canvas.removeEventListener("contextmenu", noMenu);
|
|
638
|
+
canvas.removeEventListener("dblclick", dbl);
|
|
639
|
+
canvas.removeEventListener("pointerdown", pd);
|
|
640
|
+
canvas.removeEventListener("pointermove", pm);
|
|
641
|
+
canvas.removeEventListener("pointerup", pu);
|
|
642
|
+
canvas.removeEventListener("pointercancel", pu);
|
|
643
|
+
canvas.removeEventListener("wheel", wh);
|
|
644
|
+
canvas.removeEventListener("keydown", key);
|
|
645
|
+
window.removeEventListener(SCREENSHOT_EVENT, shot);
|
|
646
|
+
if (buff) gl.deleteBuffer(buff);
|
|
647
|
+
if (colBuff) gl.deleteBuffer(colBuff);
|
|
648
|
+
if (program) gl.deleteProgram(program);
|
|
649
|
+
};
|
|
650
|
+
}, [mesh, resetToken]);
|
|
651
|
+
return h("canvas", {
|
|
652
|
+
ref,
|
|
653
|
+
className: "d3v-canvas",
|
|
654
|
+
"data-dsh-3d-canvas": "",
|
|
655
|
+
tabIndex: 0,
|
|
656
|
+
role: "img",
|
|
657
|
+
"aria-label": "3D \u6A21\u578B\u89C6\u56FE\uFF1A\u5DE6\u952E\u62D6\u52A8\u65CB\u8F6C\uFF0C\u53F3\u952E\u6216 Shift \u62D6\u52A8\u5E73\u79FB\uFF0C\u6EDA\u8F6E\u7F29\u653E\uFF0C\u53CC\u51FB\u590D\u4F4D",
|
|
658
|
+
onKeyDown: (e) => e.stopPropagation(),
|
|
659
|
+
style: { outline: "none" }
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/ui.js
|
|
664
|
+
var M3D_DOC_ID = "@addozhang/dsh-3d-model-viewer/model3d";
|
|
665
|
+
function docFileName(address) {
|
|
666
|
+
try {
|
|
667
|
+
const m = decodeURIComponent(String(address)).match(/\/([^\/?#]+)$/);
|
|
668
|
+
return m ? m[1] : "";
|
|
669
|
+
} catch {
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function fileSize(n) {
|
|
674
|
+
if (n < 1024) return `${n} B`;
|
|
675
|
+
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
|
|
676
|
+
return `${(n / 1048576).toFixed(1)} MB`;
|
|
677
|
+
}
|
|
678
|
+
var PRINT_INSIGHTS_ENABLED = false;
|
|
679
|
+
function PrintInsights({ mesh }) {
|
|
680
|
+
const [printer, setPrinter] = React.useState(PRINTER_PROFILES[0][0]);
|
|
681
|
+
const [material, setMaterial] = React.useState(MATERIALS[0][0]);
|
|
682
|
+
const [infill, setInfill] = React.useState(15);
|
|
683
|
+
const prof = PRINTER_PROFILES.find((p) => p[0] === printer) || PRINTER_PROFILES[0];
|
|
684
|
+
const fits = mesh.size[0] <= prof[1] && mesh.size[1] <= prof[2] && mesh.size[2] <= prof[3];
|
|
685
|
+
const density = (MATERIALS.find((m) => m[0] === material) || MATERIALS[0])[1];
|
|
686
|
+
const volCm3 = mesh.volume / 1e3;
|
|
687
|
+
const grams = volCm3 * density * (infill / 100 + (1 - infill / 100) * 0.25);
|
|
688
|
+
const select = (value, onChange, options, label) => h("label", null, label + " ", h(
|
|
689
|
+
"select",
|
|
690
|
+
{ value, onChange: (e) => onChange(e.target.value) },
|
|
691
|
+
options.map((o) => h("option", { key: o.value, value: o.value }, o.label))
|
|
692
|
+
));
|
|
693
|
+
return h(
|
|
694
|
+
"div",
|
|
695
|
+
{ className: "d3v-check" },
|
|
696
|
+
h(
|
|
697
|
+
"span",
|
|
698
|
+
{ className: fits ? "d3v-check-ok" : "d3v-check-warn" },
|
|
699
|
+
fits ? `\u2713 \u9002\u914D ${printer}` : `\u26A0 \u8D85\u51FA ${printer} (${prof[1]}\xD7${prof[2]}\xD7${prof[3]})`
|
|
700
|
+
),
|
|
701
|
+
select(printer, setPrinter, PRINTER_PROFILES.map((p) => ({ value: p[0], label: `${p[0]} ${p[1]}\xD7${p[2]}\xD7${p[3]}` })), "\u6253\u5370\u673A"),
|
|
702
|
+
h("span", null, `\u4F53\u79EF ${volCm3.toFixed(2)} cm\xB3`),
|
|
703
|
+
select(material, setMaterial, MATERIALS.map((m) => ({ value: m[0], label: m[0] })), "\u6750\u6599"),
|
|
704
|
+
select(String(infill), (v) => setInfill(+v), INFILL_OPTIONS.map((v) => ({ value: String(v), label: v + "%" })), "\u586B\u5145"),
|
|
705
|
+
h("span", null, `\u2248 ${grams.toFixed(1)} g`)
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
function Model3dBody({ content, resourceAddress, t }) {
|
|
709
|
+
const tt = (key, params) => {
|
|
710
|
+
try {
|
|
711
|
+
if (typeof t === "function") return t(key, params);
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
return key;
|
|
715
|
+
};
|
|
716
|
+
const name = docFileName(resourceAddress), is3mf = /\.3mf$/i.test(name);
|
|
717
|
+
const bytes = content && content.kind === "bytes" ? content.data : void 0;
|
|
718
|
+
const [state, setState] = React.useState({ status: bytes ? "loading" : "waiting" });
|
|
719
|
+
const [view, setView] = React.useState();
|
|
720
|
+
const [color, setColor] = React.useState(DEFAULT_COLOR);
|
|
721
|
+
React.useEffect(() => {
|
|
722
|
+
if (!bytes) return;
|
|
723
|
+
let live = true;
|
|
724
|
+
setState({ status: "loading" });
|
|
725
|
+
Promise.resolve().then(() => {
|
|
726
|
+
const buf = toArrayBuffer(bytes);
|
|
727
|
+
return is3mf ? parse3mf(buf) : parseStl(buf);
|
|
728
|
+
}).then((mesh) => {
|
|
729
|
+
mesh.name = name;
|
|
730
|
+
if (live) setState({ status: "ready", mesh });
|
|
731
|
+
}).catch((e) => {
|
|
732
|
+
if (live) setState({ status: "error", message: e instanceof Error ? e.message : String(e) });
|
|
733
|
+
});
|
|
734
|
+
return () => {
|
|
735
|
+
live = false;
|
|
736
|
+
};
|
|
737
|
+
}, [bytes, is3mf]);
|
|
738
|
+
return h(
|
|
739
|
+
"div",
|
|
740
|
+
{ className: "d3v-doc", "data-dsh-3d-docbody": "" },
|
|
741
|
+
state.status === "ready" && h(Viewer, { mesh: state.mesh, resetToken: 0, view, color }),
|
|
742
|
+
state.status === "ready" && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor }),
|
|
743
|
+
state.status === "loading" && h("div", { className: "d3v-doc-status" }, tt("loading")),
|
|
744
|
+
state.status === "waiting" && h("div", { className: "d3v-doc-status" }, tt("waiting")),
|
|
745
|
+
state.status === "error" && h("div", { className: "d3v-error" }, tt("failed", { message: state.message })),
|
|
746
|
+
state.status === "ready" && h(
|
|
747
|
+
"div",
|
|
748
|
+
{ className: "d3v-doc-foot" },
|
|
749
|
+
h(
|
|
750
|
+
"div",
|
|
751
|
+
{ className: "d3v-doc-stats" },
|
|
752
|
+
`${name} \xB7 ${state.mesh.triangles.toLocaleString()} \u25B3 \xB7 ${state.mesh.size.map((v) => v.toFixed(1)).join(" \xD7 ")} mm`
|
|
753
|
+
),
|
|
754
|
+
PRINT_INSIGHTS_ENABLED && h(PrintInsights, { mesh: state.mesh })
|
|
755
|
+
),
|
|
756
|
+
state.status === "ready" && h("div", { className: "d3v-help" }, "\u5DE6\u952E\u65CB\u8F6C \xB7 \u53F3\u952E/Shift \u5E73\u79FB \xB7 \u6EDA\u8F6E\u7F29\u653E \xB7 \u53CC\u51FB\u590D\u4F4D")
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
async function readWorkspaceModel(path) {
|
|
760
|
+
const r = await fetch(`/3d-model-viewer/files?path=${encodeURIComponent(path)}`, { cache: "no-store" });
|
|
761
|
+
if (!r.ok) throw Error(`\u8BFB\u53D6\u6A21\u578B\u5931\u8D25\uFF08HTTP ${r.status}\uFF09`);
|
|
762
|
+
const buf = await r.arrayBuffer();
|
|
763
|
+
return path.toLowerCase().endsWith(".3mf") ? parse3mf(buf) : parseStl(buf);
|
|
764
|
+
}
|
|
765
|
+
function SessionAction({ sessionStore, openView }) {
|
|
766
|
+
const s = useStore(sessionStore);
|
|
767
|
+
if (!s.files.length) return null;
|
|
768
|
+
return h("button", { type: "button", className: "d3v-session-btn", "data-dsh-3d-session-trigger": "", title: `\u9884\u89C8 ${s.files.length} \u4E2A 3D \u6A21\u578B`, onClick: () => openView("3d-preview", s.selected || s.files[0].path) }, `\u25C7 3D \u9884\u89C8 (${s.files.length})`);
|
|
769
|
+
}
|
|
770
|
+
function SessionDrawer({ sessionStore, viewRequest, completeViewRequest }) {
|
|
771
|
+
const s = useStore(sessionStore);
|
|
772
|
+
const [reset, setReset] = React.useState(0), [query, setQuery] = React.useState("");
|
|
773
|
+
const [view, setView] = React.useState(), [color, setColor] = React.useState(DEFAULT_COLOR);
|
|
774
|
+
const selected = s.files.find((x) => x.path === s.selected) || s.files[0];
|
|
775
|
+
const shown = s.files.filter((f) => f.path.toLowerCase().includes(query.trim().toLowerCase()));
|
|
776
|
+
const load = React.useCallback(async (path) => {
|
|
777
|
+
if (!path) return;
|
|
778
|
+
sessionStore.set({ selected: path, loading: true, error: "" });
|
|
779
|
+
try {
|
|
780
|
+
const mesh = await readWorkspaceModel(path);
|
|
781
|
+
mesh.name = path.split("/").pop();
|
|
782
|
+
const row = sessionStore.getSnapshot().files.find((x) => x.path === path);
|
|
783
|
+
sessionStore.set({ mesh, loading: false, mtime: row?.mtime || 0 });
|
|
784
|
+
setReset((x) => x + 1);
|
|
785
|
+
} catch (e) {
|
|
786
|
+
sessionStore.set({ mesh: null, loading: false, error: e instanceof Error ? e.message : String(e) });
|
|
787
|
+
}
|
|
788
|
+
}, [sessionStore]);
|
|
789
|
+
React.useEffect(() => {
|
|
790
|
+
if (viewRequest?.view !== "3d-preview") return;
|
|
791
|
+
if (viewRequest.focus) load(viewRequest.focus);
|
|
792
|
+
completeViewRequest();
|
|
793
|
+
}, [viewRequest]);
|
|
794
|
+
React.useEffect(() => {
|
|
795
|
+
if (selected && !s.mesh && !s.loading) load(selected.path);
|
|
796
|
+
}, [selected?.path]);
|
|
797
|
+
React.useEffect(() => {
|
|
798
|
+
if (!selected) return;
|
|
799
|
+
const timer = setInterval(async () => {
|
|
800
|
+
try {
|
|
801
|
+
const j = await fetch("/3d-model-viewer/files", { cache: "no-store" }).then((r) => r.json());
|
|
802
|
+
const row = j.files.find((x) => x.path === selected.path);
|
|
803
|
+
if (row && row.mtime !== sessionStore.getSnapshot().mtime) {
|
|
804
|
+
sessionStore.set({ files: j.files });
|
|
805
|
+
load(row.path);
|
|
806
|
+
}
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
}, 1500);
|
|
810
|
+
return () => clearInterval(timer);
|
|
811
|
+
}, [selected?.path, load]);
|
|
812
|
+
return h(
|
|
813
|
+
"div",
|
|
814
|
+
{ className: "d3v-drawer-body", "data-dsh-3d-drawer": "" },
|
|
815
|
+
h(
|
|
816
|
+
"aside",
|
|
817
|
+
{ className: "d3v-browser" },
|
|
818
|
+
h(
|
|
819
|
+
"div",
|
|
820
|
+
{ className: "d3v-search-wrap" },
|
|
821
|
+
h("input", { className: "d3v-search", type: "search", placeholder: "\u641C\u7D22 3D \u6587\u4EF6\u2026", value: query, onChange: (e) => setQuery(e.target.value) })
|
|
822
|
+
),
|
|
823
|
+
h(
|
|
824
|
+
"div",
|
|
825
|
+
{ className: "d3v-files" },
|
|
826
|
+
shown.map((f) => h(
|
|
827
|
+
"button",
|
|
828
|
+
{
|
|
829
|
+
key: f.path,
|
|
830
|
+
className: "d3v-file" + (f.path === selected?.path ? " active" : ""),
|
|
831
|
+
title: f.path,
|
|
832
|
+
onClick: () => load(f.path)
|
|
833
|
+
},
|
|
834
|
+
h("span", { className: "d3v-file-icon" }, f.name.toLowerCase().endsWith(".3mf") ? "3MF" : "STL"),
|
|
835
|
+
h(
|
|
836
|
+
"span",
|
|
837
|
+
{ className: "d3v-file-main" },
|
|
838
|
+
h("span", { className: "d3v-file-name" }, f.name),
|
|
839
|
+
h(
|
|
840
|
+
"span",
|
|
841
|
+
{ className: "d3v-file-meta" },
|
|
842
|
+
h("span", null, fileSize(f.size)),
|
|
843
|
+
h("span", null, new Date(f.mtime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
)),
|
|
847
|
+
!shown.length && h("div", { className: "d3v-empty-note", style: { padding: "12px" } }, "\u6CA1\u6709\u5339\u914D\u6587\u4EF6")
|
|
848
|
+
),
|
|
849
|
+
h("div", { className: "d3v-browser-foot" }, `${shown.length} / ${s.files.length} \u4E2A\u6A21\u578B`)
|
|
850
|
+
),
|
|
851
|
+
h(
|
|
852
|
+
"section",
|
|
853
|
+
{ className: "d3v-preview-column" },
|
|
854
|
+
h(
|
|
855
|
+
"div",
|
|
856
|
+
{ className: "d3v-stage" },
|
|
857
|
+
s.mesh && h(Viewer, { mesh: s.mesh, resetToken: reset, view, color }),
|
|
858
|
+
s.mesh && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor }),
|
|
859
|
+
s.loading && h("div", { className: "d3v-loading" }, "\u6B63\u5728\u66F4\u65B0\u6A21\u578B\u2026"),
|
|
860
|
+
s.error && h("div", { className: "d3v-error" }, s.error),
|
|
861
|
+
s.mesh && h("div", { className: "d3v-help" }, "\u5DE6\u952E\u65CB\u8F6C \xB7 \u53F3\u952E/Shift \u5E73\u79FB \xB7 \u6EDA\u8F6E\u7F29\u653E \xB7 \u53CC\u51FB\u590D\u4F4D \xB7 \u6587\u4EF6\u53D8\u5316\u81EA\u52A8\u66F4\u65B0")
|
|
862
|
+
),
|
|
863
|
+
h(
|
|
864
|
+
"footer",
|
|
865
|
+
{ className: "d3v-footer" },
|
|
866
|
+
s.mesh ? h(
|
|
867
|
+
React.Fragment,
|
|
868
|
+
null,
|
|
869
|
+
h("span", { className: "d3v-live" }, "\u5B9E\u65F6"),
|
|
870
|
+
h("span", { className: "d3v-stat" }, "\u4E09\u89D2\u9762\uFF1A", h("b", null, s.mesh.triangles.toLocaleString())),
|
|
871
|
+
h("span", { className: "d3v-stat" }, "\u5C3A\u5BF8\uFF1A", h("b", null, s.mesh.size.map((v) => v.toFixed(2)).join(" \xD7 ") + " mm")),
|
|
872
|
+
PRINT_INSIGHTS_ENABLED && h(PrintInsights, { mesh: s.mesh }),
|
|
873
|
+
h("button", { className: "d3v-btn", onClick: () => setReset((x) => x + 1) }, "\u91CD\u7F6E\u89C6\u89D2")
|
|
874
|
+
) : h("span", null, "\u6682\u65E0\u53EF\u9884\u89C8\u6A21\u578B")
|
|
875
|
+
)
|
|
876
|
+
)
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
function SessionDrawerOverlay({ sessionStore, layoutStore }) {
|
|
880
|
+
const s = useStore(layoutStore);
|
|
881
|
+
if (!s.open) return null;
|
|
882
|
+
return h(
|
|
883
|
+
"div",
|
|
884
|
+
{ className: "d3v-drawer-wrap" },
|
|
885
|
+
h("div", { className: "d3v-drawer-backdrop", onClick: () => layoutStore.set({ open: false }) }),
|
|
886
|
+
h(
|
|
887
|
+
"aside",
|
|
888
|
+
{ className: "d3v-drawer", role: "complementary", "aria-label": "3D \u5B9E\u65F6\u9884\u89C8" },
|
|
889
|
+
h(
|
|
890
|
+
"header",
|
|
891
|
+
{ className: "d3v-drawer-head" },
|
|
892
|
+
h("div", { className: "d3v-title" }, "3D \u5B9E\u65F6\u9884\u89C8"),
|
|
893
|
+
h("div", { className: "d3v-spacer" }),
|
|
894
|
+
h("button", { className: "d3v-btn d3v-close", onClick: () => layoutStore.set({ open: false }) }, "\xD7")
|
|
895
|
+
),
|
|
896
|
+
h(SessionDrawer, { sessionStore, viewRequest: null, completeViewRequest: () => {
|
|
897
|
+
} })
|
|
898
|
+
)
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
function modelReferenceText(window2) {
|
|
902
|
+
let text = "";
|
|
903
|
+
for (const entry of window2?.entries || []) {
|
|
904
|
+
try {
|
|
905
|
+
text += "\n" + JSON.stringify(entry.event || entry).toLowerCase();
|
|
906
|
+
} catch {
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return text.replace(/\\\\/g, "/");
|
|
910
|
+
}
|
|
911
|
+
function sessionModels(all, text) {
|
|
912
|
+
if (!text) return [];
|
|
913
|
+
return all.filter((file) => text.includes(file.name.toLowerCase()) || text.includes(file.path.toLowerCase().replace(/\\/g, "/")));
|
|
914
|
+
}
|
|
915
|
+
function SessionBridge({ sessionId, sessions, stores, layoutStore }) {
|
|
916
|
+
const sessionStore = stores.get(sessionId);
|
|
917
|
+
const binding = sessions.binding(sessionId);
|
|
918
|
+
const window2 = React.useSyncExternalStore(
|
|
919
|
+
binding.eventSource.subscribe.bind(binding.eventSource),
|
|
920
|
+
() => binding.eventSource.getSnapshot(),
|
|
921
|
+
() => binding.eventSource.getSnapshot()
|
|
922
|
+
);
|
|
923
|
+
const s = useStore(sessionStore);
|
|
924
|
+
React.useEffect(() => {
|
|
925
|
+
let live = true, timer;
|
|
926
|
+
const refresh = async () => {
|
|
927
|
+
try {
|
|
928
|
+
const j = await fetch("/3d-model-viewer/files", { cache: "no-store" }).then((r) => r.json());
|
|
929
|
+
if (!live) return;
|
|
930
|
+
const eventText = modelReferenceText(binding.eventSource.getSnapshot());
|
|
931
|
+
const pageText = (document.body.innerText + "\n" + [...document.querySelectorAll("[title]")].map((x) => x.getAttribute("title") || "").join("\n")).toLowerCase();
|
|
932
|
+
const files = sessionModels(j.files, eventText + "\n" + pageText);
|
|
933
|
+
const current = sessionStore.getSnapshot();
|
|
934
|
+
sessionStore.set({
|
|
935
|
+
files,
|
|
936
|
+
selected: files.some((x) => x.path === current.selected) ? current.selected : files[0]?.path || "",
|
|
937
|
+
...files.length ? {} : { mesh: null, error: "", loading: false, mtime: 0 }
|
|
938
|
+
});
|
|
939
|
+
} catch {
|
|
940
|
+
}
|
|
941
|
+
timer = setTimeout(refresh, 1500);
|
|
942
|
+
};
|
|
943
|
+
refresh();
|
|
944
|
+
return () => {
|
|
945
|
+
live = false;
|
|
946
|
+
clearTimeout(timer);
|
|
947
|
+
};
|
|
948
|
+
}, [sessionId, window2.revision]);
|
|
949
|
+
if (!s.files.length) return null;
|
|
950
|
+
return h(SessionAction, {
|
|
951
|
+
sessionStore,
|
|
952
|
+
openView: (view, focus) => {
|
|
953
|
+
layoutStore.set({ open: true, sessionId });
|
|
954
|
+
if (focus && focus !== sessionStore.getSnapshot().selected) sessionStore.set({ selected: focus, mesh: null });
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/app.js
|
|
960
|
+
var INJECT = ["slots", "sessions", "locale", "documentPreviews"];
|
|
961
|
+
function apply(ctx) {
|
|
962
|
+
injectStyles();
|
|
963
|
+
const layoutStore = makeStore();
|
|
964
|
+
const sessionStores = /* @__PURE__ */ new Map();
|
|
965
|
+
const stores = {
|
|
966
|
+
get(id) {
|
|
967
|
+
let s = sessionStores.get(id);
|
|
968
|
+
if (!s) {
|
|
969
|
+
s = makeSessionStore();
|
|
970
|
+
sessionStores.set(id, s);
|
|
971
|
+
}
|
|
972
|
+
return s;
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
function ActiveDrawer() {
|
|
976
|
+
const layout = useStore(layoutStore);
|
|
977
|
+
const list = React.useSyncExternalStore(ctx.sessions.list.subscribe, ctx.sessions.list.getSnapshot, ctx.sessions.list.getSnapshot);
|
|
978
|
+
const id = layout.sessionId || list.current;
|
|
979
|
+
const sessionStore = id ? stores.get(id) : makeSessionStore();
|
|
980
|
+
return h(SessionDrawerOverlay, { sessionStore, layoutStore });
|
|
981
|
+
}
|
|
982
|
+
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register(
|
|
983
|
+
{ name: "conversation.session.header.actions", id: "3d-model-preview-action", order: 70, inject: (owner) => ({ sessions: ctx.sessions, stores, layoutStore }) },
|
|
984
|
+
SessionBridge
|
|
985
|
+
));
|
|
986
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
987
|
+
{ name: "shell.overlay", id: "3d-session-preview-drawer", order: 70 },
|
|
988
|
+
ActiveDrawer
|
|
989
|
+
));
|
|
990
|
+
const t = ctx.locale.bind("dsh3dDoc");
|
|
991
|
+
ctx.effect(() => ctx.locale.register("dsh3dDoc", {
|
|
992
|
+
zh: { title: "3D \u6A21\u578B", loading: "\u6B63\u5728\u89E3\u6790\u6A21\u578B\u2026", waiting: "\u7B49\u5F85\u6587\u4EF6\u5185\u5BB9\u2026", failed: "\u65E0\u6CD5\u89E3\u6790\u8FD9\u4E2A\u6A21\u578B\uFF1A{message}" },
|
|
993
|
+
en: { title: "3D Model", loading: "Parsing model\u2026", waiting: "Waiting for file contents\u2026", failed: "This model could not be parsed: {message}" }
|
|
994
|
+
}), "3d-model-viewer: doc dictionaries");
|
|
995
|
+
ctx.effect(() => ctx.documentPreviews.register({
|
|
996
|
+
id: M3D_DOC_ID,
|
|
997
|
+
extensions: ["stl", "3mf"],
|
|
998
|
+
binaryExtensions: ["stl", "3mf"],
|
|
999
|
+
priority: "extension",
|
|
1000
|
+
title: () => t("title"),
|
|
1001
|
+
loading: "bytes-complete",
|
|
1002
|
+
wrap: false
|
|
1003
|
+
}), "3d-model-viewer: doc metadata");
|
|
1004
|
+
ctx.slots.inject("sidebar.right.tab.document", () => ctx.slots.register(
|
|
1005
|
+
{ name: "sidebar.right.tab.document", key: M3D_DOC_ID, locale: "dsh3dDoc" },
|
|
1006
|
+
Model3dBody
|
|
1007
|
+
));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/entry-client.js
|
|
1011
|
+
injectStyles();
|
|
1012
|
+
window.__ModuleLoader__.load({
|
|
1013
|
+
id: "@addozhang/dsh-3d-model-viewer",
|
|
1014
|
+
factory: (require2) => {
|
|
1015
|
+
setReact(require2("react"));
|
|
1016
|
+
const module = { exports: {} };
|
|
1017
|
+
module.exports.inject = INJECT;
|
|
1018
|
+
module.exports.apply = apply;
|
|
1019
|
+
return module.exports;
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
})();
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, resolve, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const inject = ['webServer'];
|
|
6
|
+
const ROOT = resolve(process.cwd());
|
|
7
|
+
const EXTENSIONS = new Set(['.stl', '.3mf']);
|
|
8
|
+
const SKIP = new Set(['node_modules', '.git', '.svn', '.hg', '.dsh']);
|
|
9
|
+
|
|
10
|
+
function insideRoot(path) {
|
|
11
|
+
const rel = relative(ROOT, path);
|
|
12
|
+
return rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function models() {
|
|
16
|
+
const found = [];
|
|
17
|
+
async function walk(dir, depth) {
|
|
18
|
+
if (depth > 5 || found.length >= 200) return;
|
|
19
|
+
let entries;
|
|
20
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (found.length >= 200) break;
|
|
23
|
+
if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
|
|
24
|
+
const full = resolve(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) await walk(full, depth + 1);
|
|
26
|
+
else if (entry.isFile() && EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
27
|
+
try {
|
|
28
|
+
const info = await stat(full);
|
|
29
|
+
found.push({ path: relative(ROOT, full).split(sep).join('/'), name: entry.name, size: info.size, mtime: info.mtimeMs });
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await walk(ROOT, 0);
|
|
35
|
+
return found.sort((a, b) => b.mtime - a.mtime);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function apply(ctx) {
|
|
39
|
+
ctx.webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: '/3d-model-viewer/files',
|
|
42
|
+
async handler(req, res) {
|
|
43
|
+
if (req.method !== 'GET') { res.writeHead(405).end(); return; }
|
|
44
|
+
const url = new URL(req.url ?? '', 'http://localhost');
|
|
45
|
+
const requested = url.searchParams.get('path');
|
|
46
|
+
if (requested === null) {
|
|
47
|
+
const body = JSON.stringify({ root: ROOT, files: await models() });
|
|
48
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(body) });
|
|
49
|
+
res.end(body);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const full = resolve(ROOT, requested);
|
|
53
|
+
if (!insideRoot(full) || !EXTENSIONS.has(extname(full).toLowerCase())) { res.writeHead(403).end(); return; }
|
|
54
|
+
let info;
|
|
55
|
+
try { info = await stat(full); } catch { res.writeHead(404).end(); return; }
|
|
56
|
+
if (!info.isFile() || info.size > 250 * 1024 * 1024) { res.writeHead(413).end(); return; }
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
'content-type': extname(full).toLowerCase() === '.3mf' ? 'model/3mf' : 'model/stl',
|
|
59
|
+
'content-length': info.size,
|
|
60
|
+
'cache-control': 'no-store',
|
|
61
|
+
'x-model-mtime': String(info.mtimeMs),
|
|
62
|
+
});
|
|
63
|
+
createReadStream(full).pipe(res);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@addozhang/dsh-3d-model-viewer",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Interactive STL/3MF viewer plugin for the DeepSeek Harness Web GUI: sidebar document preview, live session drawer, CAD navigation, orthographic dimension labels, 3MF part colors.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/addozhang/dsh-3d-model-viewer.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/addozhang/dsh-3d-model-viewer/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "lib/index.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./lib/index.js",
|
|
23
|
+
"./client": "./lib/client.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs",
|
|
27
|
+
"test": "node --test test/*.test.mjs",
|
|
28
|
+
"test:e2e": "python3 scripts/e2e.py"
|
|
29
|
+
},
|
|
30
|
+
"dsh": {
|
|
31
|
+
"client": {
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
34
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
37
|
+
],
|
|
38
|
+
"platform": "web"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"esbuild": "^0.25.0"
|
|
46
|
+
},
|
|
47
|
+
"files": ["lib/index.js", "lib/client.js", "README.md", "README.zh-CN.md", "LICENSE"]
|
|
48
|
+
}
|