@addozhang/dsh-3d-model-viewer 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/README.zh-CN.md +1 -0
- package/lib/client.js +130 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ active sessions.
|
|
|
22
22
|
- **CAD-style navigation** — left-drag rotates (true trackball, no gimbal
|
|
23
23
|
limits), right / middle / Shift-drag pans, wheel zooms (perspective and
|
|
24
24
|
orthographic), double-click resets.
|
|
25
|
+
- **Multi-plate switcher** — 3MF projects carrying a Bambu plate table get
|
|
26
|
+
an "全部 | 盘 N…" toolbar row; each plate renders alone, re-centered, with
|
|
27
|
+
its own dimension labels.
|
|
25
28
|
- **View presets** — front/back/left/right/top/bottom use an orthographic
|
|
26
29
|
camera and show labelled bounding-box dimensions (mm); iso is the classic
|
|
27
30
|
perspective view. Keyboard: `1-7` presets, `R` reset (when the canvas is
|
package/README.zh-CN.md
CHANGED
|
@@ -11,6 +11,7 @@ DeepSeek Harness Web GUI 的本地交互式 STL / 3MF 预览插件。模型可
|
|
|
11
11
|
- **侧边栏原生文档预览**:在「文件」面板点击 `.stl` / `.3mf`,右侧文档标签页像打开 Markdown / PDF / 图片一样打开 3D 视图(通过公开的 `documentPreviews` + `sidebar.right.tab.document` 扩展点接入)
|
|
12
12
|
- **会话实时抽屉**:当前会话引用了模型时,会话标题栏出现 `3D 预览 (N)` 按钮,打开带搜索列表的预览抽屉;文件在磁盘上变化后约 1.5 秒自动重新解析
|
|
13
13
|
- **CAD 式导航**:左键拖动旋转(真轨迹球,无万向锁)、右键 / 中键 / Shift 拖动平移、滚轮缩放(透视与正交通用)、双击复位
|
|
14
|
+
- **多盘切换**:带 Bambu 盘表的 3MF 工程会出现「全部 | 盘 N…」工具行,逐盘渲染、自动居中、尺寸标签随盘切换
|
|
14
15
|
- **视角预设**:前/后/左/右/顶/底使用正交相机并显示包围盒三向尺寸标签(毫米);等轴测为经典透视。画布聚焦时快捷键 `1-7` 切视角、`R` 复位
|
|
15
16
|
- **模型颜色**:七个预置色板(含白色)+ 原生取色器;STL 单色渲染,3MF 的部件颜色(`basematerials`)按顶点多色显示
|
|
16
17
|
- **打印检查**(当前由 `PRINT_INSIGHTS_ENABLED` 开关关闭):打印机体积适配(默认 X2D)、封闭网格体积、耗材重量估算
|
package/lib/client.js
CHANGED
|
@@ -64,7 +64,7 @@
|
|
|
64
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
65
|
@media(prefers-color-scheme:dark){.d3v-views{background:rgba(10,12,17,.65)}}
|
|
66
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}
|
|
67
|
+
.d3v-view-btn:hover{background:rgba(96,165,250,.22);color:#173a63}.d3v-view-btn.active{background:rgba(96,165,250,.4);color:#0f2a4d;font-weight:600}
|
|
68
68
|
@media(prefers-color-scheme:dark){.d3v-view-btn{color:#c8d0dc}.d3v-view-btn:hover{color:#e8eef7}}
|
|
69
69
|
.d3v-colors{display:flex;gap:5px;align-items:center;background:rgba(129,138,150,.18);border-radius:8px;padding:5px}
|
|
70
70
|
@media(prefers-color-scheme:dark){.d3v-colors{background:rgba(10,12,17,.65)}}
|
|
@@ -272,6 +272,78 @@
|
|
|
272
272
|
];
|
|
273
273
|
}
|
|
274
274
|
var P_NS = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06";
|
|
275
|
+
function buildPlates(items, plateOfObject, plateMeta, data) {
|
|
276
|
+
if (!plateOfObject || plateOfObject.size === 0) return null;
|
|
277
|
+
const byPlate = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const it of items) {
|
|
279
|
+
const plate = plateOfObject.get(String(it.objectid));
|
|
280
|
+
if (plate === void 0) continue;
|
|
281
|
+
if (!byPlate.has(plate)) byPlate.set(plate, []);
|
|
282
|
+
byPlate.get(plate).push(it);
|
|
283
|
+
}
|
|
284
|
+
if (byPlate.size < 2) return null;
|
|
285
|
+
const metaById = new Map((plateMeta || []).map((m) => [String(m.id), m]));
|
|
286
|
+
const ids = [...byPlate.keys()].sort((a, b) => Number(a) - Number(b));
|
|
287
|
+
const plates = [];
|
|
288
|
+
for (const id of ids) {
|
|
289
|
+
const ranges = byPlate.get(id).map((it) => ({ startTri: it.startTri, triCount: it.triCount }));
|
|
290
|
+
let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity], triangles = 0;
|
|
291
|
+
for (const r of ranges) {
|
|
292
|
+
triangles += r.triCount;
|
|
293
|
+
for (let i = r.startTri * 18; i < (r.startTri + r.triCount) * 18; i += 6) {
|
|
294
|
+
for (let j = 0; j < 3; j++) {
|
|
295
|
+
const v = data[i + j];
|
|
296
|
+
if (v < lo[j]) lo[j] = v;
|
|
297
|
+
if (v > hi[j]) hi[j] = v;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!Number.isFinite(lo[0])) continue;
|
|
302
|
+
const size = lo.map((x, i) => hi[i] - x);
|
|
303
|
+
plates.push({
|
|
304
|
+
id,
|
|
305
|
+
label: (metaById.get(String(id)) || {}).label || `\u76D8 ${id}`,
|
|
306
|
+
ranges,
|
|
307
|
+
triangles,
|
|
308
|
+
lo,
|
|
309
|
+
hi,
|
|
310
|
+
size,
|
|
311
|
+
center: lo.map((x, i) => (x + hi[i]) / 2),
|
|
312
|
+
radius: Math.hypot(...size) / 2 || 1
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return plates.length > 1 ? plates : null;
|
|
316
|
+
}
|
|
317
|
+
function readPlateTable(files) {
|
|
318
|
+
const entry = files.find((f) => f.name.replace(/^\//, "") === "Metadata/model_settings.config");
|
|
319
|
+
if (!entry) return null;
|
|
320
|
+
try {
|
|
321
|
+
const doc = new DOMParser().parseFromString(new TextDecoder().decode(entry.data), "application/xml");
|
|
322
|
+
if (doc.querySelector("parsererror")) return null;
|
|
323
|
+
const plateOfObject = /* @__PURE__ */ new Map();
|
|
324
|
+
const plateMeta = [];
|
|
325
|
+
for (const plate of doc.getElementsByTagName("plate")) {
|
|
326
|
+
let id = null, label = "";
|
|
327
|
+
for (const md of plate.getElementsByTagName("metadata")) {
|
|
328
|
+
if (md.getAttribute("key") === "plater_id") id = md.getAttribute("value");
|
|
329
|
+
if (md.getAttribute("key") === "plater_name" && md.getAttribute("value")) label = md.getAttribute("value");
|
|
330
|
+
}
|
|
331
|
+
if (id === null) continue;
|
|
332
|
+
plateMeta.push({ id, label });
|
|
333
|
+
for (const inst of plate.getElementsByTagName("model_instance")) {
|
|
334
|
+
for (const md of inst.getElementsByTagName("metadata")) {
|
|
335
|
+
if (md.getAttribute("key") === "object_id") {
|
|
336
|
+
const objectId = md.getAttribute("value");
|
|
337
|
+
if (!plateOfObject.has(objectId)) plateOfObject.set(objectId, id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return plateOfObject.size ? { plateOfObject, plateMeta } : null;
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
275
347
|
async function parse3mf(buf) {
|
|
276
348
|
const files = await unzip(buf);
|
|
277
349
|
const models = files.filter((f) => /\.model$/i.test(f.name));
|
|
@@ -353,17 +425,26 @@
|
|
|
353
425
|
}
|
|
354
426
|
visiting.delete(key);
|
|
355
427
|
};
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
428
|
+
const buildItems = [...root.doc.getElementsByTagNameNS("*", "item")];
|
|
429
|
+
const itemRanges = [];
|
|
430
|
+
if (buildItems.length) {
|
|
431
|
+
for (const item of buildItems) {
|
|
432
|
+
const start = triangles;
|
|
359
433
|
emitObject(root, item.getAttribute("objectid"), mat4FromTransform(item.getAttribute("transform")), /* @__PURE__ */ new Set());
|
|
434
|
+
if (triangles > start) itemRanges.push({ objectid: item.getAttribute("objectid"), startTri: start, triCount: triangles - start });
|
|
360
435
|
}
|
|
361
436
|
} else {
|
|
362
437
|
for (const id of root.objects.keys()) emitObject(root, id, MAT4_IDENTITY, /* @__PURE__ */ new Set());
|
|
363
438
|
}
|
|
364
439
|
if (!triangles) throw Error("3MF \u4E2D\u6CA1\u6709\u53EF\u663E\u793A\u7684\u4E09\u89D2\u7F51\u683C");
|
|
365
|
-
const
|
|
440
|
+
const data = new Float32Array(vals);
|
|
441
|
+
const mesh = finishMesh(data, triangles);
|
|
366
442
|
if (sawColor) mesh.colors = new Float32Array(cols);
|
|
443
|
+
const plateTable = readPlateTable(files);
|
|
444
|
+
if (plateTable) {
|
|
445
|
+
const plates = buildPlates(itemRanges, plateTable.plateOfObject, plateTable.plateMeta, data);
|
|
446
|
+
if (plates) mesh.plates = plates;
|
|
447
|
+
}
|
|
367
448
|
return mesh;
|
|
368
449
|
}
|
|
369
450
|
|
|
@@ -427,10 +508,28 @@
|
|
|
427
508
|
const cw = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15] || 1;
|
|
428
509
|
return [(cx / cw * 0.5 + 0.5) * w, (0.5 - cy / cw * 0.5) * hh];
|
|
429
510
|
}
|
|
430
|
-
function ViewPresetBar({ onPick, color, onPickColor }) {
|
|
511
|
+
function ViewPresetBar({ onPick, color, onPickColor, plates, plate, onPickPlate }) {
|
|
431
512
|
return h(
|
|
432
513
|
"div",
|
|
433
514
|
{ className: "d3v-viewtools", "data-dsh-3d-views": "" },
|
|
515
|
+
plates && plates.length > 1 && h(
|
|
516
|
+
"div",
|
|
517
|
+
{ className: "d3v-views", title: "\u5207\u6362\u6253\u5370\u76D8" },
|
|
518
|
+
h("button", {
|
|
519
|
+
type: "button",
|
|
520
|
+
className: "d3v-view-btn" + (plate < 0 ? " active" : ""),
|
|
521
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
522
|
+
onClick: () => onPickPlate(-1)
|
|
523
|
+
}, "\u5168\u90E8"),
|
|
524
|
+
plates.map((p, i) => h("button", {
|
|
525
|
+
key: p.id,
|
|
526
|
+
type: "button",
|
|
527
|
+
className: "d3v-view-btn" + (plate === i ? " active" : ""),
|
|
528
|
+
title: `${p.label} \xB7 ${p.triangles.toLocaleString()} \u25B3`,
|
|
529
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
530
|
+
onClick: () => onPickPlate(i)
|
|
531
|
+
}, p.label))
|
|
532
|
+
),
|
|
434
533
|
h(
|
|
435
534
|
"div",
|
|
436
535
|
{ className: "d3v-views" },
|
|
@@ -484,19 +583,27 @@
|
|
|
484
583
|
["y", [hi[0] + margin, (lo[1] + hi[1]) / 2, lo[2]]],
|
|
485
584
|
["z", [hi[0] + margin, lo[1], (lo[2] + hi[2]) / 2]]
|
|
486
585
|
];
|
|
487
|
-
function Viewer({ mesh, resetToken, view, color }) {
|
|
586
|
+
function Viewer({ mesh, resetToken, view, color, plate }) {
|
|
488
587
|
const ref = React.useRef(null);
|
|
489
588
|
const orient = React.useRef({ rot: DEFAULT_ROT(), ortho: false });
|
|
490
589
|
const pan = React.useRef([0, 0]);
|
|
491
590
|
const redraw = React.useRef(() => {
|
|
492
591
|
});
|
|
493
592
|
const colorRef = React.useRef(null);
|
|
593
|
+
const plateRef = React.useRef(-1);
|
|
494
594
|
React.useEffect(() => {
|
|
495
595
|
if (!view) return;
|
|
496
596
|
orient.current = { rot: buildRot(view.yaw, view.pitch), ortho: !!view.ortho };
|
|
497
597
|
pan.current = [0, 0];
|
|
498
598
|
redraw.current();
|
|
499
599
|
}, [view]);
|
|
600
|
+
React.useEffect(() => {
|
|
601
|
+
const idx = Number.isInteger(plate) && mesh.plates && plate >= 0 && plate < mesh.plates.length ? plate : -1;
|
|
602
|
+
if (plateRef.current === idx) return;
|
|
603
|
+
plateRef.current = idx;
|
|
604
|
+
pan.current = [0, 0];
|
|
605
|
+
redraw.current();
|
|
606
|
+
}, [plate, mesh]);
|
|
500
607
|
React.useEffect(() => {
|
|
501
608
|
if (!color) return;
|
|
502
609
|
colorRef.current = color;
|
|
@@ -573,12 +680,13 @@
|
|
|
573
680
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
574
681
|
const aspect = w / hh;
|
|
575
682
|
const proj = o.ortho ? orthographic(aspect, orthoHalfSize(zoom)) : perspective(aspect);
|
|
576
|
-
const
|
|
683
|
+
const active = plateRef.current >= 0 && mesh.plates && mesh.plates[plateRef.current] || mesh;
|
|
684
|
+
const scale = 1 / active.radius;
|
|
577
685
|
const model = new Float32Array(rot);
|
|
578
686
|
for (const idx of [0, 1, 2, 4, 5, 6, 8, 9, 10]) model[idx] *= scale;
|
|
579
|
-
model[12] = -(model[0] *
|
|
580
|
-
model[13] = -(model[1] *
|
|
581
|
-
model[14] = -(model[2] *
|
|
687
|
+
model[12] = -(model[0] * active.center[0] + model[4] * active.center[1] + model[8] * active.center[2]) + pan.current[0];
|
|
688
|
+
model[13] = -(model[1] * active.center[0] + model[5] * active.center[1] + model[9] * active.center[2]) + pan.current[1];
|
|
689
|
+
model[14] = -(model[2] * active.center[0] + model[6] * active.center[1] + model[10] * active.center[2]) - zoom;
|
|
582
690
|
lastModel = model;
|
|
583
691
|
gl.useProgram(program);
|
|
584
692
|
const col = colorRef.current || DEFAULT_COLOR;
|
|
@@ -587,15 +695,16 @@
|
|
|
587
695
|
gl.uniformMatrix4fv(gl.getUniformLocation(program, "model"), false, model);
|
|
588
696
|
const mvp = mul(proj, model);
|
|
589
697
|
gl.uniformMatrix4fv(gl.getUniformLocation(program, "mvp"), false, mvp);
|
|
590
|
-
|
|
698
|
+
const ranges = active.ranges || [{ startTri: 0, triCount: mesh.data.length / 18 }];
|
|
699
|
+
for (const r of ranges) gl.drawArrays(gl.TRIANGLES, r.startTri * 3, r.triCount * 3);
|
|
591
700
|
if (o.ortho) {
|
|
592
|
-
const m =
|
|
701
|
+
const m = active.radius * 0.18;
|
|
593
702
|
const inPlane = {
|
|
594
703
|
x: Math.abs(rot[2]) < 0.9,
|
|
595
704
|
y: Math.abs(rot[6]) < 0.9,
|
|
596
705
|
z: Math.abs(rot[10]) < 0.9
|
|
597
706
|
};
|
|
598
|
-
const anchors = DIM_ANCHORS(
|
|
707
|
+
const anchors = DIM_ANCHORS(active.lo, active.hi, m);
|
|
599
708
|
for (const [axis, point] of anchors) {
|
|
600
709
|
const el = labelEls[axis];
|
|
601
710
|
if (!inPlane[axis]) {
|
|
@@ -606,7 +715,7 @@
|
|
|
606
715
|
el.style.display = "";
|
|
607
716
|
el.style.left = sx + "px";
|
|
608
717
|
el.style.top = sy + "px";
|
|
609
|
-
el.textContent =
|
|
718
|
+
el.textContent = active.size[axis === "x" ? 0 : axis === "y" ? 1 : 2].toFixed(2);
|
|
610
719
|
}
|
|
611
720
|
} else {
|
|
612
721
|
for (const el of Object.values(labelEls)) el.style.display = "none";
|
|
@@ -791,6 +900,7 @@
|
|
|
791
900
|
const [state, setState] = React.useState({ status: bytes ? "loading" : "waiting" });
|
|
792
901
|
const [view, setView] = React.useState();
|
|
793
902
|
const [color, setColor] = React.useState(DEFAULT_COLOR);
|
|
903
|
+
const [plate, setPlate] = React.useState(-1);
|
|
794
904
|
React.useEffect(() => {
|
|
795
905
|
if (!bytes) return;
|
|
796
906
|
let live = true;
|
|
@@ -811,8 +921,8 @@
|
|
|
811
921
|
return h(
|
|
812
922
|
"div",
|
|
813
923
|
{ className: "d3v-doc", "data-dsh-3d-docbody": "" },
|
|
814
|
-
state.status === "ready" && h(Viewer, { mesh: state.mesh, resetToken: 0, view, color }),
|
|
815
|
-
state.status === "ready" && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor }),
|
|
924
|
+
state.status === "ready" && h(Viewer, { mesh: state.mesh, resetToken: 0, view, color, plate }),
|
|
925
|
+
state.status === "ready" && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor, plates: state.mesh.plates, plate, onPickPlate: setPlate }),
|
|
816
926
|
state.status === "loading" && h("div", { className: "d3v-doc-status" }, tt("loading")),
|
|
817
927
|
state.status === "waiting" && h("div", { className: "d3v-doc-status" }, tt("waiting")),
|
|
818
928
|
state.status === "error" && h("div", { className: "d3v-error" }, tt("failed", { message: state.message })),
|
|
@@ -844,6 +954,7 @@
|
|
|
844
954
|
const s = useStore(sessionStore);
|
|
845
955
|
const [reset, setReset] = React.useState(0), [query, setQuery] = React.useState("");
|
|
846
956
|
const [view, setView] = React.useState(), [color, setColor] = React.useState(DEFAULT_COLOR);
|
|
957
|
+
const [plate, setPlate] = React.useState(-1);
|
|
847
958
|
const selected = s.files.find((x) => x.path === s.selected) || s.files[0];
|
|
848
959
|
const shown = s.files.filter((f) => f.path.toLowerCase().includes(query.trim().toLowerCase()));
|
|
849
960
|
const load = React.useCallback(async (path) => {
|
|
@@ -927,8 +1038,8 @@
|
|
|
927
1038
|
h(
|
|
928
1039
|
"div",
|
|
929
1040
|
{ className: "d3v-stage" },
|
|
930
|
-
s.mesh && h(Viewer, { mesh: s.mesh, resetToken: reset, view, color }),
|
|
931
|
-
s.mesh && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor }),
|
|
1041
|
+
s.mesh && h(Viewer, { mesh: s.mesh, resetToken: reset, view, color, plate }),
|
|
1042
|
+
s.mesh && h(ViewPresetBar, { onPick: (yaw, pitch, ortho) => setView({ yaw, pitch, ortho }), color, onPickColor: setColor, plates: s.mesh.plates, plate, onPickPlate: setPlate }),
|
|
932
1043
|
s.loading && h("div", { className: "d3v-loading" }, "\u6B63\u5728\u66F4\u65B0\u6A21\u578B\u2026"),
|
|
933
1044
|
s.error && h("div", { className: "d3v-error" }, s.error),
|
|
934
1045
|
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")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addozhang/dsh-3d-model-viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"engines": {
|