@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 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 items = [...root.doc.getElementsByTagNameNS("*", "item")];
357
- if (items.length) {
358
- for (const item of items) {
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 mesh = finishMesh(new Float32Array(vals), triangles);
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 scale = 1 / mesh.radius;
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] * mesh.center[0] + model[4] * mesh.center[1] + model[8] * mesh.center[2]) + pan.current[0];
580
- model[13] = -(model[1] * mesh.center[0] + model[5] * mesh.center[1] + model[9] * mesh.center[2]) + pan.current[1];
581
- model[14] = -(model[2] * mesh.center[0] + model[6] * mesh.center[1] + model[10] * mesh.center[2]) - zoom;
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
- gl.drawArrays(gl.TRIANGLES, 0, mesh.data.length / 6);
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 = mesh.radius * 0.18;
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(mesh.lo, mesh.hi, m);
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 = mesh.size[axis === "x" ? 0 : axis === "y" ? 1 : 2].toFixed(2);
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.2",
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": {