# Changelog All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.2.0] - 2026-09-18 ### Added - **`list_3dmodels` — the model library is finally readable by the agent.** Until now a stored model was write-only from the agent's side: `add_3dmodel` / `build_3dmodel` returned an id nobody could look up again, so an entry created in an earlier conversation could not be found, inspected or referred to. The new read-only tool returns, per entry, `id`, `title`, `name`, the resolved folder path, an ISO `createdAt`, `source`, `hasSource` (whether the CadQuery source was kept, i.e. whether exports are exact geometry) and the part / triangle counts — oldest first, matching the library's own order — plus every folder with its path and its direct-entry count. `query` is a case-insensitive substring match over title, name and id; `folder` accepts a folder id, a folder name or a path fragment, and because a folder's path contains its ancestors' names, matching one folder's path also picks up the nested folders below it (`/`, `root` or `根目录` narrows to the unfiled root instead); `limit` defaults to 100, caps at 500, and never hides the totals, which are reported as `total` (whole library), `matched` (after filtering) and `shown` (actually returned). The rendered text is one line per entry — `「标题」 id=… · 零件 2 · 12,345 面 · 有 CadQuery 源码 · 文件夹 A / B · 创建于 …` — with an explicit note when it truncated, so a partial answer never reads as the whole library. - **`delete_3dmodel` — removal, with a guard rail instead of a footgun.** Ids come from `list_3dmodels`; a `title` selects **exactly** (case-insensitive, surrounding spaces ignored) and is not assumed to be unique — `add_3dmodel` de-duplicates on geometry **+ title**, so two entries may legitimately share one. An ambiguous title therefore deletes **nothing** and returns the candidates (id, title, folder path, creation time) for the agent to choose from; `all: true` is the explicit opt-in that deletes every exact match. Passing both `id` and `title` is a cross-check rather than a second selector: a mismatch refuses and names the title the id actually carries. Deleting removes the stored file with its mesh and its saved CadQuery source — there is no undo, and the tool description says so — while the folder itself is never touched. Both tools render their failures as failures (`删除失败:…` plus the candidate list), because a tool whose error reads like a success is worse than a tool that has none. - **Editing a stored model is now an explicit question, asked in the tool descriptions themselves.** A request to change a model that is already in the library has two legitimate answers — replace that entry, or keep it and add the modified model as a separate copy — and only the user can choose. `add_3dmodel` and `build_3dmodel` now share one paragraph (`MODIFY_CONTRACT` in `lib/index.js`) telling the agent to ask first, wait for the answer, and only then store anything — because the de-duplication rule does **not** decide it: it keys on geometry + title, so re-storing a *changed* geometry under the same title creates a **second** entry and leaves the original untouched. The intuitive reading ("same title = overwrite") is wrong, and quietly producing a duplicate — or quietly destroying an entry — is exactly the surprise a library cannot undo. The same paragraph spells out how each answer is carried out: a replace stores the modified model **first** and deletes the old id afterwards, so a failed store costs nothing, while a copy simply gets its own title. `delete_3dmodel`'s description points back at it as the second half of that flow, the README says the same in Chinese, and both `dev/route-test.mjs` and `dev/tool-test.mjs` assert the wording reaches **both** store tools — the agent picks one of them per request, so stating it on either alone would leave the other path unguarded. - **`dev/tool-test.mjs`** — 50 checks that mount the plugin against a throwaway `dataDir` (`mkdtemp`, removed at the end; the real library is never touched), drive the five tools directly through a faked `ctx`, and pin the contracts above: the empty library, seeding three entries of which two share a title, the query/folder/limit filters (folders are created and moved through the real HTTP routes), refusals for a missing selector / unknown id / ambiguous title / id-title mismatch (each asserted to leave the library byte-for-byte intact), deletion by id and by `all: true`, the replace-vs-copy wording on both store tools, and the exact rendered text of the success and failure paths. - **Multi-part model trees out of `build_3dmodel`.** An assembly now lands in the viewer's navigation tree as **one node per part**, each carrying its own name and colour, so parts can be shown, hidden and picked individually instead of the whole model behaving as a single lump. A script can say so explicitly with a module-level `parts` list — `[(name, shape, color), …]`, or `{name, shape, color}` dicts — but it does not have to: `lib/cadquery_build.py` falls back to the `cq.Assembly` still sitting in the script namespace and reads back the per-part name, object, location and colour that `assy.toCompound()` throws away, applying each node's `loc` with `Shape.located()`. Failing that it splits a multi-solid compound into `Part_1`, `Part_2`, …; a single-solid model keeps the previous one-node behaviour, so nothing that worked before changes shape. Because the tree is keyed by part **name**, duplicates are suffixed `_2`, `_3`, … rather than silently overwriting each other. ### Changed - **The HTTP list and `list_3dmodels` now share one implementation.** `GET /3dmodel/api/items` built its metadata inline; it and the new tool would have drifted apart on the next field either of them added. Both now go through `store.listMeta()` (whole library, folders included), `store.metaOf()` (per-entry metadata) and `store.folderPath()` (a folder id resolved to `A / B`, cycle-safe), and the HTTP response is projected back down to exactly its previous field set — `folderPath`, `parts` and `triangles` are tool-only, which `dev/tool-test.mjs` asserts so the client's payload cannot change by accident. ### Fixed - **The navigation tree's show/hide toggles did nothing.** `_handleStateChange` calls `objectHandler(getNodePath(node), …)`, which ends in `setObject` resolving `nestedGroup.groups[path]` — the tree path being `//`, while the mesh is registered under the part **id**. Those two only matched by accident (for a single part both happened to be the part name), so any part whose id differed from `//` produced a lookup returning `undefined` and `setObject` returned *silently*: clicking the icon redrew the icon and changed nothing else, with no error anywhere. Part ids are now always `//`, matching the tree path exactly — which is also why names have to be deduplicated. - **`build_shape` emitted per-triangle face and edge grouping.** `face_types` and `triangles_per_face` carried one entry per **triangle**, and `edge_types` / `segments_per_edge` one entry per **line segment**, but the cad-format defines them as one entry per **B-Rep face** and per **B-Rep edge**: `_faceTriangleIndices` strides `triangles` by `3 * triangles_per_face[i]` to cut a face out of the soup, `_edgeSegments` slices `edges` by `6 * segments_per_edge[i]`, and `nodeCounts()` reports the face and edge counts straight from those array lengths. Declaring every triangle a face of its own is what made a tessellated part render as **a pile of triangles rather than a solid face**, put face picking and highlight on a single triangle, and report the triangle count as the face count. Both groupings are now derived from the real B-Rep: `lib/cadquery_build.py` walks `shape.Faces()` for each face's triangle count and its `BRepAdaptor_Surface(...).GetType()` — the `Geom_*` object returned by `BRep_Tool.Surface_s()` has no `GetType()` in OCP, so the adaptor is required or every face reports 0 (plane) — and walks `shape.Edges()`, sampling each real curve with `GCPnts_QuasiUniformDeflection`. For the Ø12.5 outer tube of the hat stand that is 8 faces and 14 edges instead of 2016 and 2028, and the axes-free `edges` array drops from 2028 line segments to 106. If the face order ever fails to line up with the merged tessellation, both fall back to a single group rather than emit counts that would mis-index `triangles`. ## [0.1.1] - 2026-09-11 ### Added - **The README points at the npm package now.** Both install paths were GitHub-only before; the npm route (`dsh plugin --profile web add dsh-cad-viewer`) is now listed first, the package page is linked from the badge row, the link line and the intro note, and the install section spells out what the tarball contains (`dev/` is not published). - **`screenshots.json`.** The dsh market reads screenshots out of the repository; declaring them keeps the selection and the order under our control instead of leaving the storefront to guess from the README. Two entries, both already in `docs/images/`. - **An AI-generation notice.** The README states up front that the code and documentation were produced by an AI coding assistant, with the maintainer setting the requirements, reviewing the changes and deciding on releases. It is both a badge and a section. ### Changed - **The README was rewritten around what a reader wants first.** Screenshots, what the plugin is and how to install it now come before everything else, and the document went from 522 to 284 lines: the table of contents, the long *why* narrative behind each feature and the full project-layout tree are gone, because that reasoning already lives in this changelog and in the code. Nothing actionable was dropped — every install command, route, config key, format name and troubleshooting row survives, re-checked as literal strings after the rewrite. - **`LICENSE` is plain MIT again, and `THIRD-PARTY-NOTICES.md` carries the notices.** The three-cad-viewer / three.js paragraph used to be glued to the end of the MIT text after a horizontal rule; GitHub's licensee cannot classify a LICENSE with extra prose appended and reported `NOASSERTION`, so the repository displayed no licence at all. Nothing is attributed less than before — the same notices were already in `assets/README.md` — and the new file ships in the npm tarball (`files`), so a consumer of the package still gets the attribution alongside the vendored bundle. ### Fixed - **README screenshots are absolute URLs now.** `docs/images/*.png` were referenced relatively, which renders on GitHub but not on the npm package page: npm does not rewrite relative image paths, so both screenshots were broken there. They now point at `raw.githubusercontent.com`. ## [0.1.0] - 2026-09-11 First public release. ### Added - **Screenshots in the README.** A *截图* section now sits directly after the overview in `README.md`, next to a new `docs/images/` row in the project-layout tree. Two images: the model library with a card menu open and the **导出** flyout listing all ten formats — a mesh-only entry, so its header reads *仅有网格 · 由三角网格重建几何* — and a stored model open in a workbench tab, with the plugin's 导出 button visible immediately before the `?` help button. - **`cadquery_env` tool + an install guide the agent executes.** Because CadQuery is not bundled, the READMEs now carry an *Installing CadQuery* section written as a procedure the agent runs (probe → create venv → `pip install cadquery vtk` → set `cadqueryPython` in the profile's `cordis.patch.yml` → restart → re-probe), with the versions/paths verified and the config snippet confirmed against `dsh --profile web --patch … --dump-config`. The new read-only `cadquery_env` tool is the agent's handle on it: it probes the configured (or a given) interpreter through the new `lib/cadquery_probe.py` and returns the interpreter path, Python version, whether `cadquery` / `OCP` / `vtkmodules` / `ezdxf` import, what is missing, and the exact `pip install` command to fix it — plus the whole venv-create sequence when the interpreter path is dead. `dev/route-test.mjs` calls the tool for real (36 checks). - **CadQuery attribution in the READMEs.** Both documents now name CadQuery (Apache-2.0, © 2015 Parametric Products Intellectual Holdings, LLC) in the third-party notices, state that it is **not bundled** — the plugin calls the interpreter configured in `cadqueryPython` — and list the components its export paths actually reach: cadquery-ocp (Apache-2.0, bindings to Open CASCADE Technology, itself LGPL-2.1 with an exception), ezdxf (MIT, DXF) and VTK (BSD, VTP). The requirements table and the CadQuery sections link to it as well. - **导出 icon button in the viewer toolbar.** The workbench's three-cad-viewer toolbar gets an 导出 button, placed **just before the help (`?`) button**, built from the viewer's own button markup (`span.tcv_tooltip[data-tooltip] > span.tcv_button_frame > input.tcv_reset.tcv_btn.tcv_button_export`) so it inherits the toolbar's sizing, hover highlight and tooltip; only the icon is ours — a download arrow into a tray drawn on the viewer's 26×26 canvas with its palette (`#444` outlines, `rgb(83,160,227)` blue). `dev/make-icon-preview.mjs` renders the icon next to the viewer's own icons for comparison, reading the data URI straight out of `lib/client.js`. The button opens the **same format flyout** the library cards use — identical list from `GET /3dmodel/api/items/formats`, same download path and dialog — for the model open in that tab, and clicking it again closes the flyout. It is re-placed in front of help if the viewer rebuilds its toolbar, and the CAD view re-fits once if the button wraps the toolbar onto a second row. A workbench fed by an inline card that carries no library id stores the model first (the store de-duplicates on geometry + title, so this normally resolves to the entry the model tool already created) and then exports. - **Export submenu on model cards** (`⋯` → 导出 ▸, right-clicking the card opens the same menu): it pops out **on hover** (a tap opens it too, since touch has no hover) and the card menu stays open in both cases, so 重命名 / 移动到… / 删除 remain reachable. It opens to the **right** of the card menu and aligned with the 导出 row — flipping left only when the right side has no room, clamped into the viewport and re-aligned once its real height is measured. It hides as soon as the pointer enters another entry (重命名 / 移动到… / 删除) or leaves the menu area — with a short grace period so travelling across the gap into the flyout does not flicker — and also on Esc or a press outside; picking a format closes both menus. It covers every format CadQuery 2.x can write — **STEP, BREP, STL, 3MF, AMF, VRML, VTP, TJS, SVG, DXF** — grouped as *精确几何 / 网格 / 二维图纸*, and its header says whether the entry exports exact geometry or is rebuilt from its triangle mesh. The submenu indicator is a CSS triangle (6×10 px, centred on the row) instead of a small `▸` glyph. - **`lib/cadquery_export.py`**: the server-side exporter. Entries whose CadQuery source was kept are rebuilt and handed to CadQuery's own exporters; mesh-only entries get the closest input the stored triangles allow — planar faces sewn into a shell/solid for STEP/BREP, attached `Poly_Triangulation` faces for the mesh formats, `exportDXF` over the planar faces, and a silhouette + feature-edge projection for SVG (hidden-line removal over a triangle soup takes minutes at ~10k faces). - **`GET /3dmodel/api/items/formats`** — the format catalog the card menu is built from, so the menu cannot drift from what the exporter supports. - **`GET|HEAD /3dmodel/api/items/:id/export?format=`** — runs the exporter for one entry and streams the result as a download (`content-disposition: attachment`, RFC 5987 `filename*` for non-ASCII titles). `400` unknown format, `404` unknown entry, `405` wrong method, `500` exporter failure. - **CadQuery source is kept** with models created by `build_3dmodel` (`script`, capped at 256 KB) so their exports are exact; `hasSource` is reported in the list metadata. Re-adding the same geometry and title refreshes the stored source. - **`cadqueryExportTimeout` config** (default 5 minutes) bounding one export. - **Progress/result dialog** for an export (dismissable while the server keeps building the file), plus `dev/route-test.mjs`, `dev/client-render-test.mjs` and `dev/restart-verify.ps1` as runnable checks for the server routes, the client interaction and a rollback-safe restart. - **"3D模型" tab** in the dsh conversation view (`conversation.view` slot, `id: "model"`, `order: 21`), registered by the client half with the locale-aware label `3D Model` / `3D模型`. - **Model library**: folder-scoped grid with nested folders, breadcrumbs and a *返回上一级* card, library-wide search, per-card `⋯` menu (rename / move / delete), double-click rename, drag & drop with a touch long-press path, blank-space context menu (new folder / refresh). Deleting a folder moves its models and child folders up instead of deleting them. - **Workbench**: one closable tab per model, mounting the upstream three-cad-viewer UI (toolbar, navigation tree, canvas) with a pure-CSS responsive layout and a `ResizeObserver` re-fit; left/right button rotate, middle button pan, wheel zoom. - **Inline model cards** at the message tail (`conversation.chat.turnTail`) for turns that called a model tool, with collapse, *全屏* (open as a workbench tab) and retry on load failure. - **Chinese localisation** of the hard-coded English three-cad-viewer UI (labels, tooltips, help table, select options), applied by walking the rendered DOM. - **`add_3dmodel`** tool: persist a three-cad-viewer `Shape` (cad-format JSON) in the server-side model library and render it as an inline card. - **`build_3dmodel`** tool: run a CadQuery script through `lib/cadquery_build.py`, tessellate the solid into a `Shape` (smooth per-vertex normals, boundary edges, bounding box) and persist it. - **Server-side model store**: one JSON file per model plus `folders.meta.json` under `dataDir` (default `/data`), shared by every device on the same dsh instance — no IndexedDB, no external service. - **Content fingerprint** (`h`, SHA-256 over the canonicalised `Shape`) so the same geometry under the same title updates the existing entry instead of creating a duplicate, with a retry loop for concurrent saves and an in-place upgrade pass for entries written by older builds. - **HTTP API**: `/tcv/` for the vendored three-cad-viewer assets, and `/3dmodel/api/items` (list / create / read / patch / delete) plus `/3dmodel/api/items/folders` (list / create / patch / delete). - **Vendored three-cad-viewer bundle** in `assets/`, so the plugin is self-contained and needs no build step. - MIT `LICENSE`, `.gitignore` (excludes `node_modules/`, the `data/` model library and backups), and this changelog. ### Changed - **`@deepseek-ai/dsh-tools` moved from `dependencies` to `peerDependencies` — and only that one.** dsh provides it: the plugin's `node_modules` links it straight at the harness's own copy, while `@deepseek-ai/schemastery` was installed from the registry like any other dependency — both facts readable in the plugin's `node_modules/.package-map.json` and `.pnpm/`. So declaring `dsh-tools` as a plain dependency made npm install a *second* copy of the tool-registry module, while making `schemastery` a peer would have removed the only copy the plugin can actually resolve. The peer range is written out per tuple (`>=0.1.5-0 <0.1.6`, …) because node-semver admits a prerelease only when some comparator shares its exact `major.minor.patch` tuple *and* carries a prerelease tag itself: the range usually suggested for this, `>=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0`, matches `0.1.0-rc.x` and nothing else — it does **not** match `0.1.5-rc.1`, the harness this is developed against — and `*` matches no prerelease at all. Extend the last branch when the harness moves to 0.2. The range deliberately starts at `0.1.0-0` instead of `0.0.1-0`: with a `<0.1.0` branch present, npm's peer auto-install matched the `latest` tag (`0.0.1-rc.1`, a long-obsolete harness line) against it and put that copy next to the one dsh actually provides — measured by installing the packed tarball into an empty project, which now resolves `0.1.5-rc.2`. - **The English README is gone; `README.md` is now the Chinese one.** The English document was deleted and `README.zh-CN.md` was renamed to `README.md`, so the repository and the npm package each carry a single Chinese README. It has to be that name: npm and GitHub only pick up a file called `README.md`, so leaving the Chinese text under the `zh-CN` suffix would have published a package whose page had no README at all. The cross-language links at the top of both documents, the two `README.md` / `README.zh-CN.md` rows in the project-layout tree and the *Screenshots* entry below were updated with it. - **Renamed to `dsh-cad-viewer`.** The npm package, the repository and the bundle patch moved from `dsh-model-viewer` to `dsh-cad-viewer`, and the plugin id from `model-viewer` to `cad-viewer` — that is `package.json`, `cordis.patch.yml`, the `name` export in `lib/index.js`, the log prefixes, the client module id (`window.__ModuleLoader__.load`), the injected CSS id / `data-*` attributes and the `dsh-cad-viewer:open-workbench` event. Nothing else moved: the `3D模型` tab, the slot id `model`, the `/tcv` and `/3dmodel` routes, `dataDir` and the stored model library keep their names, so an existing install only has to re-add the plugin under its new name (the old `link:` dependency and its `node_modules` junction point at the old directory). - **The viewer toolbar is symmetric again** — both vertically and around its group dividers. The toolbar carried `padding: 0 6px 6px` plus a second `padding-bottom: 6px` rule (a leftover from keeping the shape-filter pill clear of its bottom border), so the icons sat 2px from the top of the row and 8px from the bottom; and the plugin's `[data-dmv] .tcv_cad_toolbar > *{margin:0}` reset wiped the one-sided rule the viewer spaces its dividers with (`.tcv_separator{margin-left:6px;padding-right:5px}`), leaving each divider 0px from the group on its left and 5px from the group on its right. Now: toolbar padding `0 6px` (the buttons' own 2px frame supplies the vertical breathing room → 2px above, 2px below), and the divider's horizontal padding is folded into equal `margin: 0 6px` (→ 6px on both sides). The 6px row gap to the canvas (`--dmv-row-gap`) and the pill's position are unchanged — the pill is laid out inside `.tcv_cad_view`, not from the toolbar's padding. Measured rather than guessed: `dev/make-toolbar-layout.mjs` builds a page with the real viewer bundle plus the real plugin CSS (dumped straight out of the client bundle by `dev/dump-css.mjs`), and headless Chrome reports the toolbar's box model, the button gaps and every divider's left/right gap. - **The agent-facing tool descriptions were tightened.** `build_3dmodel` now names the real tab (`'3D模型'`, it previously said `'模型'`), states that the script is kept with the entry (≤256 KB) so that entry can later be exported as **exact geometry** in every format CadQuery writes, documents the geometry+title de-duplication key, the `{ok:false,error}` failure contract, the 60 s build timeout and the units, and points out that an assembly must be converted (`model = assy.toCompound()`) because only a real `Workplane`/`Shape` is tessellated. `add_3dmodel` no longer points at a non-existent `examples/box1.js`; it describes the cad-format structure inline instead. `dev/route-test.mjs` now asserts these descriptions (27 checks). - **The blank-space menu belongs to the model-library panel.** It was anchored at the pointer with only a viewport clamp, so it could hang over the tab strip or the workbench edge and it appeared anywhere in the tab. It now only opens for a right-click **inside the library panel** and is clamped to that panel's box; a right-click outside the panel, or on a field (search box), is left to the browser's own menu. Opening a card menu — by right-click or by the `⋯` button — dismisses it, so the two menus never show at once. - **The card menu (model and folder cards) is now anchored to the pointer.** It was a card-anchored popup pinned above the `⋯` button; it is now viewport-fixed and a **right-click opens it where the mouse is** (a second right-click moves it there rather than toggling it away, and it is clamped into the viewport). The `⋯` button still works and anchors the menu under the button, flipping above it when there is no room below. Scrolling the grid, clicking elsewhere and Esc all close it (a fixed menu would otherwise float away from its card). ### Fixed - **`dev/restart-verify.ps1` no longer fails on an empty library.** Its export probes pick the first stored model; with no models left it counted that as a failed health check and would have disabled the plugin on the next restart. An empty library is now a skip. - **A missing or broken CadQuery now explains itself.** Both helper scripts imported `vtkmodules` **unconditionally** as the first thing they did (a DLL-conflict workaround), so an environment with CadQuery but no VTK failed with an ImportError raised by our own import line — and a missing CadQuery died with a traceback that only reached the server log. The VTK import is now tolerant (it only helps when VTK is there; nine of the ten formats never need it), a failing `import cadquery` prints `{ok:false, error}` with the interpreter path *and* the install command (`pip install cadquery vtk` — vtk is required at import time even though cadquery 2.4 omits it from its dependency list), and the VTP writer says what to install instead of failing silently. - **VTP export is no longer empty.** CadQuery's exporter goes through the OCC/VTK bridge (`IVtkOCC_ShapeMesher`), which returns an empty polydata on Windows hosts — a 2 KB `.vtp` with 0 points. The bridge is still used when it produces something; otherwise the triangles are written with the same `vtkXMLPolyDataWriter` CadQuery itself uses (mesh entries from the stored mesh, script entries from the tessellated shape). [Unreleased]: https://github.com/CMoyuer/dsh-cad-viewer/compare/v0.2.0...HEAD [0.2.0]: https://github.com/CMoyuer/dsh-cad-viewer/releases/tag/v0.2.0 [0.1.1]: https://github.com/CMoyuer/dsh-cad-viewer/releases/tag/v0.1.1 [0.1.0]: https://github.com/CMoyuer/dsh-cad-viewer/releases/tag/v0.1.0