diff --git a/README.md b/README.md index cf6f65f..69a1603 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) | | [灵签易占 / Chinese Divination](plugins/weekbin/chinese-divination/) | Cast hexagrams with the Plum Blossom method (time, number, three-coin, or daily), read the result through the classical body/use rules, and browse all sixty-four hexagrams with a ganzhi almanac | [weekbin](https://github.com/weekbin) | | [Session Trajectory](plugins/avatasia/mmc-trajectory/) | Browse a conversation's model trajectory: messages, reasoning, tool calls and results, Token usage, and per-turn timing | [avatasia](https://github.com/avatasia) | -| [CadQuery Viewer](plugins/hanzijie/cq-viewer/) | View STL / STEP models from your project (STEP keeps assembly part names, colours, and real CAD edges), watch models pushed from CadQuery scripts, and mark positions on the model to send to the conversation in one click | [HanZijie](https://github.com/HanZijie) | +| [CadQuery Viewer](plugins/hanzijie/cq-viewer/) | View STL / STEP models from your project (STEP keeps assembly part names, colours, and real CAD edges), watch models pushed from CadQuery scripts, and mark positions on the model to send to the conversation in one click; MCP tools let the AI add model folders and open models, and a CadQuery parametric-design skill is bundled | [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index a11c105..e053be4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) | | [灵签易占](plugins/weekbin/chinese-divination/README.zh-CN.md) | 梅花易数起卦与解卦:时间、数字、铜钱摇卦、每日一卦四法起卦,按体用生克断吉凶,另备六十四卦全文与干支历法 | [weekbin](https://github.com/weekbin) | | [会话轨迹](plugins/avatasia/mmc-trajectory/README.zh-CN.md) | 按轮次浏览一段会话的模型轨迹:消息流、思考过程、工具调用与结果、token 用量与每轮耗时 | [avatasia](https://github.com/avatasia) | -| [CadQuery Viewer](plugins/hanzijie/cq-viewer/README.zh-CN.md) | 本地查看项目里的 STL / STEP(保留装配零件名称、颜色和真实边线),实时显示 CadQuery 脚本推送的模型,在模型上标注位置后一键发给 AI | [HanZijie](https://github.com/HanZijie) | +| [CadQuery Viewer](plugins/hanzijie/cq-viewer/README.zh-CN.md) | 本地查看项目里的 STL / STEP(保留装配零件名称、颜色和真实边线),实时显示 CadQuery 脚本推送的模型,在模型上标注位置后一键发给 AI;AI 可通过 MCP 工具添加模型文件夹、打开模型,并内置 CadQuery 参数化建模 skill | [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) |
预览:Token 用量看板 diff --git a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json index b55b910..5d63d87 100644 --- a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json +++ b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json @@ -2,17 +2,23 @@ "schemaVersion": 1, "name": "cq-viewer", "displayName": "CadQuery Viewer", - "version": "1.0.0", - "description": "本地 3D 模型查看器:浏览项目文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),接收 CadQuery 脚本推送的模型,在模型上标注位置并一键发给 AI。", - "author": "HanZijie", + "version": "1.1.0", + "description": "本地 3D 模型查看器:浏览项目文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),接收 CadQuery 脚本推送的模型,在模型上标注位置并一键发给 AI。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;内置 flowful-ai 的 CadQuery 参数化建模 skill。", + "author": "HanZijie, flowful-ai", "icon": "icon.png", "category": "Code", "exampleQueries": [ "打开 CadQuery Viewer 看一下这个项目里的模型", "用 CadQuery Viewer 打开这个 STEP 装配", - "我在 Viewer 里标注了几个位置,读一下再改模型" + "我在 Viewer 里标注了几个位置,读一下再改模型", + "用 CadQuery 设计一个带安装孔的支架,做好后在 Viewer 里打开", + "把这个项目文件夹添加到 CadQuery Viewer" ], "apps": [], - "mcpServers": [], - "skills": [] + "mcpServers": [ + "servers.mcp.json" + ], + "skills": [ + "skills/parametric-3d-printing/SKILL.md" + ] } diff --git a/plugins/hanzijie/cq-viewer/LICENSE b/plugins/hanzijie/cq-viewer/LICENSE index 93bf933..1058c3f 100644 --- a/plugins/hanzijie/cq-viewer/LICENSE +++ b/plugins/hanzijie/cq-viewer/LICENSE @@ -22,3 +22,7 @@ SOFTWARE. Third-party components keep their own licenses: - three.js (MIT) — miniapp/client/assets/vendor/LICENSE-three.txt +- skills/parametric-3d-printing/ — from https://github.com/flowful-ai/cad-skill, + PolyForm Noncommercial License 1.0.0 (noncommercial use only), see + skills/parametric-3d-printing/LICENSE. This MIT license does not apply to it. + Required Notice: Copyright Nicolas Chourrout (https://github.com/nchourrout) diff --git a/plugins/hanzijie/cq-viewer/README.md b/plugins/hanzijie/cq-viewer/README.md index 21c89e1..676ee76 100644 --- a/plugins/hanzijie/cq-viewer/README.md +++ b/plugins/hanzijie/cq-viewer/README.md @@ -7,9 +7,9 @@ CadQuery Viewer -A local 3D viewer for MiniMax Code. Open STL and STEP models from your project folder — STEP assemblies keep their part names, colours, and real CAD edges — watch models pushed live from CadQuery scripts, then mark positions on the model and send them to the conversation in one click, so the AI knows exactly which face, hole, or edge you mean. +A local 3D viewer for MiniMax Code. Open STL and STEP models from your project folder — STEP assemblies keep their part names, colours, and real CAD edges — watch models pushed live from CadQuery scripts, then mark positions on the model and send them to the conversation in one click, so the AI knows exactly which face, hole, or edge you mean. The AI can register model folders and open models in the viewer through MCP tools, and the package bundles flowful-ai's CadQuery parametric-design skill. -Author: [HanZijie](https://github.com/HanZijie) · Version: `1.0.0` +Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) (bundled CadQuery skill) · Version: `1.1.0` ![CadQuery Viewer: a STEP assembly with three annotations ready to send](docs/preview.jpg) @@ -23,12 +23,15 @@ Author: [HanZijie](https://github.com/HanZijie) · Version: `1.0.0` | CadQuery push | `python/cqview.py` → `show(part_or_assembly, "name")` writes into `~/.cqview/scene/`; the Viewer shows it immediately. Assemblies are pushed as STEP so names and colours survive. | | Annotate | Pick mode (`P`): click several points on the surface, then confirm (`Enter`). Each point records part, sub-part (for assemblies), coordinates, and surface normal; add a note per point plus one instruction for the AI. | | Send to AI | One button sends the composed message to the conversation (see [Sending to the conversation](#sending-to-the-conversation)). The same data is kept in `~/.cqview/selection.json` for agents and scripts. | +| AI tools (MCP) | `add_model_folder`, `add_model_file`, `list_model_folders` — the AI registers folders and opens the models it just exported; an open viewer switches to them at once, a closed one does so when it next opens (see [AI tools](#ai-tools-mcp)). | +| CadQuery skill | `parametric-3d-printing` from [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill): requirements → phased CadQuery modelling → STL/STEP export → watertight check → multi-view preview, plus a tested Gridfinity generator (see [Bundled CadQuery skill](#bundled-cadquery-skill)). | ## Requirements - MiniMax Code with Mini App support. - STL needs nothing else. - STEP needs Python 3 with [CadQuery](https://github.com/CadQuery/cadquery) (`pip install cadquery`). The Viewer looks for `$CQVIEW_PYTHON`, `python3`, `python`, `/usr/bin/python3`, `/opt/homebrew/bin/python3`, `/usr/local/bin/python3` and uses the first one that can `import cadquery`. +- The bundled skill's scripts additionally need `trimesh`, `pyrender`, `Pillow`, `numpy`, and `lxml` (`skills/parametric-3d-printing/requirements.txt`); `outline_from_scan.py` also needs `shapely`. ## Install & use @@ -38,10 +41,10 @@ Copy this directory into the active MiniMax Code data directory under `plugins/` /plugins/cq-viewer/ ``` -Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code, then open "CadQuery Viewer" from the plugin list or ask the assistant, for example "打开 CadQuery Viewer 看一下这个项目里的模型". +Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code, then open "CadQuery Viewer" from the plugin list or ask the assistant, for example "打开 CadQuery Viewer 看一下这个项目里的模型". The MCP tools and the skill are available in conversations started after installation. -1. **Pick a folder.** Click the folder icon on the left, then **+** to add a folder (any folder inside your home directory). An assistant can also open the page on the current workspace with the path `/viewer?root=`. -2. **Open models.** Tick files in the list. Use the toolbar at the bottom for views, fit (`F`), edges, grid, and section. +1. **Pick a folder.** Click the folder icon on the left, then **+** to add a folder (any folder inside your home directory). The assistant can also do this with `add_model_folder`, or open the page with `/viewer?root=`. +2. **Open models.** Tick files in the list, or let the assistant call `add_model_file`. Use the toolbar at the bottom for views, fit (`F`), edges, grid, and section. 3. **Annotate.** Press **选点** (`P`), click points on the model, press **确认** (`Enter`). Write a note for each point and what you want the AI to do. 4. **Send.** Press **发送到对话** (`⌘/Ctrl + Enter` in the instruction box). @@ -54,6 +57,25 @@ Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code, then open "C | `⌘/Ctrl + Z` | Undo the last annotation change | | `F` | Fit the view | +### AI tools (MCP) + +The Mini App serves an MCP server named `cq-viewer` (Streamable HTTP, `POST /mcp/cq-viewer` on its own loopback listener). MiniMax Code starts the Mini App when a tool is called. + +| Tool | What it does | +| --- | --- | +| `add_model_folder` | `{ path, select? }` Registers a folder (same rules as **+**: inside your home directory, not home itself, `~/Library`, or a hidden top-level folder). Returns the folder ID and up to 200 model paths. With `select` (default `true`) the viewer switches to it. | +| `add_model_file` | `{ path }` Opens an `.stl` / `.step` / `.stp` in place: the closest registered folder that lists the file is used, otherwise the file's own folder is registered. The viewer ticks it, fits the view, and reloads it whenever the file is re-exported. The file must be inside your home directory. | +| | `{ path, copy_to_scene: true, name?, color?, opacity? }` Copies the file into `~/.cqview/scene/.` instead (like `cqview.show_file`), for files elsewhere such as `/tmp`. Later edits to the source are not followed. | +| `list_model_folders` | `{ folder? }` Lists registered folders and the pushed scene; with a folder ID or path, lists that folder's models (absolute paths, up to 200). Read-only. | + +Requests are kept in `dataDir` for 24 hours (last 20), so a model the AI opens while the viewer is closed appears when you next open it. Each browser carries out a request once; closing a file yourself is not undone on reload. Results include `viewer_connected`, which tells the AI whether a viewer window is currently listening. + +### Bundled CadQuery skill + +`skills/parametric-3d-printing/` is the `parametric-3d-printing` skill from [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill) (commit `fe42159`), listed in MiniMax Code as `cq-viewer:parametric-3d-printing`. It guides the agent through requirements, phased modelling with show-as-you-go previews, printability review, and slicer recommendations, and ships `run_cadquery_model.py`, `preview.py`, `gridfinity.py`, `stl_to_3mf.py`, and `outline_from_scan.py`. One section is appended to its `SKILL.md` telling the agent to open each exported model with `add_model_file`; everything else is unchanged — see `skills/parametric-3d-printing/UPSTREAM.md`. + +The skill is licensed under the [PolyForm Noncommercial License 1.0.0](skills/parametric-3d-printing/LICENSE), not MIT: noncommercial use only. Required Notice: Copyright Nicolas Chourrout (https://github.com/nchourrout). + ### Push models from CadQuery ```python @@ -79,17 +101,19 @@ The page first performs the standard MCP Apps handshake (`ui/initialize` over `p **What it reads** -- Folders you register: directory listings plus the contents of `.stl`, `.step`, and `.stp` files only. Hidden directories, `node_modules`, `__pycache__`, `venv`, and `site-packages` are skipped; symbolic links are never followed; at most 10 levels, 50,000 entries, and 1,000 model files per folder. A folder must be inside your home directory, and cannot be the home directory itself, `~/Library`, or a hidden top-level folder. -- `~/.cqview/scene/` — models pushed by `python/cqview.py` (`.stl`, `.step`, `.stp`, `scene.json`). +- Folders you or the AI register: directory listings plus the contents of `.stl`, `.step`, and `.stp` files only. Hidden directories, `node_modules`, `__pycache__`, `venv`, and `site-packages` are skipped; symbolic links are never followed; at most 10 levels, 50,000 entries, and 1,000 model files per folder. A folder must be inside your home directory, and cannot be the home directory itself, `~/Library`, or a hidden top-level folder. +- `add_model_file` with `copy_to_scene`: the one model file the AI names, wherever it is (symbolic links resolved; `.stl`/`.step`/`.stp` regular files up to 512 MB). +- `~/.cqview/scene/` — models pushed by `python/cqview.py` or `copy_to_scene` (`.stl`, `.step`, `.stp`, `scene.json`). - `~/.cqview/roots.json` and `~/.cqview/selection.json` (its own files, see below). -**What it writes.** Outside `dataDir` it writes only its own exchange folder `~/.cqview/`, always atomically (temp file in the same folder, then rename): +**What it writes.** Outside `dataDir` it writes only its own exchange folder `~/.cqview/`, always atomically (temp file in the same folder, then rename), one write at a time: -- `~/.cqview/roots.json` — the folders you registered (when you add one, or the page is opened with `?root=`). +- `~/.cqview/roots.json` — the registered folders (when you add one, the page is opened with `?root=`, or the AI calls `add_model_folder` / `add_model_file`). - `~/.cqview/selection.json` — the current annotations: points, notes, instruction, and the absolute path of each annotated model. Rewritten on every change; the last write wins. -- It creates `~/.cqview/` and `~/.cqview/scene/` if missing. It never writes model files; only `cqview.py` does. +- `~/.cqview/scene/.` and `~/.cqview/scene/scene.json` — only for `add_model_file` with `copy_to_scene`: the copied model, its colour/opacity, and removal of the same name in the other model formats (as `cqview.py` does). +- It creates `~/.cqview/` and `~/.cqview/scene/` if missing. It never writes into your project folders. -Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file path, size, and modification time. Deleting it is safe. +Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file path, size, and modification time (deleting it is safe), and `open-requests.json`, the last 20 AI requests from the past 24 hours (folder ID, path relative to that folder, or scene name). **Processes.** Python is started only when a STEP file is opened, with arguments passed as an array (no shell), stdio ignored, and every child killed on shutdown: @@ -99,9 +123,13 @@ Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file The model path is validated first (registered folder, allowed extension, no symbolic link) and passed as a single argument. +**MCP endpoint.** `POST /mcp/cq-viewer` on the Mini App's loopback listener. It rejects requests whose `Host` or `Origin` is not loopback, and accepts JSON bodies only. The tools apply the same folder rules as the page. + +**Bundled skill.** The Mini App never runs it. When the agent follows the skill it runs the skill's Python scripts itself, through its normal shell tool and permission prompts: `run_cadquery_model.py` executes the model script in a child Python process and collects the files it wrote; `preview.py` renders PNGs offscreen. The scripts make no network requests; the skill asks the agent to look up real-world dimensions with web search, and its setup section installs the Python packages above with `pip` (or `conda`) if they are missing. + **Watching.** While the Viewer runs it watches `~/.cqview/scene/` and each registered folder (recursively) for model changes. -**Browser storage** holds view preferences only: selected folder, opened files, hidden parts, edge/grid toggles. +**Browser storage** holds view preferences only: selected folder, opened files, hidden parts, edge/grid toggles, and the number of the last AI request this browser carried out. **Host page bridge.** On **Send** only, as described above. @@ -109,15 +137,19 @@ The model path is validated first (registered folder, allowed extension, no symb ## Tested environment -- MiniMax Code desktop `3.1.1-preview.4597` (Electron 42), macOS 26 (Apple Silicon); Python 3.9 with CadQuery 2.5.2 / OCP 7.7.2. -- In MiniMax Code: install, open, folder registration via `?root=`, and Host bridge detection (the `ui/initialize` handshake is not answered in this build, so the fallback path is used). -- End-to-end in headless Chrome against the same Node entry: STL and STEP opening (multi-level assemblies with names, colours, instances), live reload, pick → confirm → notes, the `ui/message` path against a simulated MCP Apps host, the fallback path against a simulated page bridge, and the button switching to copy mode when no Host is present. -- Not verified: Windows; that the fallback actually fills the conversation input in this build; STEP files with face-level colours from other CAD tools. +- MiniMax Code desktop `3.1.1-preview.4597` (Electron 42), macOS 26 (Apple Silicon); Python 3.9 with CadQuery 2.5.2 / OCP 7.7.2, trimesh 4.12, pyrender 0.1.45. +- In MiniMax Code (1.1.0): local install; a new conversation lists `mcp__cq-viewer__add_model_folder`, `mcp__cq-viewer__add_model_file`, `mcp__cq-viewer__list_model_folders` and the skill `cq-viewer:parametric-3d-printing`; `list_model_folders` and `add_model_folder` (`select: false`) called through the Host. Earlier (1.0.0): open, folder registration via `?root=`, and Host bridge detection (the `ui/initialize` handshake is not answered in this build, so the fallback path is used). +- `tests/mcp.test.mjs` (`node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`): the three tools, folder rules, copy to scene with colour, request log across a restart, loopback/JSON checks — 10 tests against the real Node entry with a temporary home directory. +- End-to-end in headless Chrome against the same Node entry: the page switches folder and opens the file when the AI calls the tools; `copy_to_scene` shows the copied part in its colour; a file requested while the page is closed opens on the next visit, is not replayed after that, and stays closed once you untick it; `viewer_connected` turns false when the page goes away. From 1.0.0: STL and STEP opening (multi-level assemblies with names, colours, instances), live reload, pick → confirm → notes, the `ui/message` path against a simulated MCP Apps host, the fallback path against a simulated page bridge, and copy mode without a Host. +- Bundled skill: `run_cadquery_model.py --preview --strict` run from the installed plugin directory (model → STL → watertight check → preview PNG). +- Not verified: Windows; `add_model_file` through the Host with the viewer panel open (the page side was verified in headless Chrome only); that the Send fallback actually fills the conversation input in this build; STEP files with face-level colours from other CAD tools; the skill's Gridfinity generator with CadQuery 2.5 (the skill asks for 2.6.1 or later). ## Third-party -[three.js](https://threejs.org/) r170 (`three.module.min.js`, `OrbitControls.js`, `STLLoader.js`), MIT — see `miniapp/client/assets/vendor/LICENSE-three.txt`. STEP conversion uses the CadQuery / OCP installed on your machine; nothing from it is bundled. +- [three.js](https://threejs.org/) r170 (`three.module.min.js`, `OrbitControls.js`, `STLLoader.js`), MIT — see `miniapp/client/assets/vendor/LICENSE-three.txt`. +- `skills/parametric-3d-printing/` from [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill), PolyForm Noncommercial 1.0.0 — see `skills/parametric-3d-printing/LICENSE` and `UPSTREAM.md`. +- STEP conversion uses the CadQuery / OCP installed on your machine; nothing from it is bundled. ## License -[MIT](LICENSE) +[MIT](LICENSE), except `skills/parametric-3d-printing/`, which keeps its own [PolyForm Noncommercial 1.0.0](skills/parametric-3d-printing/LICENSE) license. diff --git a/plugins/hanzijie/cq-viewer/README.zh-CN.md b/plugins/hanzijie/cq-viewer/README.zh-CN.md index b5936e4..d9de401 100644 --- a/plugins/hanzijie/cq-viewer/README.zh-CN.md +++ b/plugins/hanzijie/cq-viewer/README.zh-CN.md @@ -7,9 +7,9 @@ CadQuery Viewer -MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL 和 STEP(STEP 装配保留零件名称、颜色和真实 CAD 边线),实时显示 CadQuery 脚本推送的模型,在模型上标出位置后一键发到对话里,让 AI 准确知道你说的是哪个面、哪个孔、哪条边。 +MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL 和 STEP(STEP 装配保留零件名称、颜色和真实 CAD 边线),实时显示 CadQuery 脚本推送的模型,在模型上标出位置后一键发到对话里,让 AI 准确知道你说的是哪个面、哪个孔、哪条边。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;包里还内置了 flowful-ai 的 CadQuery 参数化建模 skill。 -作者:[HanZijie](https://github.com/HanZijie) · 版本:`1.0.0` +作者:[HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai)(内置的 CadQuery skill) · 版本:`1.1.0` ![CadQuery Viewer:一个 STEP 装配,已标注三个位置,准备发送](docs/preview.jpg) @@ -23,12 +23,15 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL | CadQuery 推送 | `python/cqview.py` 的 `show(零件或装配, "名字")` 写入 `~/.cqview/scene/`,Viewer 立即显示。装配以 STEP 推送,名称和颜色都保留。 | | 标注 | 选点模式(`P`):在表面上连续点多个位置,再确认(`Enter`)。每个点记录所在零件、装配里的子零件、坐标和表面法向;每个点可写备注,再写一句想让 AI 做什么。 | | 发给 AI | 一个按钮把整理好的消息发到对话(见[发送到对话](#发送到对话))。同样的数据也保存在 `~/.cqview/selection.json`,方便 Agent 和脚本读取。 | +| AI 工具(MCP) | `add_model_folder`、`add_model_file`、`list_model_folders`:AI 登记文件夹、打开它刚导出的模型。查看器开着就立刻切过去,没开就在下次打开时执行(见 [AI 工具](#ai-工具mcp))。 | +| CadQuery skill | 来自 [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill) 的 `parametric-3d-printing`:需求梳理 → 分阶段 CadQuery 建模 → 导出 STL/STEP → watertight 校验 → 多视角预览,另带一个经过测试的 Gridfinity 生成器(见[内置 CadQuery skill](#内置-cadquery-skill))。 | ## 运行要求 - 支持 Mini App 的 MiniMax Code。 - STL 不需要额外依赖。 - STEP 需要本机 Python 3 和 [CadQuery](https://github.com/CadQuery/cadquery)(`pip install cadquery`)。Viewer 依次查找 `$CQVIEW_PYTHON`、`python3`、`python`、`/usr/bin/python3`、`/opt/homebrew/bin/python3`、`/usr/local/bin/python3`,使用第一个能 `import cadquery` 的。 +- 内置 skill 的脚本另外需要 `trimesh`、`pyrender`、`Pillow`、`numpy`、`lxml`(见 `skills/parametric-3d-printing/requirements.txt`);`outline_from_scan.py` 还需要 `shapely`。 ## 安装与使用 @@ -38,10 +41,10 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL /plugins/cq-viewer/ ``` -保留隐藏目录 `.minimax-plugin/`。重启 MiniMax Code 后,从插件列表打开「CadQuery Viewer」,或直接对助手说「打开 CadQuery Viewer 看一下这个项目里的模型」。 +保留隐藏目录 `.minimax-plugin/`。重启 MiniMax Code 后,从插件列表打开「CadQuery Viewer」,或直接对助手说「打开 CadQuery Viewer 看一下这个项目里的模型」。MCP 工具和 skill 在安装之后新开的对话里可用。 -1. **选文件夹**:点左侧的文件夹图标,再点 **+** 添加文件夹(用户目录下的任意项目文件夹)。助手也可以用 `/viewer?root=<文件夹绝对路径>` 直接按当前工作区打开。 -2. **打开模型**:在列表里勾选文件。底部工具条可以切换视角、适配(`F`)、边线、网格和剖切。 +1. **选文件夹**:点左侧的文件夹图标,再点 **+** 添加文件夹(用户目录下的任意项目文件夹)。助手也可以调用 `add_model_folder`,或用 `/viewer?root=<文件夹绝对路径>` 打开页面。 +2. **打开模型**:在列表里勾选文件,或让助手调用 `add_model_file`。底部工具条可以切换视角、适配(`F`)、边线、网格和剖切。 3. **标注**:点 **选点**(`P`),在模型上点击位置,再点 **确认**(`Enter`)。给每个点写备注,并写下想让 AI 做什么。 4. **发送**:点 **发送到对话**(在说明框里可按 `⌘/Ctrl + Enter`)。 @@ -54,6 +57,25 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL | `⌘/Ctrl + Z` | 撤销上一次标注改动 | | `F` | 适配视图 | +### AI 工具(MCP) + +Mini App 自己提供一个名为 `cq-viewer` 的 MCP 服务(Streamable HTTP,在自己的回环端口上 `POST /mcp/cq-viewer`)。工具被调用时 MiniMax Code 会启动这个 Mini App。 + +| 工具 | 作用 | +| --- | --- | +| `add_model_folder` | `{ path, select? }` 登记一个文件夹,规则和 **+** 一样:在用户目录下,不能是用户目录本身、`~/Library` 或顶层隐藏目录。返回文件夹 ID 和最多 200 个模型路径。`select`(默认 `true`)为真时查看器切到这个文件夹。 | +| `add_model_file` | `{ path }` 原地打开一个 `.stl` / `.step` / `.stp`:优先用能列出这个文件的、最近的已登记文件夹,没有就登记文件所在的文件夹。查看器会勾选它、适配视图,文件重新导出时自动刷新。文件必须在用户目录下。 | +| | `{ path, copy_to_scene: true, name?, color?, opacity? }` 改为把文件复制到 `~/.cqview/scene/.<扩展名>`(和 `cqview.show_file` 一样),适合 `/tmp` 等其他位置的文件。之后源文件的修改不会同步。 | +| `list_model_folders` | `{ folder? }` 列出已登记的文件夹和推送场景;传文件夹 ID 或路径时,列出该文件夹里的模型(绝对路径,最多 200 个)。只读。 | + +请求会在 `dataDir` 里保留 24 小时(最近 20 条),所以查看器关着时 AI 打开的模型,会在你下次打开查看器时出现。每个浏览器只执行一次同一条请求;你自己取消勾选的文件,刷新后不会被重新打开。工具结果里的 `viewer_connected` 告诉 AI 当前有没有查看器窗口在线。 + +### 内置 CadQuery skill + +`skills/parametric-3d-printing/` 是 [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill) 里的 `parametric-3d-printing` skill(commit `fe42159`),在 MiniMax Code 里显示为 `cq-viewer:parametric-3d-printing`。它引导 Agent 梳理需求、分阶段建模并随时给出预览、做可打印性检查、给出切片建议,自带 `run_cadquery_model.py`、`preview.py`、`gridfinity.py`、`stl_to_3mf.py`、`outline_from_scan.py`。`SKILL.md` 末尾追加了一节,让 Agent 用 `add_model_file` 打开每个导出的模型;其余内容没有改动,见 `skills/parametric-3d-printing/UPSTREAM.md`。 + +这个 skill 使用 [PolyForm Noncommercial License 1.0.0](skills/parametric-3d-printing/LICENSE),不是 MIT:只能用于非商业用途。Required Notice: Copyright Nicolas Chourrout (https://github.com/nchourrout)。 + ### 从 CadQuery 推送模型 ```python @@ -79,17 +101,19 @@ clear() # 清空推送的模型 **读取** -- 你登记的文件夹:目录列表,以及 `.stl`、`.step`、`.stp` 文件的内容,不读其他文件。跳过隐藏目录、`node_modules`、`__pycache__`、`venv`、`site-packages`;不跟随符号链接;每个文件夹最多 10 层、5 万个目录项、1000 个模型文件。文件夹必须在用户目录下,且不能是用户目录本身、`~/Library` 或顶层隐藏目录。 -- `~/.cqview/scene/`:`python/cqview.py` 推送的模型(`.stl`、`.step`、`.stp`、`scene.json`)。 +- 你或 AI 登记的文件夹:目录列表,以及 `.stl`、`.step`、`.stp` 文件的内容,不读其他文件。跳过隐藏目录、`node_modules`、`__pycache__`、`venv`、`site-packages`;不跟随符号链接;每个文件夹最多 10 层、5 万个目录项、1000 个模型文件。文件夹必须在用户目录下,且不能是用户目录本身、`~/Library` 或顶层隐藏目录。 +- `add_model_file` 带 `copy_to_scene` 时:AI 指定的那一个模型文件,位置不限(解析符号链接;只接受不超过 512 MB 的 `.stl`/`.step`/`.stp` 普通文件)。 +- `~/.cqview/scene/`:`python/cqview.py` 或 `copy_to_scene` 推送的模型(`.stl`、`.step`、`.stp`、`scene.json`)。 - `~/.cqview/roots.json` 和 `~/.cqview/selection.json`(它自己的文件,见下文)。 -**写入**:在 `dataDir` 之外只写自己的交换目录 `~/.cqview/`,都是原子写入(同目录临时文件再改名): +**写入**:在 `dataDir` 之外只写自己的交换目录 `~/.cqview/`,都是原子写入(同目录临时文件再改名),并且逐个执行: -- `~/.cqview/roots.json`:登记过的文件夹(添加文件夹,或页面带 `?root=` 打开时)。 +- `~/.cqview/roots.json`:登记过的文件夹(添加文件夹、页面带 `?root=` 打开,或 AI 调用 `add_model_folder` / `add_model_file` 时)。 - `~/.cqview/selection.json`:当前标注,包括点、备注、说明和每个标注所在模型的绝对路径。每次修改都会重写,以最后一次写入为准。 -- 如果 `~/.cqview/` 和 `~/.cqview/scene/` 不存在会创建。Viewer 从不写模型文件,只有 `cqview.py` 会写。 +- `~/.cqview/scene/.<扩展名>` 和 `~/.cqview/scene/scene.json`:只在 `add_model_file` 带 `copy_to_scene` 时写,包括复制的模型、它的颜色/不透明度,并删除同名的其他格式模型(和 `cqview.py` 一样)。 +- 如果 `~/.cqview/` 和 `~/.cqview/scene/` 不存在会创建。它从不写入你的项目文件夹。 -在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除。 +在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除;以及 `open-requests.json`,最近 24 小时内最多 20 条 AI 请求(文件夹 ID、相对该文件夹的路径,或场景模型名)。 **子进程**:只有打开 STEP 时才启动 Python。参数以数组传递(不经过 shell),忽略标准输入输出,Viewer 关闭时结束所有子进程: @@ -99,9 +123,13 @@ clear() # 清空推送的模型 模型路径会先校验(在登记的文件夹内、扩展名允许、不是符号链接),再作为单个参数传入。 +**MCP 端点**:Mini App 回环端口上的 `POST /mcp/cq-viewer`。`Host` 或 `Origin` 不是本机的请求一律拒绝,只接受 JSON 请求体。工具遵守和页面相同的文件夹规则。 + +**内置 skill**:Mini App 本身从不运行它。Agent 按 skill 操作时,会通过自己的终端工具(照常经过权限确认)运行其中的 Python 脚本:`run_cadquery_model.py` 在子 Python 进程里执行模型脚本并收集它写出的文件,`preview.py` 离屏渲染 PNG。脚本不发起网络请求;skill 会让 Agent 用网页搜索查真实尺寸,缺少依赖时它的安装说明会用 `pip`(或 `conda`)安装上面列出的 Python 包。 + **监听**:Viewer 运行期间监听 `~/.cqview/scene/` 和每个登记的文件夹(递归)里的模型变化。 -**浏览器存储**:只保存视图偏好,包括选中的文件夹、打开的文件、隐藏的零件、边线和网格开关。 +**浏览器存储**:只保存视图偏好,包括选中的文件夹、打开的文件、隐藏的零件、边线和网格开关,以及这个浏览器执行到的最后一条 AI 请求的序号。 **宿主页面桥接**:只在点 **发送** 时使用,见上文。 @@ -109,15 +137,19 @@ clear() # 清空推送的模型 ## 测试环境 -- MiniMax Code 桌面版 `3.1.1-preview.4597`(Electron 42),macOS 26(Apple Silicon);Python 3.9 + CadQuery 2.5.2 / OCP 7.7.2。 -- 在 MiniMax Code 中验证:安装、打开、通过 `?root=` 登记文件夹、宿主桥接探测(这个版本不响应 `ui/initialize` 握手,因此走降级路径)。 -- 在无头 Chrome 中对同一个 Node 入口做了端到端测试:打开 STL 和 STEP(多层装配,含名称、颜色、复用实例)、自动重新加载、选点 → 确认 → 备注、模拟 MCP Apps 宿主下的 `ui/message` 路径、模拟页面桥接下的降级路径,以及没有宿主时按钮切换为复制。 -- 未验证:Windows;这个版本里降级路径是否真的填进了对话输入框;其他 CAD 软件导出的带面级颜色的 STEP。 +- MiniMax Code 桌面版 `3.1.1-preview.4597`(Electron 42),macOS 26(Apple Silicon);Python 3.9 + CadQuery 2.5.2 / OCP 7.7.2、trimesh 4.12、pyrender 0.1.45。 +- 在 MiniMax Code 中验证(1.1.0):本机安装;新开的对话里能看到 `mcp__cq-viewer__add_model_folder`、`mcp__cq-viewer__add_model_file`、`mcp__cq-viewer__list_model_folders` 和 skill `cq-viewer:parametric-3d-printing`;经宿主调用了 `list_model_folders` 和 `add_model_folder`(`select: false`)。1.0.0 时验证过:打开、通过 `?root=` 登记文件夹、宿主桥接探测(这个版本不响应 `ui/initialize` 握手,因此走降级路径)。 +- `tests/mcp.test.mjs`(`node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`):三个工具、文件夹规则、带颜色复制到场景、重启后的请求记录、回环与 JSON 检查,共 10 个测试,用临时用户目录跑真实的 Node 入口。 +- 在无头 Chrome 中对同一个 Node 入口做了端到端测试:AI 调用工具后页面切换文件夹并打开文件;`copy_to_scene` 复制的零件按指定颜色显示;页面关着时请求的文件在下次打开时出现,之后不会重复执行,用户取消勾选后保持关闭;页面离开后 `viewer_connected` 变为 false。1.0.0 时测过:打开 STL 和 STEP(多层装配,含名称、颜色、复用实例)、自动重新加载、选点 → 确认 → 备注、模拟 MCP Apps 宿主下的 `ui/message` 路径、模拟页面桥接下的降级路径,以及没有宿主时按钮切换为复制。 +- 内置 skill:在已安装的插件目录里运行 `run_cadquery_model.py --preview --strict`(建模 → STL → watertight 校验 → 预览图)。 +- 未验证:Windows;查看器面板开着时经宿主调用 `add_model_file`(页面这一侧只在无头 Chrome 里验证过);这个版本里发送的降级路径是否真的填进了对话输入框;其他 CAD 软件导出的带面级颜色的 STEP;skill 的 Gridfinity 生成器在 CadQuery 2.5 下的表现(skill 要求 2.6.1 及以上)。 ## 第三方 -[three.js](https://threejs.org/) r170(`three.module.min.js`、`OrbitControls.js`、`STLLoader.js`),MIT 协议,见 `miniapp/client/assets/vendor/LICENSE-three.txt`。STEP 转换使用你本机安装的 CadQuery / OCP,不随包分发。 +- [three.js](https://threejs.org/) r170(`three.module.min.js`、`OrbitControls.js`、`STLLoader.js`),MIT 协议,见 `miniapp/client/assets/vendor/LICENSE-three.txt`。 +- `skills/parametric-3d-printing/` 来自 [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill),PolyForm Noncommercial 1.0.0 协议,见 `skills/parametric-3d-printing/LICENSE` 和 `UPSTREAM.md`。 +- STEP 转换使用你本机安装的 CadQuery / OCP,不随包分发。 ## 许可证 -[MIT](LICENSE) +[MIT](LICENSE);`skills/parametric-3d-printing/` 除外,它保留自己的 [PolyForm Noncommercial 1.0.0](skills/parametric-3d-printing/LICENSE) 协议。 diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js b/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js index dfd7f8b..625f06e 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js @@ -32,9 +32,12 @@ function loadPrefs() { root: typeof raw.root === 'string' ? raw.root : null, edges: raw.edges !== false, grid: raw.grid !== false, + // last AI request (MCP) this browser has carried out + reqEpoch: typeof raw.reqEpoch === 'string' ? raw.reqEpoch : null, + reqSeq: Number.isInteger(raw.reqSeq) ? raw.reqSeq : 0, }; } catch { - return { hidden: new Set(), opened: [], root: null, edges: true, grid: true }; + return { hidden: new Set(), opened: [], root: null, edges: true, grid: true, reqEpoch: null, reqSeq: 0 }; } } function savePrefs() { @@ -47,6 +50,8 @@ function savePrefs() { root: prefs.root, edges: prefs.edges, grid: prefs.grid, + reqEpoch: prefs.reqEpoch, + reqSeq: prefs.reqSeq, }), ); } catch { @@ -825,7 +830,7 @@ function selectRoot(id) { savePrefs(); renderRootSelect(); renderFileList(); - void syncFiles(id); + return syncFiles(id); } function renderRootSelect() { @@ -1849,6 +1854,95 @@ function applyHostContext(ctx) { refreshThemeColors(); } +// ---------------------------------------------------------------- requests from the AI (MCP tools) +// The Node side keeps the last requests in dataDir, so they still apply when the page opens later. +let requestChain = Promise.resolve(); +function applyRequests() { + requestChain = requestChain.then(applyRequestsNow, applyRequestsNow).catch(() => {}); + return requestChain; +} +async function applyRequestsNow() { + const res = await fetch(apiUrl('requests'), { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (!data || !Array.isArray(data.items) || typeof data.epoch !== 'string') return; + // a new epoch means the request log was recreated: everything in it is new to us + const after = data.epoch === prefs.reqEpoch ? prefs.reqSeq : 0; + const todo = data.items + .filter((it) => it && Number.isInteger(it.seq) && it.seq > after) + .sort((a, b) => a.seq - b.seq); + for (const item of todo) { + try { + await applyRequest(item); + } catch (error) { + showToast(`AI 请求的内容没有打开:${error instanceof Error ? error.message : String(error)}`, 'error', 5000); + } + prefs.reqEpoch = data.epoch; + prefs.reqSeq = item.seq; + savePrefs(); + } + if (prefs.reqEpoch !== data.epoch) { + prefs.reqEpoch = data.epoch; + prefs.reqSeq = Number.isInteger(data.seq) ? data.seq : 0; + savePrefs(); + } +} + +async function applyRequest(item) { + if (item.type === 'scene' && typeof item.name === 'string') { + const key = sceneKey(item.name); + if (prefs.hidden.delete(key)) savePrefs(); + await refreshScene(); + const entry = await settled(key); + if (entry) applyMaterial(entry); + revealAll(); + showToast(`AI 推送了 ${item.name}`, 'info', 3000); + return; + } + if ((item.type !== 'folder' && item.type !== 'file') || !ROOT_ID_RE.test(item.root)) return; + if (!roots.some((r) => r.id === item.root)) await loadRoots(); + const root = roots.find((r) => r.id === item.root); + if (!root) throw new Error('文件夹已不在列表里'); + await selectRoot(item.root); + if (item.type === 'folder') { + openPanel('files'); + showToast(`AI 添加了文件夹「${root.label}」`, 'info', 3000); + return; + } + const state = fileLists.get(item.root); + if (state?.error) throw new Error(state.error); + const file = state?.files.find((f) => f.rel === item.rel); + if (!file) throw new Error(`在「${root.label}」里找不到 ${item.rel}`); + const key = fileKey(item.root, item.rel); + if (prefs.opened.includes(key)) { + // already open: make sure it is shown + if (prefs.hidden.delete(key)) savePrefs(); + const entry = parts.get(key); + if (entry) applyMaterial(entry); + renderParts(); + } else { + await openFile(item.root, file); + } + revealAll(); + showToast(`AI 打开了 ${baseName(item.rel)}`, 'info', 3000); +} + +/** Wait briefly for a part to finish its first load (STEP conversion may take longer; it still shows up). */ +async function settled(key, ms = 5000) { + for (let t = 0; t < ms; t += 100) { + const entry = parts.get(key); + if (entry && entry.status !== 'loading') return entry; + await new Promise((r) => setTimeout(r, 100)); + } + return parts.get(key) ?? null; +} + +function revealAll() { + afterSceneChange(false); + const box = visibleBox(); + if (!box.isEmpty()) fitTo(box); +} + // ---------------------------------------------------------------- live connection let events = null; let offlineTimer = null; @@ -1889,9 +1983,11 @@ function connect() { if (msg.type === 'hello') { void refreshScene(); for (const id of rootsInUse()) void syncFiles(id); + void applyRequests(); // the server may have restarted while we were away } else if (msg.type === 'scene') void refreshScene(); else if (msg.type === 'files' && typeof msg.root === 'string' && rootsInUse().has(msg.root)) void syncFiles(msg.root); else if (msg.type === 'roots') void loadRoots().catch(() => {}); + else if (msg.type === 'request') void applyRequests(); else if (msg.type === 'selection' && msg.client !== clientId) void loadSelection(); // ignore our own echo else if (msg.type === 'watch-lost') { events?.close(); @@ -1899,6 +1995,15 @@ function connect() { } }; } +// Close the live connection when the page goes away, so the Node side knows no viewer is +// listening (the AI is told to ask the user to open it). Reconnect if the page comes back. +window.addEventListener('pagehide', () => { + events?.close(); + events = null; +}); +window.addEventListener('pageshow', (ev) => { + if (ev.persisted && !events) connect(); +}); // ---------------------------------------------------------------- boot const hostBridge = createHostBridge(); @@ -1957,5 +2062,6 @@ async function boot() { afterSceneChange(false); await loadSelection(); connect(); + await applyRequests(); // models the AI asked for while the viewer was closed } void boot(); diff --git a/plugins/hanzijie/cq-viewer/miniapp/miniapp.json b/plugins/hanzijie/cq-viewer/miniapp/miniapp.json index 597bbeb..4559b8f 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/miniapp.json +++ b/plugins/hanzijie/cq-viewer/miniapp/miniapp.json @@ -16,5 +16,10 @@ "surface": { "path": "/viewer" }, - "mcpEndpoints": [] + "mcpEndpoints": [ + { + "server": "cq-viewer", + "path": "/mcp/cq-viewer" + } + ] } diff --git a/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs b/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs new file mode 100644 index 0000000..7aabb02 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs @@ -0,0 +1,250 @@ +// @ts-check +// CadQuery Viewer — MCP endpoint (Streamable HTTP, JSON responses only, no server-initiated stream). +// +// The Host's MCP client POSTs JSON-RPC to MCP_PATH on this Mini App's own listener. The tools only +// call the services the Node entry passes in; they never touch the file system directly. + +export const MCP_PATH = '/mcp/cq-viewer'; + +const SERVER_INFO = { name: 'cq-viewer', version: '1.1.0' }; + +const INSTRUCTIONS = [ + 'CadQuery Viewer 是本机的 STL / STEP 查看器,页面在 MiniMax Code 的 Mini App 面板里打开。', + '导出模型后用 add_model_file 在查看器里打开它:默认原地打开,脚本重新导出同一个文件时查看器会自动刷新,所以每个文件调用一次即可。', + '用 add_model_folder 登记项目文件夹,用 list_model_folders 查看已登记的文件夹和模型。', + '查看器没打开时请求会保留 24 小时,用户打开查看器后自动执行;结果里 viewer_connected 为 false 时,提醒用户打开 CadQuery Viewer。', + '用户在查看器里标注的位置会作为消息发到对话里,同样的数据也在 ~/.cqview/selection.json。', +].join('\n'); + +const NAME_PATTERN = '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$'; +const COLOR_PATTERN = '^#[0-9a-fA-F]{6}$'; + +const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }; +const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; + +/** @typedef {{type?: string|string[], properties?: Record, required?: string[], additionalProperties?: boolean, minLength?: number, maxLength?: number, pattern?: string, minimum?: number, maximum?: number, description?: string}} Schema */ + +/** @type {{name: string, title: string, description: string, inputSchema: Schema, annotations: Record}[]} */ +export const TOOLS = [ + { + name: 'add_model_folder', + title: '添加模型文件夹', + description: + '把本机的一个文件夹登记到 CadQuery Viewer。查看器会递归列出其中的 .stl / .step / .stp,并在文件变化时刷新。' + + '文件夹必须在用户主目录内,不能是主目录本身、~/Library 或以 . 开头的顶层目录。' + + '返回文件夹 ID 和其中的模型文件(绝对路径,最多 200 个)。select 为 true(默认)时,查看器会切换到这个文件夹。', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', minLength: 1, maxLength: 1024, description: '文件夹的绝对路径,可以用 ~/ 开头' }, + select: { type: 'boolean', description: '是否让查看器切换到这个文件夹,默认 true' }, + }, + required: ['path'], + additionalProperties: false, + }, + annotations: WRITE, + }, + { + name: 'add_model_file', + title: '添加模型文件', + description: + '在 CadQuery Viewer 里打开一个 .stl / .step / .stp 文件。' + + '默认原地打开:自动登记文件所在的文件夹(已登记的上级文件夹优先),文件之后被重新导出时查看器自动刷新;这种方式要求文件在用户主目录内。' + + 'copy_to_scene 为 true 时,改为把文件复制到查看器的脚本推送目录 ~/.cqview/scene/.<扩展名>,适合主目录以外的文件(例如 /tmp),但源文件之后的修改不会同步;name、color、opacity 只在这种方式下生效。' + + '查看器没打开时请求会保留 24 小时,打开后自动执行。', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', minLength: 1, maxLength: 1024, description: '模型文件的绝对路径,可以用 ~/ 开头' }, + copy_to_scene: { type: 'boolean', description: '复制到脚本推送目录而不是原地打开,默认 false' }, + name: { type: 'string', pattern: NAME_PATTERN, description: '复制后的模型名(字母、数字、. _ -),默认取文件名' }, + color: { type: 'string', pattern: COLOR_PATTERN, description: '显示颜色,例如 #4f8cff' }, + opacity: { type: 'number', minimum: 0.05, maximum: 1, description: '不透明度 0.05–1' }, + }, + required: ['path'], + additionalProperties: false, + }, + annotations: WRITE, + }, + { + name: 'list_model_folders', + title: '列出模型文件夹', + description: + '列出 CadQuery Viewer 已登记的模型文件夹,以及脚本推送目录 ~/.cqview/scene 里的模型。' + + '传 folder(文件夹 ID 或绝对路径)时,改为返回该文件夹里的模型文件(绝对路径,最多 200 个)。', + inputSchema: { + type: 'object', + properties: { + folder: { type: 'string', minLength: 1, maxLength: 1024, description: '文件夹 ID 或绝对路径' }, + }, + additionalProperties: false, + }, + annotations: READ, + }, +]; + +const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])); + +/** + * @typedef {object} McpServices + * @property {(args: {path: string, select?: boolean}) => Promise>} addModelFolder + * @property {(args: {path: string, copy_to_scene?: boolean, name?: string, color?: string, opacity?: number}) => Promise>} addModelFile + * @property {(args: {folder?: string}) => Promise>} listModelFolders + * @property {(message: string) => void} [log] + */ + +/** + * @param {McpServices} services + * @returns {(body: unknown) => Promise} null means "no response body" (HTTP 202) + */ +export function createMcpHandler(services) { + /** @param {any} message */ + const handleMessage = async (message) => { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + return rpcError(null, -32600, 'Invalid Request'); + } + if (message.id === undefined || message.id === null) return null; // notification or response + const id = message.id; + switch (message.method) { + case 'initialize': + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: + typeof message.params?.protocolVersion === 'string' ? message.params.protocolVersion : '2025-06-18', + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: INSTRUCTIONS, + }, + }; + case 'ping': + return { jsonrpc: '2.0', id, result: {} }; + case 'tools/list': + return { jsonrpc: '2.0', id, result: { tools: TOOLS } }; + case 'tools/call': { + const name = message.params?.name; + const tool = typeof name === 'string' ? TOOLS_BY_NAME.get(name) : undefined; + if (!tool) return rpcError(id, -32602, `Unknown tool: ${typeof name === 'string' ? name : '(missing)'}`); + const args = message.params?.arguments ?? {}; + const issues = validate(tool.inputSchema, args, 'arguments'); + if (issues.length) { + return { + jsonrpc: '2.0', + id, + result: failure('invalid_arguments', `参数不正确:${issues.join(';')}`), + }; + } + services.log?.(`cqviewer.mcp.call ${tool.name}`); + try { + const data = await dispatch(services, tool.name, args); + return { jsonrpc: '2.0', id, result: success(data) }; + } catch (error) { + const e = /** @type {any} */ (error); + const known = typeof e?.status === 'number' && typeof e?.code === 'string'; + if (!known) services.log?.(`cqviewer.mcp.failed ${tool.name} ${e?.code ?? 'unknown'}`); + return { + jsonrpc: '2.0', + id, + result: known ? failure(e.code, e.userMessage || e.code) : failure('internal_error', '查看器内部出错,请重试'), + }; + } + } + default: + return rpcError(id, -32601, `Method not found: ${String(message.method)}`); + } + }; + + return async (body) => { + if (Array.isArray(body)) { + if (body.length === 0) return rpcError(null, -32600, 'Invalid Request'); + const replies = []; + for (const m of body) { + const r = await handleMessage(m); + if (r) replies.push(r); + } + return replies.length ? replies : null; + } + return handleMessage(body); + }; +} + +/** @param {McpServices} services @param {string} name @param {any} args */ +function dispatch(services, name, args) { + switch (name) { + case 'add_model_folder': + return services.addModelFolder(args); + case 'add_model_file': + return services.addModelFile(args); + case 'list_model_folders': + return services.listModelFolders(args); + default: + throw new Error('unreachable'); + } +} + +/** @param {Record} data */ +function success(data) { + const structuredContent = { ok: true, ...data }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], structuredContent }; +} + +/** @param {string} code @param {string} message */ +function failure(code, message) { + const structuredContent = { ok: false, error: { code, message } }; + return { isError: true, content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], structuredContent }; +} + +/** @param {unknown} id @param {number} code @param {string} message */ +function rpcError(id, code, message) { + return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; +} + +/** + * Minimal JSON Schema check for the subset used by TOOLS. + * @param {Schema} schema @param {unknown} value @param {string} path @returns {string[]} + */ +export function validate(schema, value, path) { + /** @type {string[]} */ + const issues = []; + const types = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; + if (types.length && !types.some((t) => isType(t, value))) { + issues.push(`${path} 应为 ${types.join(' 或 ')}`); + return issues; + } + if (typeof value === 'string') { + if (schema.minLength !== undefined && value.length < schema.minLength) issues.push(`${path} 不能为空`); + if (schema.maxLength !== undefined && value.length > schema.maxLength) issues.push(`${path} 太长`); + if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) issues.push(`${path} 格式不对`); + } + if (typeof value === 'number') { + if (schema.minimum !== undefined && value < schema.minimum) issues.push(`${path} 不能小于 ${schema.minimum}`); + if (schema.maximum !== undefined && value > schema.maximum) issues.push(`${path} 不能大于 ${schema.maximum}`); + } + if (isType('object', value)) { + const obj = /** @type {Record} */ (value); + const props = schema.properties ?? {}; + for (const req of schema.required ?? []) if (!Object.hasOwn(obj, req)) issues.push(`缺少 ${path}.${req}`); + for (const [k, v] of Object.entries(obj)) { + if (props[k]) issues.push(...validate(props[k], v, `${path}.${k}`)); + else if (schema.additionalProperties === false) issues.push(`${path}.${k} 不是可用参数`); + } + } + return issues; +} + +/** @param {string} type @param {unknown} value */ +function isType(type, value) { + switch (type) { + case 'object': + return value !== null && typeof value === 'object' && !Array.isArray(value); + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return Number.isInteger(value); + case 'array': + return Array.isArray(value); + default: + return typeof value === type; + } +} diff --git a/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs b/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs index 01544e5..2304cdf 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs +++ b/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs @@ -6,17 +6,34 @@ // ~/.cqview/scene/scene.json optional per-part colour/opacity (read) // ~/.cqview/roots.json registered folders the page may browse (read/write) // ~/.cqview/selection.json last picked points, resolved to absolute files (write) +// /open-requests.json what the AI asked the page to open (MCP tools) (read/write) // // Folder browsing is scoped: only folders registered in roots.json, only inside the // user's home directory, only .stl/.step/.stp files, no symlinks, no hidden folders. +// The MCP endpoint (mcp.mjs) only answers loopback requests and reuses the same rules; +// its one extra power is copy_to_scene, which copies a model file into ~/.cqview/scene. import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { createReadStream, watch } from 'node:fs'; -import { access, lstat, mkdir, readdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { + access, + copyFile, + lstat, + mkdir, + readdir, + readFile, + realpath, + rename, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; import { createServer } from 'node:http'; import { homedir } from 'node:os'; -import { basename, extname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'; + +import { createMcpHandler, MCP_PATH } from './mcp.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ /** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ @@ -50,6 +67,12 @@ const PY_PROBE_TIMEOUT_MS = 60_000; const STEP_TIMEOUT_MS = 300_000; const STEP_MESH_VERSION = 'cqvm-1'; const CQVM_TYPE = 'application/x-cqview-mesh'; +const REQUEST_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_REQUESTS = 20; +const REQUEST_TYPES = new Set(['folder', 'file', 'scene']); +const MCP_LIST_LIMIT = 200; +const MAX_COPY_BYTES = 512 * 1024 * 1024; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); const STEP_TO_STL_PY = [ 'import sys', @@ -93,8 +116,21 @@ export async function start(context) { const clientRoot = join(context.pluginRoot, 'miniapp', 'client'); const indexHtml = await readFile(join(clientRoot, 'index.html')); const stepCacheDir = join(context.dataDir, 'step-cache'); + const requestsFile = join(context.dataDir, 'open-requests.json'); const log = context.logger; + /** Read-modify-write of our JSON files runs one at a time (page and AI can write together). */ + let writeChain = Promise.resolve(); + /** @template T @param {() => Promise} fn @returns {Promise} */ + const serial = (fn) => { + const run = writeChain.then(fn, fn); + writeChain = run.then( + () => {}, + () => {}, + ); + return run; + }; + /** @type {Set} */ const children = new Set(); /** @type {Map>} */ @@ -237,17 +273,242 @@ export async function start(context) { /** @param {unknown} input */ const addRoot = async (input) => { const real = await validateRootPath(input); - const roots = await readRoots(); - const next = [{ path: real, used: new Date().toISOString() }, ...roots.filter((r) => r.path !== real)].slice( - 0, - MAX_ROOTS, - ); - await writeJsonAtomic(ROOTS_FILE, { roots: next }); // commit first … + await serial(async () => { + const roots = await readRoots(); + const next = [{ path: real, used: new Date().toISOString() }, ...roots.filter((r) => r.path !== real)].slice( + 0, + MAX_ROOTS, + ); + await writeJsonAtomic(ROOTS_FILE, { roots: next }); // commit first … + }); await syncRootWatchers(); broadcast({ type: 'roots' }); // … then notify return describeRoot(real); }; + // ---------- domain: requests from the AI (MCP) for the page ---------- + /** @returns {Promise<{epoch: string|null, seq: number, items: Record[]}>} */ + const readRequests = async () => { + try { + const parsed = JSON.parse(await readFile(requestsFile, 'utf8')); + if (parsed && typeof parsed.epoch === 'string' && Number.isInteger(parsed.seq) && Array.isArray(parsed.items)) { + const now = Date.now(); + const items = parsed.items.filter( + (/** @type {any} */ it) => + it && + Number.isInteger(it.seq) && + typeof it.at === 'string' && + now - Date.parse(it.at) < REQUEST_TTL_MS && + REQUEST_TYPES.has(it.type) && + (it.type === 'scene' + ? typeof it.name === 'string' && PART_NAME_RE.test(it.name) + : typeof it.root === 'string' && ROOT_ID_RE.test(it.root) && (it.type === 'folder' || typeof it.rel === 'string')), + ); + return { epoch: parsed.epoch, seq: parsed.seq, items }; + } + } catch { + /* none yet */ + } + return { epoch: null, seq: 0, items: [] }; + }; + + /** Persist a request (the page may not be open, or the process may restart), then notify. */ + const pushRequest = async (/** @type {Record} */ req) => { + const seq = await serial(async () => { + const doc = await readRequests(); + const next = doc.seq + 1; + const items = [...doc.items, { seq: next, at: new Date().toISOString(), ...req }].slice(-MAX_REQUESTS); + await writeJsonAtomic(requestsFile, { epoch: doc.epoch ?? randomBytes(6).toString('hex'), seq: next, items }); + return next; + }); + broadcast({ type: 'request', seq }); + return seq; + }; + + const viewerConnected = () => clients.size > 0; + + /** @param {string} p */ + const folderInfo = (p) => ({ id: rootId(p), path: p, label: basename(p) }); + + /** @param {string} rootPath @param {{rel: string, size: number, kind: string}[]} files */ + const modelList = (rootPath, files) => + files.slice(0, MCP_LIST_LIMIT).map((f) => ({ path: join(rootPath, ...f.rel.split('/')), kind: f.kind, size: f.size })); + + /** @param {{path: string, select?: boolean}} args */ + const addModelFolder = async (args) => { + const real = await validateRootPath(args.path); + await addRoot(real); + const { files, truncated } = await listModels(real); + if (args.select !== false) await pushRequest({ type: 'folder', root: rootId(real) }); + return { + folder: folderInfo(real), + model_count: files.length, + truncated, + models: modelList(real, files), + viewer_connected: viewerConnected(), + }; + }; + + /** @param {{path: string, copy_to_scene?: boolean, name?: string, color?: string, opacity?: number}} args */ + const addModelFile = async (args) => { + const src = await validateModelPath(args.path); + if (args.copy_to_scene) return copyToScene(src, args); + if (args.name !== undefined || args.color !== undefined || args.opacity !== undefined) { + throw new HttpError(400, 'copy_only_option', 'name、color、opacity 只在 copy_to_scene 为 true 时可用'); + } + if (basename(src.real).startsWith('.')) throw new HttpError(400, 'hidden_file', '不能打开隐藏文件'); + if (!isAllowedRoot(dirname(src.real))) { + throw new HttpError( + 400, + 'outside_home', + '只能原地打开用户主目录内的文件(不能直接放在主目录下,也不能在 ~/Library 或隐藏的顶层目录里);其他位置的文件请加 copy_to_scene: true', + ); + } + // closest registered folder that really lists the file, else register its own folder + const candidates = (await readRoots()) + .filter((r) => src.real.startsWith(r.path + sep)) + .sort((a, b) => b.path.length - a.path.length); + /** @type {{rootPath: string, rel: string} | null} */ + let chosen = null; + for (const r of candidates) { + const rel = relative(r.path, src.real).split(sep).join('/'); + if (!isListableRel(rel)) continue; + const { files } = await listModels(r.path); + if (files.some((f) => f.rel === rel)) { + chosen = { rootPath: r.path, rel }; + break; + } + } + if (!chosen) { + const dir = dirname(src.real); + const rel = basename(src.real); + await validateRootPath(dir); + const { files } = await listModels(dir); + if (!files.some((f) => f.rel === rel)) { + throw new HttpError(422, 'not_listed', '这个文件所在的文件夹模型太多,查看器列不出它'); + } + chosen = { rootPath: dir, rel }; + } + await addRoot(chosen.rootPath); // registers it, or marks it recently used + await pushRequest({ type: 'file', root: rootId(chosen.rootPath), rel: chosen.rel }); + return { + mode: 'reference', + path: src.real, + kind: src.ext === '.stl' ? 'stl' : 'step', + size: src.stat.size, + folder: folderInfo(chosen.rootPath), + rel: chosen.rel, + viewer_connected: viewerConnected(), + note: '原地打开:之后重新导出这个文件,查看器会自动刷新,不用再调用。', + }; + }; + + /** + * Copy a model file into the pushed scene, like cqview.show_file(). + * @param {{real: string, stat: import('node:fs').Stats, ext: string}} src + * @param {{name?: string, color?: string, opacity?: number}} opts + */ + const copyToScene = async (src, opts) => { + const name = opts.name ?? sceneNameFrom(src.real); + if (!PART_NAME_RE.test(name)) throw new HttpError(400, 'invalid_name', '模型名只能用字母、数字、. _ -,且不能以 . 开头'); + if (src.stat.size === 0) throw new HttpError(422, 'empty_file', '这个文件是空的'); + if (src.stat.size > MAX_COPY_BYTES) throw new HttpError(413, 'file_too_large', '文件超过 512 MB,不能复制'); + const dst = join(SCENE_DIR, `${name}${src.ext}`); + await serial(async () => { + await mkdir(SCENE_DIR, { recursive: true }); + const tmp = join(SCENE_DIR, `.${name}.${process.pid}.${Date.now()}.tmp`); + try { + await copyFile(src.real, tmp); + await rename(tmp, dst); + } finally { + await unlink(tmp).catch(() => {}); + } + for (const ext of MODEL_EXT) { + if (ext !== src.ext) await unlink(join(SCENE_DIR, `${name}${ext}`)).catch(() => {}); + } + await setSceneMeta(name, opts); + }); + await pushRequest({ type: 'scene', name }); + return { + mode: 'copy', + name, + scene_file: dst, + source: src.real, + kind: src.ext === '.stl' ? 'stl' : 'step', + size: src.stat.size, + viewer_connected: viewerConnected(), + note: '这是一份副本:源文件之后的修改不会同步,需要时再调用一次。', + }; + }; + + /** Same rules as cqview.py: a push replaces the part's colour/opacity with what was given. */ + const setSceneMeta = async (/** @type {string} */ name, /** @type {{color?: string, opacity?: number}} */ opts) => { + const file = join(SCENE_DIR, 'scene.json'); + /** @type {{parts: Record}} */ + let meta = { parts: {} }; + try { + const parsed = JSON.parse(await readFile(file, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.parts && typeof parsed.parts === 'object') meta = parsed; + } catch { + /* missing or unreadable: start fresh */ + } + /** @type {Record} */ + const entry = {}; + if (opts.color !== undefined) entry.color = opts.color.toLowerCase(); + if (opts.opacity !== undefined) entry.opacity = clamp(opts.opacity, 0.05, 1); + const had = Object.hasOwn(meta.parts, name); + if (!had && !Object.keys(entry).length) return; + if (Object.keys(entry).length) meta.parts[name] = entry; + else delete meta.parts[name]; + await writeJsonAtomic(file, meta); + }; + + /** @param {{folder?: string}} args */ + const listModelFolders = async (args) => { + const roots = await readRoots(); + if (args.folder === undefined) { + const folders = []; + for (const r of roots) { + const st = await stat(r.path).catch(() => null); + folders.push({ ...folderInfo(r.path), exists: !!st && st.isDirectory(), last_used: r.used ?? null }); + } + const scene = await readScene(); + return { + folders, + scene: { + dir: SCENE_DIR, + models: scene.parts.map((p) => ({ name: p.name, path: join(SCENE_DIR, p.file), kind: p.kind, size: p.size })), + }, + viewer_connected: viewerConnected(), + }; + } + let root = ROOT_ID_RE.test(args.folder) ? roots.find((r) => rootId(r.path) === args.folder) : undefined; + if (!root) { + let p = args.folder.trim(); + if (p === '~' || p.startsWith('~/')) p = join(HOME, p.slice(1)); + const real = isAbsolute(p) ? await realpath(p).catch(() => null) : null; + root = real ? roots.find((r) => r.path === real) : undefined; + } + if (!root) throw new HttpError(404, 'root_not_registered', '这个文件夹还没有登记,先调用 add_model_folder'); + const st = await stat(root.path).catch(() => null); + if (!st || !st.isDirectory()) throw new HttpError(404, 'root_missing', '文件夹已不存在'); + const { files, truncated } = await listModels(root.path); + return { + folder: folderInfo(root.path), + model_count: files.length, + truncated, + models: modelList(root.path, files), + viewer_connected: viewerConnected(), + }; + }; + + const handleMcp = createMcpHandler({ + addModelFolder, + addModelFile, + listModelFolders, + log: (message) => log.info(message), + }); + /** @param {string} file @param {import('node:fs').Stats} st @returns {Promise<{path: string, type: 'cqvm'|'stl'}>} */ const stepToMesh = async (file, st) => { const key = createHash('sha1').update(`${STEP_MESH_VERSION}|${file}|${st.size}|${st.mtimeMs}`).digest('hex'); @@ -446,6 +707,25 @@ export async function start(context) { const path = url.pathname; const method = request.method ?? 'GET'; + // MCP: the Host's MCP client calls this over loopback. Refuse anything a browser page on another + // origin (or a DNS-rebound hostname) could send. + if (path === MCP_PATH) { + if (!isLoopbackRequest(request)) return sendJson(response, 403, { error: 'forbidden' }); + if (method !== 'POST') { + response.writeHead(405, { allow: 'POST', 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'method_not_allowed' })); + return; + } + const body = await readJsonBody(request); + const reply = await handleMcp(body); + if (reply === null) { + response.writeHead(202); + response.end(); + return; + } + return sendJson(response, 200, reply); + } + if (method === 'GET' && path === BASE) { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }); response.end(indexHtml); @@ -512,6 +792,9 @@ export async function start(context) { return sendJson(response, 200, await writeSelection(body)); } + // what the AI asked the page to open + if (method === 'GET' && path === `${BASE}/api/requests`) return sendJson(response, 200, await readRequests()); + // live updates if (method === 'GET' && path === `${BASE}/api/events`) { @@ -703,6 +986,71 @@ async function resolveModel(rootPath, rel) { return { full, stat: st, kind: /** @type {'stl'|'step'} */ (ext === '.stl' ? 'stl' : 'step') }; } +/** + * A model file named by the AI: absolute (or ~/), an allowed extension, a regular file. + * Symbolic links are resolved; every later rule applies to the real path. + * @param {unknown} input + */ +async function validateModelPath(input) { + if (typeof input !== 'string' || !input.trim() || input.length > 1024 || input.includes('\0')) { + throw new HttpError(400, 'invalid_path', '请提供模型文件的绝对路径'); + } + let p = input.trim(); + if (p === '~' || p.startsWith('~/')) p = join(HOME, p.slice(1)); + if (!isAbsolute(p)) throw new HttpError(400, 'invalid_path', '请提供绝对路径(可用 ~/ 开头)'); + if (!MODEL_EXT.has(extname(p).toLowerCase())) throw new HttpError(400, 'unsupported_type', '只支持 .stl、.step、.stp 文件'); + let real; + try { + real = await realpath(p); + } catch { + throw new HttpError(404, 'file_not_found', '文件不存在'); + } + const ext = extname(real).toLowerCase(); + if (!MODEL_EXT.has(ext)) throw new HttpError(400, 'unsupported_type', '只支持 .stl、.step、.stp 文件'); + const st = await stat(real); + if (!st.isFile()) throw new HttpError(400, 'not_a_file', '这不是文件'); + return { real, stat: st, ext }; +} + +/** Would listModels() list this path, relative to its folder? */ +function isListableRel(/** @type {string} */ rel) { + const segs = rel.split('/'); + if (segs.length - 1 > WALK_MAX_DEPTH) return false; + return segs.every((s, i) => !!s && !s.startsWith('.') && (i === segs.length - 1 || !SKIP_DIRS.has(s))); +} + +/** Scene name from a file name, like cqview.show_file(). */ +function sceneNameFrom(/** @type {string} */ file) { + const stem = basename(file, extname(file)) + .replace(/[^A-Za-z0-9._-]/g, '_') + .replace(/^[._-]+|[._-]+$/g, '') + .slice(0, 100); + return stem || 'model'; +} + +/** + * Host must be loopback (blocks DNS rebinding); Origin, when a browser sends one, too. + * The Host's MCP client is a Node process and sends no Origin. + * @param {import('node:http').IncomingMessage} request + */ +function isLoopbackRequest(request) { + const host = request.headers.host; + if (host !== undefined && !LOOPBACK_HOSTS.has(hostnameOf(host))) return false; + const origin = request.headers.origin; + if (origin === undefined) return true; + const m = /^[a-z][a-z0-9+.-]*:\/\/(\[[^\]]*\]|[^/:?#]+)/i.exec(origin); + return !!m && LOOPBACK_HOSTS.has(hostnameOf(m[1])); +} + +/** @param {string} hostHeader */ +function hostnameOf(hostHeader) { + const h = hostHeader.trim().toLowerCase(); + const bracketed = /^\[([^\]]*)\]/.exec(h); + if (bracketed) return bracketed[1]; + const colon = h.lastIndexOf(':'); + return colon === -1 ? h : h.slice(0, colon); +} + // ---------------------------------------------------------------- helpers /** @param {unknown} raw */ @@ -784,7 +1132,7 @@ function clamp(v, lo, hi) { /** @param {string} file @param {unknown} data */ async function writeJsonAtomic(file, data) { - await mkdir(EXCHANGE_DIR, { recursive: true }); + await mkdir(dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); await rename(tmp, file); diff --git a/plugins/hanzijie/cq-viewer/servers.mcp.json b/plugins/hanzijie/cq-viewer/servers.mcp.json new file mode 100644 index 0000000..fd96ee6 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/servers.mcp.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "mcpServers": { + "cq-viewer": { + "type": "streamable-http", + "url": "http://127.0.0.1:9/mcp/cq-viewer", + "headers": {}, + "description": "CadQuery Viewer:登记模型文件夹、在查看器里打开 STL / STEP 模型、列出已登记的文件夹和模型。", + "timeout": 120000 + } + } +} diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/LICENSE b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/LICENSE new file mode 100644 index 0000000..c220730 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/LICENSE @@ -0,0 +1,131 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Nicolas Chourrout (https://github.com/nchourrout) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/SKILL.md b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/SKILL.md new file mode 100644 index 0000000..31338ce --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/SKILL.md @@ -0,0 +1,426 @@ +--- +name: parametric-3d-printing +description: "Use this skill when the user wants to design a 3D-printable physical object they intend to manufacture. Triggers: any mention of '3D print', 'STL', 'parametric model', 'Gridfinity', 'enclosure', 'bracket', 'mount', 'case', 'housing', 'CadQuery', 'OpenSCAD', or a specific FDM printer (Bambu Lab, Prusa, Ender); questions about print-friendly design, snap-fits, tolerances, or wall thickness; and requests for functional parts like Arduino enclosures, cable organizers, wall mounts, adapters, or mechanical components. Also fires when the user describes a real physical object to make, provided the goal is to manufacture it. Do NOT use for: 3D rendering, animation, game assets, digital-only art, photogrammetry, sculpting, editing an existing STL file the user already has, or any 3D work that is not heading toward a printer." +--- + +# Parametric 3D Printing with CadQuery + +## Overview + +This skill generates parametric 3D models using **CadQuery** (Python) and exports them as STL files ready for slicing. CadQuery is preferred because it installs via pip, has a Pythonic API, and handles complex geometry (fillets, chamfers, booleans, assemblies) better than alternatives. + +## Setup + +```bash +# CadQuery requires Python 3.10-3.12 (OCC kernel lacks 3.13+ wheels) +python3.12 -m venv .venv && source .venv/bin/activate + +# Install CadQuery and preview dependencies +pip install cadquery trimesh pyrender Pillow numpy lxml +``` + +CadQuery uses the OpenCASCADE kernel under the hood. trimesh, pyrender, Pillow, and numpy are used for the preview-analyze-iterate loop; lxml is required by the 3MF writer (`stl_to_3mf.py`). No display server is needed; everything renders headlessly via pyrender's offscreen backend. + +**If CadQuery fails to install** (OCC kernel build errors), try: +```bash +# Option 1: Use conda (CadQuery's officially recommended method) +conda install -c cadquery -c conda-forge cadquery + +# Option 2: Use the pre-built wheels +pip install cadquery --find-links https://github.com/CadQuery/CadQuery/releases +``` + +## Real-World Dimension Research + +When designing objects that interface with real products (phones, chargers, PCBs, connectors, etc.), **use web search to find accurate dimensions** before writing any geometry code. Don't guess or use approximate values. Even 1-2mm off can make a part unusable. + +**What to research:** +- Connector/port dimensions (USB-C: 8.4 x 2.6mm opening, Lightning, barrel jacks) +- Device dimensions (phone width/thickness, PCB footprints, charger puck diameters) +- Mounting hole patterns and screw sizes (M2.5, M3, etc.) +- Standard component specs (MagSafe puck: 56mm diameter, 5.6mm thick) +- Cable bend radii and strain relief requirements + +**How to use it:** +1. Search for "[product] dimensions mm" or "[component] datasheet" +2. Cross-reference at least 2 sources when precision matters +3. Add the sourced dimensions as comments in the PARAMETERS section: + ```python + # MagSafe puck dimensions (source: Apple spec + iFixit teardown) + puck_diameter = 56.0 # mm + puck_thickness = 5.6 # mm + ``` +4. When in doubt, add 0.3-0.5mm clearance to external dimensions + +This is especially important for: phone cases/stands, charger mounts, PCB enclosures, cable management, adapter fittings, and anything that clips onto or wraps around an existing product. + +## Core Workflow + +1. **Gather requirements** (see Requirements Gathering below) +2. **Research dimensions** of any real-world products involved (see above) +3. **Phase 1, Base shape**: Build outer shell, preview, get user feedback +4. **Phase 2, Features**: Add functional details, preview, get user feedback +5. **Phase 3, Final delivery**: Fillets, cleanup, final preview + STL + print recommendations +6. **Offer parameter tweaks** after delivery + +This is a **collaborative, show-as-you-go** process. Do NOT disappear and come back with a finished model. Show the user your progress at each phase and incorporate their feedback before moving on. + +## Requirements Gathering + +Before writing any code, walk through these topics with the user **conversationally**. Don't dump all questions at once. Ask the most important ones first, then follow up based on answers. Use reasonable defaults when the user doesn't specify. + +**What is it?** +Object type, purpose, what it holds/protects/attaches to. Get a clear mental model of the object before anything else. + +**Critical dimensions** +Must-fit measurements, like PCB size, phone width, screw spacing, diameter of the thing it wraps around, etc. These are non-negotiable and drive everything else. + +**Mounting & attachment** +How does it connect to things? Screws (what size?), snap-fit, adhesive tape, magnets, freestanding on a desk? This affects wall thickness, boss placement, and overall structure. + +**Printer & material** +What printer do they have? (Bambu, Prusa, Ender, etc.) Nozzle size? Material (PLA, PETG, TPU)? This directly affects tolerances, minimum feature sizes, and design constraints. Defaults: 0.4mm nozzle, PLA, 0.2mm layer height. + +**Functional needs** +Ventilation/airflow, water resistance, cable routing, access panels, visibility windows, stacking, weight limits. Ask only what's relevant to the object. + +**Aesthetic preferences** +Rounded vs sharp edges, minimal vs industrial look, color considerations (affects visibility of layer lines). Ask briefly. Most users care more about function than form. + +Start with the first two (what + dimensions), then ask about mounting and material if relevant. Only ask about aesthetics if the user seems to care or if it affects structural choices. + +## Progressive Preview Workflow + +Build the model in phases. At each phase, export an STL, render a preview, self-review it, then **show it to the user and ask for feedback** before proceeding. This catches problems early and keeps the user involved. + +### Preview recipe (use at every phase) + +**One-shot (run script + render + parse result as JSON):** +```bash +python3 run_cadquery_model.py model.py --preview --strict +``` +This executes `model.py`, finds the STL it wrote, renders the multi-view preview, and emits a JSON result with `success`, `stdout`, `stderr`, `stl`, `preview`, and `watertight`. With `--strict`, a non-watertight mesh is a hard failure. Use this as the default loop: if `success` is false, read the `stderr` field to fix the CadQuery script, then re-run. + +**Rendering only (when the STL already exists):** +```bash +python3 preview.py model.stl preview.png --views multi +``` + +Then view the preview image, self-review it against the checklist in `design-review.md`, and fix any issues you spot **before** showing it to the user. + +--- + +### Phase 1: Base Shape + +Build the basic outer form: overall dimensions, shell/walls, bottom plate. No cutouts, no fillets, no details yet. + +1. Write the script with parameters and basic geometry +2. Export STL and render preview +3. Self-review: Does the shape and size look right? Is the bottom flat for printing? +4. **Show the preview to the user**: "Here's the basic shape. Does this look right before I add details?" Include key dimensions. +5. Wait for feedback. If the user wants changes, iterate here before moving on. + +### Phase 2: Features + +Add functional details: holes, cutouts, mounting bosses, cable slots, ventilation, snap-fits, internal structures. + +1. Add features to the script +2. Export STL and render preview +3. Self-review: Are all features visible? Do booleans look clean? Are holes in the right positions? +4. **Show the preview to the user**: "I've added [list features]. Anything to change before I finalize?" +5. Wait for feedback. Iterate if needed. + +### Phase 3: Final Delivery + +Apply finishing touches: fillets, chamfers, edge cleanup. Do a full printability review. + +1. Add fillets/chamfers (largest radius first, apply after shell) +2. Export final STL and render preview +3. **Full self-review** using the complete checklist from `design-review.md`: visual inspection, dimensional verification, printability analysis +4. Fix any issues found, re-export if needed +5. **Deliver to the user**: final STL + preview image + print recommendations (orientation, supports, infill, material notes) + +--- + +**Important:** Do NOT skip phases or combine them unless the model is very simple (e.g., a flat bracket with two holes). For anything with enclosed geometry, multiple features, or tight tolerances, follow all three phases. + +Read `design-review.md` for the full visual inspection checklist, dimensional verification code, and printability analysis helpers. + +### Print Recommendations (final delivery) + +When you deliver the final STL, always include a one-line slicer recipe plus a short rationale. Bambu Studio, PrusaSlicer, and OrcaSlicer already set sensible defaults from their filament + process presets, so **do not restate every slicer option**. Only tell the user what matters for *this* model: material, layer height, walls, infill, supports, and orientation. Tweak from the baseline below only when the model needs it. + +**Baseline recipe (0.4mm nozzle, typical FDM):** +> PLA, 0.2mm layer, 2 walls, 15% gyroid infill, no supports, orientation: flat side on bed. + +**When to deviate from the baseline:** +- **Load-bearing brackets / hooks / hinges**: bump infill to 25-40%, 3-4 walls, consider PETG over PLA for toughness. +- **Thin decorative walls or vases**: 0 infill, vase mode or 1 wall. +- **Tall narrow parts**: add a brim for bed adhesion. +- **Flexible parts (gaskets, grips)**: TPU 95A, 0.2mm layer, slower speed, no supports. +- **Functional overhangs the geometry can't avoid**: tree supports, or call them out so the user knows. +- **Outdoor / hot environments**: PETG or ASA, not PLA. +- **Food / skin contact**: call out that FDM parts are not food-safe and recommend a food-safe coating. + +**Format at delivery time:** +``` +Print settings: PLA, 0.2mm layer, 2 walls, 15% gyroid infill, no supports. +Orientation: place flat back side on the bed (front face up). +Why: the case has no overhangs above 45°, and 15% infill is plenty for a +TPU-adjacent protective shell. +``` + +Keep it to ~3 lines. Never dump every slicer setting; the slicer already knows. + +## Script Template + +ALWAYS structure scripts like this: + +```python +import cadquery as cq + +# ============================================================ +# PARAMETERS - Edit these to customize the model +# ============================================================ +# Overall dimensions +width = 60.0 # mm - outer width +depth = 40.0 # mm - outer depth +height = 25.0 # mm - outer height + +# Wall and structural +wall = 2.0 # mm - wall thickness (min 1.2 for FDM) +corner_r = 2.0 # mm - corner fillet radius + +# Tolerances +fit_clearance = 0.3 # mm - clearance for press-fit (adjust per printer) + +# ============================================================ +# MODEL +# ============================================================ +result = ( + cq.Workplane("XY") + .box(width, depth, height, centered=(True, True, False)) + # ... build geometry using parameters above (bottom at Z=0) +) + +# ============================================================ +# EXPORT +# ============================================================ +# Use tolerance=0.01, angularTolerance=0.1 for consistent tessellation +# across models. Defaults give coarser, wildly variable STL sizes. +cq.exporters.export(result, "output.stl", + tolerance=0.01, angularTolerance=0.1) +print(f"Exported: {width}x{depth}x{height}mm") +``` + +## Key Rules + +### Parameters First +- ALL dimensions go in the PARAMETERS section at the top +- Use descriptive names: `screw_hole_d`, not `d1` +- Add units in comments (always mm) +- Group related parameters with blank lines and section comments + +### Print-Friendly Defaults +Key FDM design defaults: + +| Property | Minimum | Recommended | +|----------|---------|-------------| +| Wall thickness | 1.2mm | 2.0mm | +| Layer height | 0.08mm | 0.2mm | +| Hole clearance | 0.2mm | 0.3mm | +| Press-fit interference | 0.1mm | 0.15mm | +| Min feature size | 0.4mm (nozzle) | 0.8mm | +| Fillet radius (bottom) | 0.5mm | 1.0mm | +| Bridge span | - | < 20mm unsupported | +| Overhang angle | - | < 45 degrees from vertical | + +**Material-specific adjustments:** TPU needs larger clearances (~0.5mm) due to flex. PETG is stickier, so add +0.1mm to fit clearances. ABS shrinks ~0.5-0.7%, so scale critical dimensions up slightly. When in doubt, print a small test piece first. + +### Orientation Awareness +- Design with print orientation in mind +- Flat bottom surfaces print best +- Avoid supports when possible by designing around overhangs +- Add chamfers to bottom edges instead of fillets (fillets need supports) +- Comment the intended print orientation in the script + +### CadQuery Patterns + +Common patterns to know: + +**Hollow enclosure (boolean subtraction, preferred):** +```python +outer = ( + cq.Workplane("XY") + .box(width, depth, height, centered=(True, True, False)) + .edges("|Z").fillet(corner_r) +) +inner = ( + cq.Workplane("XY") + .workplane(offset=floor_t) + .box(width - 2*wall, depth - 2*wall, height, centered=(True, True, False)) + .edges("|Z").fillet(max(0.1, corner_r - wall)) +) +result = outer.cut(inner) +``` + +**Screw boss:** +```python +.pushPoints([(x, y)]) +.circle(boss_od / 2).extrude(boss_h) +.pushPoints([(x, y)]) +.hole(screw_d + fit_clearance) +``` + +**Snap-fit clip:** +```python +# Cantilever beam with overhang hook +.workplane(offset=wall) +.moveTo(x, y).rect(clip_w, clip_l).extrude(clip_h) +# Add hook at tip with a small overhang (< 45 deg) +``` + +**Ventilation grid:** +```python +.pushPoints(vent_positions) +.slot2D(slot_l, slot_w).cutThruAll() +``` + +Other patterns: mounting brackets, cable routing channels, text/labels (`.text()`), multi-part assemblies with alignment pins. + +### Common Pitfalls + +- **Hollowing: prefer boolean subtraction over `.shell()`**. `.shell()` is fragile. It fails on tapered bodies, lofted shapes, unions of multiple primitives, and anything with many fillets. The reliable pattern is: + ```python + outer = cq.Workplane("XY").box(w, d, h, centered=(True, True, False)).edges("|Z").fillet(corner_r) + inner = ( + cq.Workplane("XY") + .workplane(offset=floor_t) + .box(w - 2*wall, d - 2*wall, h, centered=(True, True, False)) + .edges("|Z").fillet(max(0.1, corner_r - wall)) + ) + result = outer.cut(inner) + ``` + Only reach for `.shell()` when the body is a single simple primitive (one `.box()` or `.cylinder()`) with a uniform wall thickness on all sides. If in doubt, use boolean subtraction. +- **Build order: fillet → cut, not cut → fillet**. Apply fillets while the geometry is still a clean primitive. Once you have cut holes/slots/pockets into a body, filleting the resulting edges often fails or produces bad geometry. Same rule for chamfers. +- **Fillet failures**: Apply fillets from largest to smallest radius. **Do not wrap fillets in `try/except` to silently shrink the radius.** A fillet failure means the geometry or the radius is wrong; find the root cause (too-large radius, wall thinner than radius, adjacent faces that the fillet would degenerate) and fix that. +- **Zero-thickness geometry**: Ensure boolean operations don't create infinitely thin walls. Add a small epsilon (0.01mm) when cutting bodies that are meant to pass just through a surface. +- **Coordinate system**: CadQuery centers geometry at origin by default. Use `centered=(True, True, False)` on `.box()` to place the bottom at Z=0 so `.faces("= 1.2mm +- [ ] Designed for printability (minimal overhangs/supports) +- [ ] Print orientation noted in comments +- [ ] STL exported and file size is reasonable (not 0 bytes) +- [ ] Clear parameter names with units +- [ ] Script runs without errors +- [ ] **Multi-view preview generated and visually inspected** +- [ ] **Preview shows correct shape, features, and proportions** +- [ ] **Bounding box dimensions match requirements** +- [ ] Both STL and preview PNG delivered to user + +## Showing Models in CadQuery Viewer + +> This section was added when the skill was bundled with the CadQuery Viewer (`cq-viewer`) plugin. It is not part of upstream [flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill). + +When the `cq-viewer` MCP tools are available, also show each phase's model in the viewer, so the user can orbit it, section it, and mark positions on it. Keep rendering the PNG preview for your own self-review. + +- After the script writes `model.stl` or `model.step`, call `add_model_file` with the file's absolute path. The viewer opens the file in place and reloads it every time the script re-exports it, so call it once per file, not once per iteration. +- Export STEP as well (`cq.exporters.export(result, "model.step")`) when edges or assembly parts matter: the viewer keeps part names, colours, and real CAD edges for STEP. +- Files outside the home directory (for example a scratch directory in `/tmp`) cannot be opened in place: pass `copy_to_scene: true` to copy them into the viewer's scene instead. +- Use `add_model_folder` to register the project folder once, and `list_model_folders` to find models already there. +- If the tool result says no viewer window is connected, ask the user to open CadQuery Viewer; the requested models open automatically when it starts. +- Positions the user marks in the viewer arrive as a message in the conversation; the same data is in `~/.cqview/selection.json`. diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/UPSTREAM.md b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/UPSTREAM.md new file mode 100644 index 0000000..ac65ac4 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/UPSTREAM.md @@ -0,0 +1,17 @@ +# Upstream + +This directory is a copy of the `parametric-3d-printing` skill from +[flowful-ai/cad-skill](https://github.com/flowful-ai/cad-skill), bundled with the CadQuery Viewer +plugin. + +- Source commit: `fe4215970d39f388ff1afc411fac49a9c5f79756` (2026-07-14) +- License: [PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) — see `LICENSE` in this directory. It applies to this directory only; the rest of the plugin is MIT. +- Required Notice: Copyright Nicolas Chourrout (https://github.com/nchourrout) + +## Changes from upstream + +- `SKILL.md`: one section appended at the end, "Showing Models in CadQuery Viewer". Everything above it is unchanged. +- Not included: `README.md`, `docs/` (preview images for the upstream README), `tests/`, `.gitignore`. +- All other files are byte-identical to the source commit. + +To update, copy the same files from a newer upstream commit, re-append the section, and update the commit above. diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/design-review.md b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/design-review.md new file mode 100644 index 0000000..120ae3f --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/design-review.md @@ -0,0 +1,235 @@ +# Design Review & Iteration Guide + +## Table of Contents +1. Preview-Analyze-Iterate Workflow +2. Visual Inspection Checklist +3. Dimensional Verification +4. Printability Analysis +5. Common Issues and Fixes + +--- + +## 1. Preview-Analyze-Iterate Workflow + +After generating a model, ALWAYS run the preview-analyze-iterate loop before delivering the final STL. + +### Step 1: Generate Preview + +```bash +# Install dependencies (first time only) +pip install trimesh pyrender Pillow + +# Generate multi-view preview +python3 preview.py model.stl preview.png --views multi +``` + +### Step 2: View the Preview + +Read the generated PNG file to inspect it visually. The multi-view layout shows the model from 6 angles (isometric, front, right, back-isometric, top, bottom) with dimensions in the footer. + +### Step 3: Analyze the Preview + +Check against this visual inspection list: + +**Shape & Proportions** +- Does the overall shape match what was requested? +- Are proportions reasonable (not too thin, not too bulky)? +- Are all requested features visible (holes, slots, cutouts)? + +**Printability** +- Is there a flat bottom surface for bed adhesion? +- Are there visible overhangs > 45 degrees that need supports? +- Do thin features look thick enough to print (> 1.2mm)? +- Are screw bosses and standoffs properly connected to walls? + +**Geometry Issues** +- Are there any obviously missing features? +- Do boolean operations look clean (no floating geometry)? +- Are fillets and chamfers applied to the right edges? +- Does the interior look correctly hollowed out? + +**Dimensions** +- Check bounding box in the preview footer +- Compare against user's stated requirements +- Verify critical dimensions from the code match visual + +### Step 4: Fix Issues + +If problems are found: +1. Identify the specific issue from the preview +2. Trace it to the relevant code section +3. Fix the code +4. Re-export STL +5. Re-generate preview +6. Verify the fix + +### Step 5: Deliver + +Once the preview passes inspection: +1. Export final STL (and optionally STEP) +2. Generate final preview image +3. Share both the STL and preview with the user +4. Note any print recommendations (orientation, supports, infill) + +--- + +## 2. Visual Inspection Checklist + +Use this as a structured review after viewing the preview: + +``` +VISUAL REVIEW +============= +[ ] Overall shape matches request +[ ] All features present (holes, cutouts, mounts, etc.) +[ ] Proportions look correct +[ ] No floating or disconnected geometry +[ ] Flat bottom for printing +[ ] No extreme overhangs visible +[ ] Fillets/chamfers applied correctly +[ ] Wall thickness appears sufficient +[ ] Interior properly hollowed (if applicable) +[ ] Dimensions match requirements (check footer) +``` + +--- + +## 3. Dimensional Verification + +Beyond visual inspection, verify dimensions programmatically: + +```python +import cadquery as cq + +# After building the model: +bb = result.val().BoundingBox() +print(f"Width (X): {bb.xlen:.1f} mm") +print(f"Depth (Y): {bb.ylen:.1f} mm") +print(f"Height (Z): {bb.zlen:.1f} mm") +print(f"Volume: {result.val().Volume():.0f} mm³") + +# Check specific dimensions +assert abs(bb.xlen - expected_width) < 0.1, f"Width mismatch: {bb.xlen} vs {expected_width}" +``` + +### Key Checks +- Bounding box matches expected outer dimensions +- Volume is reasonable (not suspiciously small or large) +- Volume > 0 confirms the model is a valid solid +- Compare volume with/without shell to verify wall thickness + +### Watertight Check + +Non-watertight meshes are the most common cause of slicing failures. Always verify: + +```python +import trimesh + +tm = trimesh.load("model.stl", force="mesh") +if tm.is_watertight: + print("Mesh is watertight (good)") +else: + print("WARNING: Mesh is NOT watertight — will cause slicing issues") + # Common causes: unclosed shells, boolean artifacts, zero-thickness faces +``` + +Note: `preview.py` checks watertight status automatically when generating previews. + +--- + +## 4. Printability Analysis + +### Overhang Detection (Approximate) + +```python +import trimesh +import numpy as np + +def check_overhangs(stl_path, max_angle=45): + """Check for faces with overhang angle beyond threshold. + + Overhang angle is measured from the horizontal plane. + A flat bottom (normal pointing straight down) = 0 deg overhang (safe). + A face angled 50 deg from horizontal = needs supports if max_angle=45. + + Returns percentage of total faces that may need supports. + """ + tm = trimesh.load(stl_path, force="mesh") + normals = tm.face_normals + + # Only check downward-facing normals (potential overhangs) + downward = normals[:, 2] < 0 + if not downward.any(): + return 0.0 + + down = np.array([0, 0, -1]) + down_normals = normals[downward] + + # Angle between each normal and straight-down vector + cos_angles = np.dot(down_normals, down) / ( + np.linalg.norm(down_normals, axis=1) + 1e-10 + ) + angles_from_down = np.degrees(np.arccos(np.clip(cos_angles, -1, 1))) + + # angle_from_down=0 means flat bottom (safe) + # angle_from_down=90 means vertical face (safe) + # Overhang angle from horizontal = 90 - angle_from_down + overhang_from_horizontal = 90 - angles_from_down + problem_faces = np.sum(overhang_from_horizontal > max_angle) + + return problem_faces / len(normals) * 100 + +pct = check_overhangs("model.stl") +if pct > 5: + print(f"WARNING: {pct:.0f}% faces may need supports") +else: + print(f"OK: Model looks printable without supports ({pct:.1f}% overhang)") +``` + +### Thin Wall Detection + +```python +def estimate_min_wall(result): + """Rough check - compare shelled vs non-shelled volume.""" + vol = result.val().Volume() + bb = result.val().BoundingBox() + outer_vol = bb.xlen * bb.ylen * bb.zlen + fill_ratio = vol / outer_vol + + if fill_ratio < 0.15: + print("⚠ Very thin walls - may be fragile") + elif fill_ratio < 0.3: + print("✓ Thin-walled design (typical for enclosures)") + else: + print("✓ Solid/thick-walled design") + + return fill_ratio +``` + +--- + +## 5. Common Issues and Fixes + +### Issue: Feature not visible in preview +**Cause**: Boolean operation failed silently +**Fix**: Check that cutting bodies actually overlap the main body. Print intermediate volumes. + +### Issue: Model looks correct but walls are paper-thin +**Cause**: Shell thickness too small, or shell removed wrong faces +**Fix**: Verify shell direction (negative = inward) and thickness >= 1.2mm + +### Issue: Standoffs appear disconnected +**Cause**: Union failed because standoffs don't touch the body +**Fix**: Ensure standoff base cylinder intersects with the floor/wall of the body + +### Issue: Cutout in wrong position +**Cause**: Coordinate system confusion - CadQuery centers at origin by default +**Fix**: Use `centered=(True, True, False)` and verify offsets from known reference points + +### Issue: Fillets look wrong or preview crashes +**Cause**: Fillet radius too large for the edge +**Fix**: Reduce fillet radius. Rule of thumb: fillet radius < half the smallest adjacent face dimension + +### Issue: Preview shows correct shape but dimensions are wrong +**Cause**: Forgot to update a parameter or used wrong variable +**Fix**: Add assertions comparing bounding box to expected dimensions diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/gridfinity_d110_bin.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/gridfinity_d110_bin.py new file mode 100644 index 0000000..38a7f78 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/gridfinity_d110_bin.py @@ -0,0 +1,51 @@ +"""Gridfinity 3x2 cradle bin for the Orico D110 label printer. + +The hand-rolled original lives at the repo root; this version rebuilds +it on the vendored gridfinity module in ~25 lines. The printer lies on +its 34mm side so the bin fits an IKEA Alex top drawer; it protrudes +above the rim for easy grabbing. + +Print: upright, bottom on bed, no supports. PLA, 0.2mm layers. +""" + +import sys +from pathlib import Path + +import cadquery as cq + +# In your own project, copy gridfinity.py next to your script instead +# of this path shim (it only resolves the module inside this repo). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from gridfinity import GridfinityBin + +# ============================================================ +# PARAMETERS +# ============================================================ +# Orico D110 label printer: 88 x 72 x 34 mm lying on its side +printer_l = 88.0 # mm - printer length (X) +printer_w = 72.0 # mm - printer width (Y) +printer_clearance = 0.3 # mm - per-side fit clearance +easy_grab = 1.0 # mm - extra per-side room for insertion/removal + +# The D110 has very rounded corners on the grip/USB end (-X, ~12mm) +# and near-square corners on the label-exit end (+X, ~1mm). +pocket_r = (12.0, 1.0) # mm - (-X end, +X end) + +# ============================================================ +# MODEL +# ============================================================ +bin = GridfinityBin(grid_x=3, grid_y=2, height_units=3, + stacking_lip=True) +bin.add_pocket( + length=printer_l + 2 * (printer_clearance + easy_grab), + width=printer_w + 2 * (printer_clearance + easy_grab), + corner_r=pocket_r, +) +result = bin.build() + +# ============================================================ +# EXPORT +# ============================================================ +cq.exporters.export(result, "gridfinity_d110_bin.stl", + tolerance=0.01, angularTolerance=0.1) +print(bin.summary()) diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/mx_master3_bin_3x3.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/mx_master3_bin_3x3.py new file mode 100644 index 0000000..93f575b --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/examples/mx_master3_bin_3x3.py @@ -0,0 +1,113 @@ +"""Gridfinity 3x3 diagonal cradle bin for the Logitech MX Master 3. + +Showcases the scan-to-pocket pipeline: the pocket is the mouse's real +outline (max cross-section of its bottom 19mm, extracted from a 3D +scan with outline_from_scan.py), rotated 43.9 degrees, the angle +minimizing its bounding square (120.6mm incl. clearance), so it fits +a 3x3 grid (123.1mm usable) instead of 4x3. A thumb scoop with a +hemispherical bottom leans 35 degrees toward the mouse so the thumb +slides down a smooth ramp under the thumb-rest wing. + +Walls thin to ~1.2mm at the four diagonal contact points; fine for a +drawer bin. The rotation is computed below from the base outline, so +a regenerated outline only needs pasting once. + +Fit note (print-verified): the outline is the MX Master 3 scan scaled +up a few percent (to 128.2x88.4mm), so an MX Master 3 / 3S sits in it +with ~3mm of easy slack per side. + +Outline provenance: "Logitech Mx Master 3 3D Scan" by robomo +(printables.com/model/1149616), processed with: + python3 outline_from_scan.py mxmaster3_scan.stl \ + --dims 128.2x88.4x50.8 --zmax 19 +(For a snug MX Master 3 fit, use --dims 124.9x84.3x51 instead. The +angle comes from outline_from_scan.py --rotate-min-square.) + +Print: upright, bottom on bed, no supports. PLA, 0.2mm layers. +""" + +import math +import sys +from pathlib import Path + +import cadquery as cq + +# In your own project, copy gridfinity.py next to your script instead +# of this path shim (it only resolves the module inside this repo). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from gridfinity import GridfinityBin + +# ============================================================ +# PARAMETERS +# ============================================================ +fit_clearance = 0.3 # mm - per-side fit clearance +easy_grab = 1.0 # mm - extra per-side room to drop in/out easily + +angle_deg = 43.9 # rotation minimizing the outline's bounding square + +well_d = 22.0 # mm - thumb scoop diameter +well_center_base = (42.0, 27.0) # mm - over the wing, in base coords +well_pull = 5.0 # mm - pulled toward the bin center off the wall +well_tilt = 35.0 # deg from vertical, leaning toward the mouse + +# Max cross-section of the mouse's bottom 19mm, mm, centered on the +# footprint bbox. Nose at +X, thumb wing at +Y. 52 points. +mouse_outline_base = [ + (-61.56, -23.06), (-64.02, -12.41), (-64.07, -7.76), (-63.61, -3.2), + (-61.1, 4.55), (-52.59, 22.81), (-45.12, 34.43), (-40.35, 39.89), + (-37.67, 41.82), (-34.96, 42.98), (-30.06, 43.96), (-22.86, 44.06), + (-7.54, 43.03), (3.18, 41.39), (6.89, 39.76), (10.06, 36.88), + (13.97, 29.4), (15.89, 27.05), (18.67, 25.38), (22.04, 24.8), + (24.67, 20.91), (26.91, 19.01), (29.58, 18.02), (34.37, 17.92), + (50.66, 21.07), (53.69, 20.55), (54.27, 21.6), (56.75, 21.62), + (60.61, 19.31), (62.17, 17.4), (63.42, 14.54), (63.9, 8.65), + (62.94, -3.02), (61.32, -12.48), (58.62, -23.66), (56.9, -28.39), + (55.83, -30.05), (54.55, -30.87), (54.23, -31.97), (50.48, -34.12), + (48.88, -34.62), (38.66, -36.49), (15.69, -38.27), (-0.95, -41.56), + (-16.35, -43.73), (-26.5, -44.19), (-34.62, -43.59), (-40.31, -42.31), + (-44.22, -40.9), (-51.19, -36.53), (-54.94, -33.12), (-58.56, -28.66), +] + +# ============================================================ +# DERIVED: rotate outline and well, re-center on the rotated bbox +# ============================================================ +_a = math.radians(angle_deg) + + +def _rot(p): + return (p[0] * math.cos(_a) - p[1] * math.sin(_a), + p[0] * math.sin(_a) + p[1] * math.cos(_a)) + + +_rotated = [_rot(p) for p in mouse_outline_base] +_xs = [p[0] for p in _rotated] +_ys = [p[1] for p in _rotated] +_shift = (-(min(_xs) + max(_xs)) / 2, -(min(_ys) + max(_ys)) / 2) +mouse_outline = [(x + _shift[0], y + _shift[1]) for x, y in _rotated] + +_wx, _wy = _rot(well_center_base) +_wx, _wy = _wx + _shift[0], _wy + _shift[1] +_scale = 1 - well_pull / math.hypot(_wx, _wy) +well_center = (_wx * _scale, _wy * _scale) + +# ============================================================ +# MODEL +# ============================================================ +bin = GridfinityBin(grid_x=3, grid_y=3, height_units=3, + stacking_lip=True) +bin.add_polygon_pocket(mouse_outline, + clearance=fit_clearance + easy_grab) +bin.add_cylinder_pocket(diameter=well_d, center=well_center, + tilt=well_tilt, + tilt_dir=(-well_center[0], -well_center[1]), + round_bottom=True) +result = bin.build() + +# ============================================================ +# EXPORT +# ============================================================ +cq.exporters.export(result, "mx_master3_bin_3x3.stl", + tolerance=0.01, angularTolerance=0.1) +print(bin.summary()) +print(f"Mouse rotated {angle_deg} deg; thumb scoop at " + f"({well_center[0]:.1f}, {well_center[1]:.1f})") diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/gridfinity.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/gridfinity.py new file mode 100644 index 0000000..0692a9f --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/gridfinity.py @@ -0,0 +1,668 @@ +"""Gridfinity bin generator for CadQuery. + +Vendored module: copy this file next to your model script and use + + from gridfinity import GridfinityBin + + bin = GridfinityBin(grid_x=3, grid_y=2, height_units=3, + stacking_lip=True, magnets=True) + bin.add_pocket(length=88.6, width=72.6, corner_r=(12.0, 1.0)) + result = bin.build() + cq.exporters.export(result, "my_bin.stl", tolerance=0.01, + angularTolerance=0.1) + +Spec sources: gridfinity.xyz/specification and +github.com/gridfinity-unofficial/specification (42x42x7 grid, 41.5mm +bin footprint, 6x2mm magnets, M3 screws, holes on a 26mm square). +Base profile heights (0.8 / 1.8 / 2.15) match the bins shipped from +this repo and print-verified on standard baseplates. + +Geometry conventions: +- Bottom of the bin at Z=0, ready for `.faces(" 41.5mm footprint per cell +HEIGHT_UNIT = 7.0 # one height unit +TAPER_1 = 0.8 # base profile: bottom 45-degree taper +RISER = 1.8 # base profile: vertical riser +TAPER_2 = 2.15 # base profile: top 45-degree taper +BASE_H = TAPER_1 + RISER + TAPER_2 # 4.75 +CORNER_R = 3.75 # body corner radius (spec 4.0 minus clearance) +MAGNET_D = 6.5 # bore for 6x2mm magnets +MAGNET_DEPTH = 2.4 +SCREW_D = 3.0 # M3 thread-forming +SCREW_DEPTH = 6.0 +HOLE_SPACING = 26.0 # magnet/screw centers, square per cell + +# ============================================================ +# PRINTABILITY DEFAULTS (mm) - 0.4mm-nozzle FDM, tunable +# ============================================================ +MIN_WALL = 1.2 # minimum wall/divider thickness +DEFAULT_FLOOR = 2.0 # solid floor above the base profile +TOP_CHAMFER = 0.6 # rim edge chamfer + +_INSET_BOT = TAPER_1 + TAPER_2 # 2.95 - footprint inset at the very bottom +_INSET_MID = TAPER_2 # 2.15 - inset at the riser +_EPS = 0.01 + + +class GridfinityError(ValueError): + """Raised when a bin configuration cannot produce valid geometry.""" + + +class _Pocket(NamedTuple): + length: float + width: float + depth: float + radii: tuple + center: tuple + + +class _PolygonPocket(NamedTuple): + points: list + depth: float + clearance: float + + +class _Compartments(NamedTuple): + cols: int + rows: int + wall_t: float + scoop_r: float + label_tab: bool + label_d: float + + +class _CylinderPocket(NamedTuple): + diameter: float + depth: float + center: tuple + tilt: float + tilt_dir: tuple + round_bottom: bool + + +class _Notch(NamedTuple): + side: str + width: float + depth: float + offset: float + + +def _corner_radii(corner_r): + """Normalize scalar / 2-tuple / 4-tuple corner radii. + + Order: (+X+Y, -X+Y, -X-Y, +X-Y). A 2-tuple is (-X end, +X end), + applied to both corners of that end. + """ + if isinstance(corner_r, (int, float)): + return (corner_r,) * 4 + if len(corner_r) == 2: + r_minus, r_plus = corner_r + return (r_plus, r_minus, r_minus, r_plus) + if len(corner_r) == 4: + return tuple(corner_r) + raise GridfinityError("corner_r must be a number, 2-tuple, or 4-tuple") + + +def _rounded_rect(workplane, length, width, radii, center=(0.0, 0.0)): + """Draw a closed rounded-rect wire with per-corner radii on `workplane`. + + `radii` order: (+X+Y, -X+Y, -X-Y, +X-Y). A radius of 0 gives a + sharp corner. + """ + ra, rb, rc, rd = radii + hl, hw = length / 2, width / 2 + cx, cy = center + + wp = workplane.moveTo(cx - hl + rb, cy + hw) + wp = wp.lineTo(cx + hl - ra, cy + hw) + if ra > 0: + wp = wp.radiusArc((cx + hl, cy + hw - ra), ra) + wp = wp.lineTo(cx + hl, cy - hw + rd) + if rd > 0: + wp = wp.radiusArc((cx + hl - rd, cy - hw), rd) + wp = wp.lineTo(cx - hl + rc, cy - hw) + if rc > 0: + wp = wp.radiusArc((cx - hl, cy - hw + rc), rc) + wp = wp.lineTo(cx - hl, cy + hw - rb) + if rb > 0: + wp = wp.radiusArc((cx - hl + rb, cy + hw), rb) + return wp.close() + + +def _compound(parts): + """Collect the solids of several workplanes/solids into one Compound. + + Booleans against a single compound are much cheaper than one OCC + operation per feature. ONLY safe when the solids are disjoint or + the boolean is a fuse; CUTTING with a compound of overlapping tools + leaves internal faces. Use _fused for cutters that may overlap. + """ + solids = [] + for p in parts: + solids.extend(p.vals() if isinstance(p, cq.Workplane) else [p]) + return cq.Compound.makeCompound(solids) + + +def _fused(parts): + """Combine cutter workplanes into one safe boolean tool. + + Disjoint cutters ride in a cheap Compound; if any two bounding + boxes intersect, everything is fused instead so the cut cannot + leave internal faces. clean=False: the tool is discarded after + the cut, the built solid gets its own clean(). + """ + if len(parts) == 1: + return parts[0] + solids = [] + for p in parts: + solids.extend(p.vals() if isinstance(p, cq.Workplane) else [p]) + boxes = [s.BoundingBox() for s in solids] + overlapping = any( + a.xmin <= b.xmax and b.xmin <= a.xmax and + a.ymin <= b.ymax and b.ymin <= a.ymax and + a.zmin <= b.zmax and b.zmin <= a.zmax + for i, a in enumerate(boxes) for b in boxes[i + 1:]) + if not overlapping: + return cq.Compound.makeCompound(solids) + return parts[0].union(_compound(parts[1:]), clean=False) + + +class GridfinityBin: + """Builder for a spec-compatible Gridfinity bin. + + Create the bin, add features, then call `build()` to get a + `cq.Workplane` with the bottom at Z=0. + """ + + def __init__(self, grid_x, grid_y, height_units, + stacking_lip=True, magnets=False, screws=False, + floor_t=DEFAULT_FLOOR): + self.grid_x = grid_x + self.grid_y = grid_y + self.height_units = height_units + self.stacking_lip = stacking_lip + self.magnets = magnets + self.screws = screws + self.floor_t = floor_t + self._pockets = [] + self._polygon_pockets = [] + self._cylinder_pockets = [] + self._compartments = None + self._notches = [] + + # ------------------------------------------------------------ + # Derived dimensions + # ------------------------------------------------------------ + @property + def outer_w(self): + return self.grid_x * GRID_PITCH - 2 * CLEARANCE + + @property + def outer_d(self): + return self.grid_y * GRID_PITCH - 2 * CLEARANCE + + @property + def nominal_h(self): + return self.height_units * HEIGHT_UNIT + + @property + def total_h(self): + return self.nominal_h + (BASE_H if self.stacking_lip else 0) + + @property + def floor_z(self): + """Z of the interior floor: base profile plus solid floor.""" + return BASE_H + self.floor_t + + @property + def max_depth(self): + """Deepest cavity possible from the top of the bin.""" + return self.total_h - self.floor_z + + # ------------------------------------------------------------ + # Features + # ------------------------------------------------------------ + def add_pocket(self, length, width, depth=None, corner_r=2.0, + center=(0.0, 0.0)): + """Rounded-rect cavity cut from the top rim down `depth` mm. + + `corner_r`: scalar, (-X end, +X end) 2-tuple, or 4-tuple + (+X+Y, -X+Y, -X-Y, +X-Y). `depth=None` reaches the floor. + """ + self._pockets.append(_Pocket( + length, width, self.max_depth if depth is None else depth, + _corner_radii(corner_r), center)) + return self + + def add_polygon_pocket(self, points, depth=None, clearance=0.0): + """Cavity with an arbitrary closed outline (list of (x, y) mm). + + `clearance` offsets the outline outward, e.g. to add fit + clearance around a traced object. `depth=None` reaches the floor. + """ + self._polygon_pockets.append(_PolygonPocket( + list(points), self.max_depth if depth is None else depth, + clearance)) + return self + + def add_cylinder_pocket(self, diameter, depth=None, center=(0.0, 0.0), + tilt=0.0, tilt_dir=None, round_bottom=False): + """Round cavity cut from the top rim down `depth` mm. + + Useful for finger wells next to an object pocket, batteries, + coins, dowels. Overlapping another pocket merges the cavities. + `depth=None` reaches the floor. `center` is the opening center + at the rim. + + For a smooth thumb scoop instead of a straight hole: set + `round_bottom=True` (hemispherical bottom) and lean the bore + with `tilt` (degrees from vertical, max 45 so it prints without + supports) toward `tilt_dir`, an (dx, dy) direction the bottom + tip shifts along, e.g. toward the object pocket. + """ + if tilt and tilt_dir is None: + raise GridfinityError("tilt_dir is required when tilt is set") + if tilt_dir is not None: + norm = math.hypot(*tilt_dir) + if norm == 0: + raise GridfinityError("tilt_dir must be a non-zero (dx, dy)") + tilt_dir = (tilt_dir[0] / norm, tilt_dir[1] / norm) + self._cylinder_pockets.append(_CylinderPocket( + diameter, self.max_depth if depth is None else depth, center, + tilt, tilt_dir, round_bottom)) + return self + + def add_compartments(self, cols=1, rows=1, wall_t=MIN_WALL, + scoop_r=0.0, label_tab=False, label_d=12.0): + """Classic divided storage interior: cols x rows compartments. + + Optional `scoop_r` rounds the front (-Y) floor edge of every + compartment; `label_tab` adds a 45-degree label shelf along the + back (+Y) of every row, `label_d` mm deep. + """ + if self._compartments is not None: + raise GridfinityError("add_compartments can only be called once") + self._compartments = _Compartments( + cols, rows, wall_t, scoop_r, label_tab, label_d) + return self + + def add_finger_notch(self, side="+X", width=20.0, depth=None, + offset=0.0): + """U-shaped scallop cut into one side wall from the top rim. + + `side` is one of "+X", "-X", "+Y", "-Y". `depth` defaults to + half the bin height, measured from the top rim. `offset` slides + the notch along the wall from its center (mm, signed). + """ + self._notches.append(_Notch( + side, width, self.total_h / 2 if depth is None else depth, + offset)) + return self + + # ------------------------------------------------------------ + # Validation + # ------------------------------------------------------------ + def _validate(self): + if self.grid_x < 1 or self.grid_y < 1: + raise GridfinityError("grid_x and grid_y must be >= 1") + if self.height_units < 1: + raise GridfinityError("height_units must be >= 1") + if self.floor_t < 0.6: + raise GridfinityError("floor_t must be >= 0.6mm") + if self.nominal_h <= self.floor_z: + raise GridfinityError( + f"bin too short: nominal height {self.nominal_h}mm does not " + f"clear the floor at {self.floor_z}mm; increase height_units") + if self.screws and self.floor_z < SCREW_DEPTH + 0.6: + raise GridfinityError( + f"screw holes are {SCREW_DEPTH}mm deep; floor_t must be >= " + f"{SCREW_DEPTH + 0.6 - BASE_H:.2f}mm to keep a printable " + f"membrane above them") + + for p in self._pockets: + self._check_depth(p.depth, "pocket") + self._check_wall_clearance( + p.length / 2, p.width / 2, p.center, + f"pocket {p.length}x{p.width}mm at {p.center}") + ra, rb, rc, rd = p.radii + if (ra + rb > p.length or rc + rd > p.length or + rb + rc > p.width or ra + rd > p.width): + raise GridfinityError("pocket corner radii exceed pocket size") + + for p in self._polygon_pockets: + if len(p.points) < 3: + raise GridfinityError("polygon pocket needs >= 3 points") + self._check_depth(p.depth, "polygon pocket") + xs = [pt[0] for pt in p.points] + ys = [pt[1] for pt in p.points] + self._check_wall_clearance( + max(map(abs, xs)) + p.clearance, + max(map(abs, ys)) + p.clearance, (0, 0), + "polygon pocket (plus clearance)") + + for p in self._cylinder_pockets: + self._check_depth(p.depth, "cylinder pocket") + self._check_wall_clearance( + p.diameter / 2, p.diameter / 2, p.center, + f"cylinder pocket d={p.diameter}mm at {p.center}") + if not 0 <= p.tilt <= 45: + raise GridfinityError( + "cylinder pocket tilt must be 0..45 degrees " + "(above 45 the bore ceiling needs supports)") + if p.round_bottom and p.depth < p.diameter / 2: + raise GridfinityError( + "round_bottom needs depth >= diameter/2") + if p.tilt: + shift = self._cylinder_drop(p) * math.tan( + math.radians(p.tilt)) + bottom = (p.center[0] + shift * p.tilt_dir[0], + p.center[1] + shift * p.tilt_dir[1]) + self._check_wall_clearance( + p.diameter / 2, p.diameter / 2, bottom, + f"tilted cylinder pocket bottom at " + f"({bottom[0]:.1f}, {bottom[1]:.1f})") + + c = self._compartments + if c: + if c.wall_t < MIN_WALL: + raise GridfinityError(f"divider wall_t must be >= {MIN_WALL}mm") + comp_w, comp_d, _, _ = self._compartment_size() + if comp_w < 5 or comp_d < 5: + raise GridfinityError( + f"compartments are {comp_w:.1f}x{comp_d:.1f}mm; " + "reduce cols/rows or wall_t (5mm minimum)") + if c.scoop_r > min(comp_d, self.nominal_h - self.floor_z): + raise GridfinityError("scoop_r too large for the compartment") + if c.label_tab and c.label_d > min( + comp_d, self.nominal_h - self.floor_z): + raise GridfinityError("label_d too large for the compartment") + + for n in self._notches: + if n.side not in ("+X", "-X", "+Y", "-Y"): + raise GridfinityError('notch side must be "+X", "-X", "+Y" or "-Y"') + if n.width < 2: + raise GridfinityError("notch width must be >= 2mm") + if n.depth < n.width / 2: + raise GridfinityError("notch depth must be >= width/2") + self._check_depth(n.depth, "finger notch") + wall_half = (self.outer_d if n.side in ("+X", "-X") + else self.outer_w) / 2 + if abs(n.offset) + n.width / 2 > wall_half - CORNER_R: + raise GridfinityError( + f"notch offset {n.offset}mm pushes it past the wall " + f"(usable half-span {wall_half - CORNER_R:.1f}mm)") + + def _check_wall_clearance(self, half_x, half_y, center, what): + """Feature footprint must leave MIN_WALL of outer wall.""" + cx, cy = center + if (abs(cx) + half_x > self.outer_w / 2 - MIN_WALL or + abs(cy) + half_y > self.outer_d / 2 - MIN_WALL): + raise GridfinityError( + f"{what} leaves less than {MIN_WALL}mm of outer wall " + f"(interior is {self.outer_w - 2 * MIN_WALL:.1f}x" + f"{self.outer_d - 2 * MIN_WALL:.1f}mm)") + + def _check_depth(self, depth, what): + if depth > self.max_depth + _EPS: + raise GridfinityError( + f"{what} depth {depth}mm exceeds max {self.max_depth:.2f}mm " + f"({self.floor_t}mm floor above the {BASE_H}mm base)") + + def _compartment_size(self): + c = self._compartments + interior_w = self.outer_w - 2 * c.wall_t + interior_d = self.outer_d - 2 * c.wall_t + comp_w = (interior_w - (c.cols - 1) * c.wall_t) / c.cols + comp_d = (interior_d - (c.rows - 1) * c.wall_t) / c.rows + return comp_w, comp_d, interior_w, interior_d + + # ------------------------------------------------------------ + # Geometry + # ------------------------------------------------------------ + def build(self): + """Validate and build; returns a cq.Workplane, bottom at Z=0.""" + self._validate() + + solid = self._body().union(self._base_cells()) + if self.stacking_lip: + solid = solid.cut(self._lip_cut()) + + # Batch all interior cutters into one boolean, then all added + # material (scoops, label shelves) into one union. Notches come + # after the unions so a notch can pass through a scoop. + cutters = [self._pocket_cut(p) for p in self._pockets] + cutters += [self._polygon_cut(p) for p in self._polygon_pockets] + cutters += [self._cylinder_cut(p) for p in self._cylinder_pockets] + adds = [] + if self._compartments: + comp_cutters, comp_adds = self._compartment_solids() + cutters += comp_cutters + adds += comp_adds + if cutters: + solid = solid.cut(_fused(cutters)) + if adds: + solid = solid.union(_compound(adds)) + if self._notches: + solid = solid.cut( + _fused([self._notch_cut(n) for n in self._notches])) + if self.magnets or self.screws: + solid = solid.cut(self._base_hole_cutter()) + + return solid.clean() + + def _cell_centers(self): + return [((ix - (self.grid_x - 1) / 2) * GRID_PITCH, + (iy - (self.grid_y - 1) / 2) * GRID_PITCH) + for ix in range(self.grid_x) for iy in range(self.grid_y)] + + def _base_cells(self): + """Per-cell base profile: taper, riser, taper (45 degrees). + + The sketch fillet starts at CORNER_R - 2.95 so the corner radius + grows through the tapers to exactly CORNER_R at the top, flowing + into the body fillet with no step (spec radii 0.8 / 1.6 / 3.75). + All cells are identical: build one at the origin and translate + copies to each grid position. + """ + cell_size = GRID_PITCH - 2 * CLEARANCE + cell_bot = cell_size - 2 * _INSET_BOT + + cell = ( + cq.Workplane("XY") + .sketch().rect(cell_bot, cell_bot) + .vertices().fillet(CORNER_R - _INSET_BOT).finalize() + .extrude(TAPER_1, taper=-45) + ) + cell = cell.faces(">Z").wires().toPending().extrude(RISER) + cell = (cell.faces(">Z").wires().toPending() + .extrude(TAPER_2, taper=-45)) + solid = cell.val() + return _compound([solid.translate(cq.Vector(cx, cy, 0)) + for cx, cy in self._cell_centers()]) + + def _body(self): + return ( + cq.Workplane("XY") + .transformed(offset=(0, 0, BASE_H)) + .box(self.outer_w, self.outer_d, self.total_h - BASE_H, + centered=(True, True, False)) + .edges("|Z").fillet(CORNER_R) + .edges(">Z").chamfer(TOP_CHAMFER) + ) + + def _lip_cut(self): + """Stacking lip: the mirrored base profile lofted around the rim.""" + w, d = self.outer_w, self.outer_d + return ( + cq.Workplane("XY") + .transformed(offset=(0, 0, self.nominal_h)) + .rect(w - 2 * _INSET_BOT, d - 2 * _INSET_BOT) + .workplane(offset=TAPER_1) + .rect(w - 2 * _INSET_MID, d - 2 * _INSET_MID) + .workplane(offset=RISER) + .rect(w - 2 * _INSET_MID, d - 2 * _INSET_MID) + .workplane(offset=TAPER_2) + .rect(w, d) + .loft(ruled=True) + ) + + def _pocket_cut(self, p): + wp = cq.Workplane("XY").workplane(offset=self.total_h - p.depth) + return _rounded_rect(wp, p.length, p.width, p.radii, + p.center).extrude(p.depth + 1) + + def _polygon_cut(self, p): + wp = (cq.Workplane("XY").workplane(offset=self.total_h - p.depth) + .polyline(p.points).close()) + if p.clearance > 0: + wp = wp.offset2D(p.clearance) + return wp.extrude(p.depth + 1) + + def _cylinder_drop(self, p): + """Vertical span of the bore's cylindrical part.""" + return p.depth - (p.diameter / 2 if p.round_bottom else 0) + + def _cylinder_cut(self, p): + r = p.diameter / 2 + t = math.radians(p.tilt) + axis_len = self._cylinder_drop(p) / math.cos(t) + top_extra = 5.0 # reach above the rim so the cut clears the chamfer + if p.round_bottom: + # Single revolved capsule: a cylinder unioned with a tangent + # sphere leaves a degenerate seam that breaks watertightness. + k = r * math.sin(math.radians(45)) + cutter = ( + cq.Workplane("XZ") + .moveTo(0, top_extra) + .lineTo(r, top_extra) + .lineTo(r, -axis_len) + .threePointArc((k, -axis_len - k), (0, -axis_len - r)) + .close() + .revolve(360, (0, 0), (0, 1)) + ) + else: + cutter = (cq.Workplane("XY", origin=(0, 0, top_extra)) + .circle(r).extrude(-(axis_len + top_extra))) + if p.tilt: + # Rotate about the opening center; axis (dy, -dx) swings the + # bottom tip toward tilt_dir. + dx, dy = p.tilt_dir + cutter = cutter.rotate((0, 0, 0), (dy, -dx, 0), p.tilt) + return cutter.translate((p.center[0], p.center[1], self.total_h)) + + def _compartment_solids(self): + """Cutters (cavities) and additions (scoops, label shelves).""" + c = self._compartments + comp_w, comp_d, interior_w, interior_d = self._compartment_size() + # Interior corner radius floors at 0.5mm: with dividers thicker + # than the body fillet the interior corners just get near-sharp, + # which is valid geometry (this is a choice, not a repair). + pocket_r = max(0.5, CORNER_R - c.wall_t) + + cutters, adds = [], [] + for col in range(c.cols): + for row in range(c.rows): + cx = -interior_w / 2 + col * (comp_w + c.wall_t) + comp_w / 2 + cy = -interior_d / 2 + row * (comp_d + c.wall_t) + comp_d / 2 + wp = cq.Workplane("XY").workplane(offset=self.floor_z) + cutters.append( + _rounded_rect(wp, comp_w, comp_d, (pocket_r,) * 4, + (cx, cy)).extrude(self.max_depth + 1)) + if c.scoop_r > 0: + adds.append(self._scoop(cx, cy - comp_d / 2, comp_w, + c.scoop_r)) + if c.label_tab: + for row in range(c.rows): + y_back = -interior_d / 2 + row * (comp_d + c.wall_t) + comp_d + adds.append(self._label_shelf(y_back, interior_w, c.label_d)) + return cutters, adds + + def _scoop(self, cx, y_front, span, r): + """Concave quarter-round ramp along a compartment's front floor edge.""" + block = ( + cq.Workplane("XY") + .box(span, r, r, centered=(True, False, False)) + .translate((cx, y_front, self.floor_z)) + ) + cyl = cq.Solid.makeCylinder( + r, span, + cq.Vector(cx - span / 2, y_front + r, self.floor_z + r), + cq.Vector(1, 0, 0)) + return block.cut(cyl) + + def _label_shelf(self, y_back, span, label_d): + """45-degree triangular label shelf hanging from a back wall.""" + z_top = self.nominal_h + pts = [(y_back, z_top), (y_back - label_d, z_top), + (y_back, z_top - label_d)] + return ( + cq.Workplane("YZ", origin=(-span / 2, 0, 0)) + .polyline(pts).close() + .extrude(span) + ) + + def _notch_cut(self, n): + w = n.width + z_bot = self.total_h - n.depth + length = (self.outer_w if n.side in ("+X", "-X") + else self.outer_d) / 2 + 1 + cutter = ( + cq.Workplane("YZ") + .moveTo(-w / 2, self.total_h + 1) + .lineTo(-w / 2, z_bot + w / 2) + .threePointArc((0, z_bot), (w / 2, z_bot + w / 2)) + .lineTo(w / 2, self.total_h + 1) + .close() + .extrude(length) + ) + angle = {"+X": 0, "+Y": 90, "-X": 180, "-Y": 270}[n.side] + if angle: + cutter = cutter.rotate((0, 0, 0), (0, 0, 1), angle) + if n.offset: + # Slide along the wall: the +-X walls span Y, +-Y walls span X. + shift = ((0, n.offset, 0) if n.side in ("+X", "-X") + else (n.offset, 0, 0)) + cutter = cutter.translate(shift) + return cutter + + def _base_hole_cutter(self): + """Magnet bores and/or screw holes, 4 per cell on a 26mm square.""" + half = HOLE_SPACING / 2 + positions = [(cx + dx, cy + dy) + for cx, cy in self._cell_centers() + for dx in (-half, half) for dy in (-half, half)] + cutters = [] + for enabled, dia, depth in ((self.magnets, MAGNET_D, MAGNET_DEPTH), + (self.screws, SCREW_D, SCREW_DEPTH)): + if enabled: + cutters.append(cq.Workplane("XY").pushPoints(positions) + .circle(dia / 2).extrude(depth)) + # Magnet bores and screw holes are concentric counterbores; + # _fused detects the overlap and fuses them into one tool. + return _fused(cutters) + + def summary(self): + """One-paragraph description of the built dimensions.""" + lip = f" + {BASE_H}mm lip" if self.stacking_lip else "" + return (f"Gridfinity {self.grid_x}x{self.grid_y} bin, " + f"{self.height_units}U ({self.nominal_h:.0f}mm{lip}): " + f"{self.outer_w:.1f} x {self.outer_d:.1f} x " + f"{self.total_h:.2f}mm, interior floor at Z=" + f"{self.floor_z:.2f}mm, max cavity depth " + f"{self.max_depth:.2f}mm") diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/mesh_io.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/mesh_io.py new file mode 100644 index 0000000..2336c54 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/mesh_io.py @@ -0,0 +1,35 @@ +"""Pure trimesh STL loading with validation guards. + +Kept separate from preview.py so consumers that only need mesh loading +(stl_to_3mf.py, run_cadquery_model.py's --strict watertight check) don't +pay the pyrender + PyOpenGL import cost. Only depends on trimesh + numpy. +""" +import numpy as np +import trimesh + + +def load_mesh(path): + """Load an STL file via trimesh. + + Raises ValueError if the file cannot be parsed, contains no geometry, + has zero faces, or has non-finite vertex coordinates. Callers handle + the failure in-process instead of being killed by sys.exit, and silent + garbage (zero-face or NaN meshes) is stopped before it reaches pyrender. + """ + try: + tm = trimesh.load(path, force="mesh") + except Exception as e: + raise ValueError(f"Failed to load STL: {e}") from e + if not hasattr(tm, "vertices") or len(tm.vertices) == 0: + raise ValueError("STL file contains no vertices") + if not hasattr(tm, "faces") or len(tm.faces) == 0: + raise ValueError("STL file contains no triangles") + if not np.isfinite(tm.vertices).all(): + raise ValueError("STL file has non-finite vertex coordinates (NaN or inf)") + # OCC's tessellator emits zero-area triangles at the poles of + # spherical faces (and similar degenerate spots). They carry no + # surface, but their zero-length open edges make an otherwise + # closed mesh read as non-watertight. Drop them before any checks. + tm.update_faces(tm.nondegenerate_faces()) + tm.merge_vertices() + return tm diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/outline_from_scan.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/outline_from_scan.py new file mode 100644 index 0000000..3da19a7 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/outline_from_scan.py @@ -0,0 +1,123 @@ +"""Extract a 2D pocket outline from a 3D scan of an object. + +Produces the max cross-section of the object's bottom `--zmax` mm (the +region a Gridfinity pocket wraps), as a point list ready to paste into +`GridfinityBin.add_polygon_pocket`. Pipeline: PCA-align the mesh in XY +(long axis to X), drop it to Z=0, optionally scale anisotropically to +known real dims, slice every millimeter, union the sections, simplify. + +Example (MX Master 3 cradle from robomo's scan of the mouse): + python3 outline_from_scan.py scan.stl --dims 124.9x84.3x51 \ + --zmax 19 --out outline.json + +With --rotate-min-square the outline is rotated to the angle that +minimizes its bounding square (diagonal placement in a smaller grid) +and the chosen angle is reported. + +Deps: trimesh, shapely, numpy (pip install shapely if missing). +""" + +import argparse +import json + +import numpy as np +import trimesh +from shapely import affinity +from shapely.geometry import Polygon +from shapely.ops import unary_union + + +def align_and_scale(mesh, dims): + """PCA-align in XY, center on the origin, floor to Z=0, scale.""" + xy = mesh.vertices[:, :2] - mesh.vertices[:, :2].mean(axis=0) + evals, evecs = np.linalg.eigh(np.cov(xy.T)) + major = evecs[:, np.argmax(evals)] + mesh.apply_transform(trimesh.transformations.rotation_matrix( + -np.arctan2(major[1], major[0]), [0, 0, 1])) + mesh.apply_translation([-(mesh.bounds[0][0] + mesh.bounds[1][0]) / 2, + -(mesh.bounds[0][1] + mesh.bounds[1][1]) / 2, + -mesh.bounds[0][2]]) + if dims: + mesh.apply_scale([d / e for d, e in zip(dims, mesh.extents)]) + return mesh + + +def max_section_outline(mesh, zmax, step=1.0, simplify=0.25): + """Union of horizontal sections from Z=step/2 up to zmax.""" + polys = [] + for z in np.arange(step / 2, zmax + 0.01, step): + sec = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1]) + if sec is None: + continue + for loop in sec.discrete: + if len(loop) < 10: + continue + p = Polygon(loop[:, :2]) + if p.is_valid and p.area > 500: + polys.append(p) + u = unary_union(polys) + if u.geom_type == "MultiPolygon": + u = max(u.geoms, key=lambda g: g.area) + return Polygon(u.exterior.coords).simplify(simplify) + + +def rotate_min_square(poly, clearance): + """Angle (deg) minimizing the bounding square of poly + clearance.""" + buf = poly.buffer(clearance, join_style=1) + best = None + for tenth in range(0, 1800): + a = tenth / 10 + b = affinity.rotate(buf, a, origin="centroid").bounds + m = max(b[2] - b[0], b[3] - b[1]) + if best is None or m < best[1]: + best = (a, m) + return best + + +def recentered(poly): + minx, miny, maxx, maxy = poly.bounds + return affinity.translate(poly, -(minx + maxx) / 2, -(miny + maxy) / 2) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("mesh", help="STL/mesh scan of the object") + ap.add_argument("--dims", help="real LxWxH in mm, e.g. 128.2x88.4x50.8; " + "omit to trust the scan's scale") + ap.add_argument("--zmax", type=float, required=True, + help="pocket depth: outline covers Z 0..zmax mm") + ap.add_argument("--step", type=float, default=1.0) + ap.add_argument("--simplify", type=float, default=0.25) + ap.add_argument("--rotate-min-square", action="store_true", + help="rotate outline to minimize its bounding square") + ap.add_argument("--clearance", type=float, default=1.3, + help="pocket clearance used for the rotation search") + ap.add_argument("--out", help="write points as JSON (default: stdout)") + args = ap.parse_args() + + dims = ([float(v) for v in args.dims.split("x")] if args.dims else None) + mesh = align_and_scale(trimesh.load(args.mesh), dims) + print(f"aligned extents: {np.round(mesh.extents, 2)}") + + outline = recentered(max_section_outline(mesh, args.zmax, args.step, + args.simplify)) + if args.rotate_min_square: + angle, side = rotate_min_square(outline, args.clearance) + outline = recentered(affinity.rotate(outline, angle, + origin="centroid")) + print(f"rotated {angle} deg; bounding square incl. clearance: " + f"{side:.1f}mm") + + pts = [[round(x, 2), round(y, 2)] + for x, y in list(outline.exterior.coords)[:-1]] + minx, miny, maxx, maxy = outline.bounds + print(f"{len(pts)} points, bbox {maxx - minx:.1f} x {maxy - miny:.1f} mm") + if args.out: + json.dump(pts, open(args.out, "w")) + print(f"written: {args.out}") + else: + print(json.dumps(pts)) + + +if __name__ == "__main__": + main() diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/preview.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/preview.py new file mode 100644 index 0000000..2baefeb --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/preview.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +""" +Render preview images of a CadQuery model for visual inspection. + +Usage: + python3 preview.py model.stl [output.png] + python3 preview.py model.stl --views multi # 6-view technical sheet + python3 preview.py model.stl --views iso # single isometric + python3 preview.py model.stl --resolution 800 # higher-res per view + +Dependencies: + pip install trimesh pyrender Pillow +""" +import sys +import os +import argparse +import math +import numpy as np + +# Pyrender needs an OpenGL context for offscreen rendering. +# On Linux set PYOPENGL_PLATFORM=egl (GPU) or osmesa (CPU) before import. +# On macOS the default CGL/pyglet backend works — do NOT set egl/osmesa. +import platform as _plat +if _plat.system() == "Linux" and "PYOPENGL_PLATFORM" not in os.environ: + os.environ["PYOPENGL_PLATFORM"] = "egl" + +import trimesh +import pyrender +from PIL import Image, ImageDraw, ImageFont + +from mesh_io import load_mesh # re-exported: preview's public surface for mesh loading + + +# --------------------------------------------------------------------------- +# Scene + camera helpers +# --------------------------------------------------------------------------- + +def _rotation_matrix(elev_deg, azim_deg): + """Build a camera-pose matrix from elevation and azimuth (degrees). + + Convention: + azim = rotation around Z (world up) + elev = rotation above the XY plane + Returns a 4x4 camera pose (OpenGL: -Z forward, +Y up). + """ + elev = math.radians(elev_deg) + azim = math.radians(azim_deg) + + # Camera position on a unit sphere + cx = math.cos(elev) * math.cos(azim) + cy = math.cos(elev) * math.sin(azim) + cz = math.sin(elev) + eye = np.array([cx, cy, cz]) + + # Look-at + target = np.array([0.0, 0.0, 0.0]) + up = np.array([0.0, 0.0, 1.0]) + + forward = target - eye + forward /= np.linalg.norm(forward) + right = np.cross(forward, up) + if np.linalg.norm(right) < 1e-6: + # Degenerate case (looking straight down/up) + up = np.array([0.0, 1.0, 0.0]) + right = np.cross(forward, up) + right /= np.linalg.norm(right) + cam_up = np.cross(right, forward) + + # Build OpenGL camera matrix (-Z forward) + pose = np.eye(4) + pose[0:3, 0] = right + pose[0:3, 1] = cam_up + pose[0:3, 2] = -forward + pose[0:3, 3] = eye + return pose + + +def _nice_spacing(extent): + """Pick a human-friendly grid spacing that gives ~8-12 lines.""" + target = extent / 10 + for s in [1, 2, 5, 10, 20, 50, 100, 200]: + if s >= target: + return s + return 200 + + +def _build_grid(tm): + """Create a ground-plane grid mesh at the bottom of the object. + + Returns (ground_plane_trimesh, grid_lines_trimesh). + """ + bounds = tm.bounds # shape (2, 3): [[min_x,y,z], [max_x,y,z]] + z_floor = bounds[0][2] + cx, cy = tm.bounding_box.centroid[:2] + + extent = max(bounds[1][0] - bounds[0][0], bounds[1][1] - bounds[0][1]) + spacing = _nice_spacing(extent) + pad = extent * 0.5 + + # Snap grid bounds to spacing multiples + x0 = math.floor((cx - extent / 2 - pad) / spacing) * spacing + x1 = math.ceil((cx + extent / 2 + pad) / spacing) * spacing + y0 = math.floor((cy - extent / 2 - pad) / spacing) * spacing + y1 = math.ceil((cy + extent / 2 + pad) / spacing) * spacing + + lw = max(0.4, spacing * 0.02) # line half-width + + # --- Ground plane (slightly below grid lines to avoid z-fighting) --- + ground = trimesh.creation.box( + extents=[x1 - x0, y1 - y0, 0.01], + transform=trimesh.transformations.translation_matrix( + [(x0 + x1) / 2, (y0 + y1) / 2, z_floor - 0.02] + ), + ) + + # --- Grid lines as thin quads at z_floor --- + verts = [] + faces = [] + + def _add_quad(v0, v1, v2, v3): + n = len(verts) + verts.extend([v0, v1, v2, v3]) + faces.extend([[n, n + 1, n + 2], [n, n + 2, n + 3]]) + + # Lines parallel to X axis (one per Y tick) + y = y0 + while y <= y1 + 0.001: + _add_quad( + [x0, y - lw, z_floor], [x1, y - lw, z_floor], + [x1, y + lw, z_floor], [x0, y + lw, z_floor], + ) + y += spacing + + # Lines parallel to Y axis (one per X tick) + x = x0 + while x <= x1 + 0.001: + _add_quad( + [x - lw, y0, z_floor], [x + lw, y0, z_floor], + [x + lw, y1, z_floor], [x - lw, y1, z_floor], + ) + x += spacing + + grid_lines = trimesh.Trimesh( + vertices=np.array(verts), faces=np.array(faces) + ) + return ground, grid_lines + + +def _build_scene(tm, include_ground=True): + """Create a pyrender scene containing the mesh, lights, and optionally ground. + + Returns (scene, bounding_sphere_radius, center, ground_node, grid_node). + ground_node/grid_node are None if include_ground is False. + """ + # Compute smooth vertex normals so Phong shading looks good + tm.fix_normals() + + mesh = pyrender.Mesh.from_trimesh( + tm, + smooth=True, + material=pyrender.MetallicRoughnessMaterial( + baseColorFactor=[0.42, 0.62, 0.92, 1.0], # medium blue + metallicFactor=0.1, + roughnessFactor=0.6, + doubleSided=True, + ), + ) + + scene = pyrender.Scene( + bg_color=[0.90, 0.90, 0.92, 1.0], # neutral light gray + ambient_light=[0.3, 0.3, 0.3], + ) + scene.add(mesh) + + # Ground grid (skipped for below-horizon views to avoid occluding the model) + ground_node = None + grid_node = None + if include_ground: + ground, grid_lines = _build_grid(tm) + ground_mat = pyrender.MetallicRoughnessMaterial( + baseColorFactor=[0.82, 0.82, 0.84, 1.0], + metallicFactor=0.0, + roughnessFactor=0.9, + ) + grid_mat = pyrender.MetallicRoughnessMaterial( + baseColorFactor=[0.68, 0.68, 0.72, 1.0], + metallicFactor=0.0, + roughnessFactor=0.9, + ) + ground_node = scene.add(pyrender.Mesh.from_trimesh(ground, material=ground_mat, smooth=False)) + grid_node = scene.add(pyrender.Mesh.from_trimesh(grid_lines, material=grid_mat, smooth=False)) + + # Four-point lighting: three from above + one from below so the + # bottom view (Z-) isn't in total shadow. + for direction in ([1, 1, 1], [-1, 0.5, 0.5], [0, -1, 1], [0, 0.5, -1]): + light = pyrender.DirectionalLight(color=[1.0, 1.0, 1.0], intensity=2.5) + d = np.array(direction, dtype=float) + d /= np.linalg.norm(d) + pose = np.eye(4) + pose[0:3, 2] = -d # pyrender light shines along -Z of its frame + scene.add(light, pose=pose) + + # Bounding sphere for camera framing + center = tm.bounding_box.centroid + radius = np.linalg.norm(tm.bounding_box.extents) / 2.0 + + return scene, radius, center, ground_node, grid_node + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + +DEFAULT_VIEW_SIZE = 600 # width/height of each sub-view (multi-view) +_SINGLE_WIDTH = 900 # single isometric view defaults +_SINGLE_HEIGHT = 750 + + +def _add_edges(color, depth, strength=0.6): + """Overlay edge lines detected from the depth buffer onto the color image.""" + valid = depth > 0 + if not valid.any(): + return color + + # Normalise depth into 0-1 range for gradient detection + d = np.zeros_like(depth) + d_min, d_max = depth[valid].min(), depth[valid].max() + if d_max - d_min > 0: + d[valid] = (depth[valid] - d_min) / (d_max - d_min) + + # Sobel-style gradient magnitude + dy = np.zeros_like(d) + dx = np.zeros_like(d) + dy[1:, :] = np.abs(d[1:, :] - d[:-1, :]) + dx[:, 1:] = np.abs(d[:, 1:] - d[:, :-1]) + edges = np.sqrt(dx ** 2 + dy ** 2) + + # Object-boundary edges (depth jumps from 0 to non-zero) + boundary = np.zeros_like(valid) + boundary[1:, :] |= (valid[1:, :] != valid[:-1, :]) + boundary[:, 1:] |= (valid[:, 1:] != valid[:, :-1]) + + # Normalise to 0-1 and apply threshold + p = np.percentile(edges[valid], 97) if valid.sum() > 100 else 1.0 + if p > 0: + edges = np.clip(edges / p, 0, 1) + edge_alpha = np.clip(edges * strength, 0, strength) + edge_alpha[boundary] = np.maximum(edge_alpha[boundary], strength * 0.8) + + # Darken colour at edge pixels + result = color.astype(np.float32) + result *= (1 - edge_alpha[:, :, np.newaxis]) + return np.clip(result, 0, 255).astype(np.uint8) + + +def _render_frame(scene, radius, center, elev, azim, renderer): + """Render one frame from an existing scene. + + Adds a camera, renders, then removes the camera so the scene can be + reused for additional views without rebuilding. + """ + yfov = math.radians(35) + cam = pyrender.PerspectiveCamera(yfov=yfov) + distance = radius / math.sin(yfov / 2) * 1.15 # slight padding + + cam_pose = _rotation_matrix(elev, azim) + cam_pose[0:3, 3] = center + cam_pose[0:3, 2] * distance + + cam_node = scene.add(cam, pose=cam_pose) + try: + color, depth = renderer.render(scene) + except Exception as e: + scene.remove_node(cam_node) + raise RuntimeError( + f"Rendering failed: {e}\n" + "On Linux without GPU, try: PYOPENGL_PLATFORM=osmesa python3 preview.py ..." + ) from e + scene.remove_node(cam_node) + + color = _add_edges(color, depth) + return Image.fromarray(color) + + +def render_view(tm, elev, azim, width=DEFAULT_VIEW_SIZE, height=DEFAULT_VIEW_SIZE): + """Render the mesh from a specific angle. Returns a PIL Image.""" + scene, radius, center, _, _ = _build_scene(tm) + renderer = None + try: + renderer = pyrender.OffscreenRenderer(width, height) + img = _render_frame(scene, radius, center, elev, azim, renderer) + finally: + if renderer is not None: + renderer.delete() + return img + + +def _get_font(size=14): + """Try to load a nice sans-serif font, falling back to PIL default.""" + candidates = [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/SFNSText.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + ] + for path in candidates: + if os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + return ImageFont.load_default() + + +def _info_lines(tm): + """Two-line footer with dimensions, volume, triangle count, and status.""" + extents = tm.bounding_box.extents + line1 = f"Bounding box: {extents[0]:.1f} x {extents[1]:.1f} x {extents[2]:.1f} mm" + try: + vol = abs(tm.volume) + line1 += f" | Volume: {vol/1000:.1f} cm\u00b3" + except Exception: + pass + + line2 = f"Triangles: {len(tm.faces):,}" + line2 += f" | {'Watertight' if tm.is_watertight else 'NOT watertight'}" + try: + vol = abs(tm.volume) + weight_g = vol / 1000 * 1.24 # PLA density ~1.24 g/cm3 + line2 += f" | PLA estimate: ~{weight_g:.0f} g" + except Exception: + pass + return line1, line2 + + +def render_single(tm, output_path, title="Model Preview", width=_SINGLE_WIDTH, height=_SINGLE_HEIGHT): + """Render a single isometric view with title and footer.""" + img = render_view(tm, elev=25, azim=-60, width=width, height=height) + + # Add title + footer + canvas = Image.new("RGB", (img.width, img.height + 100), "white") + canvas.paste(img, (0, 40)) + draw = ImageDraw.Draw(canvas) + + title_font = _get_font(20) + info_font = _get_font(13) + line1, line2 = _info_lines(tm) + + draw.text((canvas.width // 2, 10), title, fill="black", font=title_font, anchor="mt") + draw.text((canvas.width // 2, canvas.height - 30), line1, + fill="gray", font=info_font, anchor="mb") + draw.text((canvas.width // 2, canvas.height - 10), line2, + fill="gray", font=info_font, anchor="mb") + + canvas.save(output_path) + return output_path + + +def render_multi_view(tm, output_path, title="Model Preview", subtitle=None, view_size=DEFAULT_VIEW_SIZE): + """Render 6-view technical preview in a 3x2 grid. + + Views: isometric, front, right (top row), back-iso, top, bottom (bottom row). + Builds the scene once and reuses a single renderer for all views. + """ + views = [ + (25, -60, "Isometric"), + (5, -90, "Front (Y-)"), + (5, 0, "Right (X+)"), + (25, 120, "Back Isometric"), + (90, -90, "Top (Z+)"), + (-90, -90, "Bottom (Z-)"), + ] + + scene, radius, center, _, _ = _build_scene(tm, include_ground=True) + scene_bottom, _, _, _, _ = _build_scene(tm, include_ground=False) + renderer = None + try: + renderer = pyrender.OffscreenRenderer(view_size, view_size) + images = [] + for elev, azim, label in views: + s = scene_bottom if elev < 0 else scene + img = _render_frame(s, radius, center, elev, azim, renderer) + images.append((img, label)) + finally: + if renderer is not None: + renderer.delete() + + # Compose 3x2 grid + cols = 3 + rows = 2 + gap = 4 + header_h = 40 + (20 if subtitle else 0) + footer_h = 55 + label_h = 24 + w = view_size * cols + gap * (cols - 1) + h = view_size * rows + gap * (rows - 1) + header_h + footer_h + label_h * rows + + canvas = Image.new("RGB", (w, h), "white") + draw = ImageDraw.Draw(canvas) + + title_font = _get_font(20) + subtitle_font = _get_font(14) + label_font = _get_font(14) + info_font = _get_font(13) + + # Title + optional subtitle + draw.text((w // 2, 12), title, fill="black", font=title_font, anchor="mt") + if subtitle: + draw.text((w // 2, 34), subtitle, fill="#666666", font=subtitle_font, anchor="mt") + + for idx, (img, label) in enumerate(images): + col = idx % cols + row = idx // cols + px = col * (view_size + gap) + py = header_h + label_h + row * (view_size + gap + label_h) + canvas.paste(img, (px, py)) + draw.text((px + view_size // 2, py - 4), label, + fill="#444444", font=label_font, anchor="mb") + + # Footer (two lines) + line1, line2 = _info_lines(tm) + draw.text((w // 2, h - 30), line1, fill="gray", font=info_font, anchor="mb") + draw.text((w // 2, h - 10), line2, fill="gray", font=info_font, anchor="mb") + + canvas.save(output_path) + return output_path + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Preview a 3D model STL file") + parser.add_argument("stl_file", help="Path to STL file") + parser.add_argument("output", nargs="?", default=None, + help="Output PNG path (default: _preview.png)") + parser.add_argument("--views", choices=["iso", "multi"], default="multi", + help="View mode: iso (single) or multi (6-view)") + parser.add_argument("--title", default=None, help="Title for the preview") + parser.add_argument("--resolution", type=int, default=DEFAULT_VIEW_SIZE, + help=f"Pixels per view (default: {DEFAULT_VIEW_SIZE})") + parser.add_argument("--subtitle", default=None, + help="Subtitle shown below the title (e.g. model description or usage)") + parser.add_argument("--strict", action="store_true", + help="Fail with exit code 2 if the mesh is not watertight") + args = parser.parse_args() + + if args.output is None: + base = os.path.splitext(args.stl_file)[0] + args.output = f"{base}_preview.png" + + if args.title is None: + args.title = os.path.splitext(os.path.basename(args.stl_file))[0].replace("_", " ").title() + + try: + tm = load_mesh(args.stl_file) + except ValueError as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + + extents = tm.bounding_box.extents + print(f"Model: {args.stl_file}") + print(f"Bounding box: {extents[0]:.1f} x {extents[1]:.1f} x {extents[2]:.1f} mm") + print(f"Triangles: {len(tm.faces)}") + + if tm.is_watertight: + print("Mesh: watertight (good)") + else: + print("WARNING: Mesh is NOT watertight. May cause slicing issues.", + file=sys.stderr) + if args.strict: + print("ERROR: --strict set, aborting before render.", + file=sys.stderr) + sys.exit(2) + + if args.views == "multi": + render_multi_view(tm, args.output, args.title, subtitle=args.subtitle, + view_size=args.resolution) + else: + scale = args.resolution / DEFAULT_VIEW_SIZE + render_single(tm, args.output, args.title, + width=int(_SINGLE_WIDTH * scale), height=int(_SINGLE_HEIGHT * scale)) + + print(f"Preview saved: {args.output} ({os.path.getsize(args.output)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/requirements.txt b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/requirements.txt new file mode 100644 index 0000000..2bcfd59 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/requirements.txt @@ -0,0 +1,9 @@ +# Requires Python 3.10-3.12 (CadQuery's OCC kernel lacks 3.13+ wheels). +# Pinned to the versions this skill was last tested against; bump +# deliberately and re-run all example scripts + previews if you upgrade. +cadquery>=2.6.1,<3 +trimesh>=4.0,<5 +pyrender>=0.1.45,<0.2 +Pillow>=10,<13 +numpy>=1.24,<3 +lxml>=4.9,<7 diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/run_cadquery_model.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/run_cadquery_model.py new file mode 100644 index 0000000..2c1eb2c --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/run_cadquery_model.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Run a generated CadQuery model script in a subprocess and emit a structured +JSON result so Claude can parse success/failure without the user copy-pasting +tracebacks. + +Usage: + python3 run_cadquery_model.py path/to/model.py + python3 run_cadquery_model.py path/to/model.py --preview # also render + python3 run_cadquery_model.py path/to/model.py --preview --strict # fail on non-watertight + +3MF and STEP files produced by the script (via cq.exporters.export(result, +"name.3mf") / cq.exporters.export(result, "name.step")) are discovered +automatically and reported alongside the STLs. + +Emits a single JSON object to stdout (key order matches the emitted JSON): + { + "success": true/false, + "script": "model.py", + "stls": ["a.stl", "b.stl"], # every .stl written during this run + "stl": "a.stl", # newest, for single-file convenience + "previews": ["a_preview.png", ...], # one per STL when --preview is set + "preview": "a_preview.png", # newest, for single-file convenience + "threemfs": ["a.3mf", "b.3mf"], # every .3mf produced by the script + "threemf": "a.3mf", # newest, for single-file convenience + "steps": ["a.step", "b.step"], # every .step produced by the script + "step": "a.step", # newest, for single-file convenience + "watertight": true/false/null, # true only if ALL meshes are watertight + "stdout": "...", + "stderr": "...", + "returncode": 0, # -1 on timeout / spawn failure + } + +Exit codes: + 0 success + 1 CadQuery script failed, preview failed, or --strict rejected output + 2 interpreter / script path could not be launched + 3 subprocess timed out +""" +import argparse +import glob +import json +import os +import subprocess +import sys +import time + + +def _sibling(path, suffix): + return os.path.splitext(path)[0] + suffix + + +def _append_stderr(result, msg): + result["stderr"] = (result["stderr"] or "") + msg + "\n" + + +def _new_files_by_ext(script_dir, after_mtime, exts): + """Return {ext: [paths]} in script_dir matching each *.{ext} + (case-insensitive) strictly newer than after_mtime, newest first. + Dedupes case-variant hits that macOS's case-insensitive filesystem + returns from both glob patterns. + + The threshold is strict (>=) rather than with a slack window because + a backward slack would pull in stale files from a previous run started + less than a second ago, silently reporting them as this run's output. + Modern filesystems (APFS, ext4, NTFS) have sub-second mtimes, so a + file written at exactly `after_mtime` is a valid hit. + """ + buckets = {ext: {} for ext in exts} + for ext in exts: + for case in (ext.lower(), ext.upper()): + for path in glob.glob(os.path.join(script_dir, f"*.{case}")): + real = os.path.realpath(path) + if real in buckets[ext]: + continue + try: + mtime = os.path.getmtime(path) + except OSError: + continue + if mtime >= after_mtime: + buckets[ext][real] = (mtime, path) + return { + ext: [p for _, p in sorted(entries.values(), reverse=True)] + for ext, entries in buckets.items() + } + + +def _process_stls(stls, views, strict, want_preview): + """Load each STL once and optionally render a preview + check watertightness. + + Single pass so an STL is never loaded twice when both outputs are + requested, and so --strict's watertight check runs on every mesh. + + `mesh_io` is imported lazily because the wrapper's common case (bare + run, no --preview, no --strict) doesn't touch meshes at all. `preview` + is imported only inside the rendering branch so that --strict by itself + stays headless-safe (no pyrender / PyOpenGL required). + + Returns a dict with previews, watertights (lists), and error (str or None). + The caller surfaces the error into result["stderr"]. + """ + import mesh_io # trimesh + numpy only + + out = {"previews": [], "watertights": [], "error": None} + + for stl in stls: + try: + tm = mesh_io.load_mesh(stl) + except ValueError as e: + out["error"] = f"Mesh load failed ({stl}): {e}" + return out + + watertight = bool(tm.is_watertight) + out["watertights"].append(watertight) + + if strict and not watertight: + out["error"] = f"Mesh {stl} is not watertight (--strict set)." + return out + + if want_preview: + import preview # heavy: trimesh + pyrender, only here + preview_path = _sibling(stl, "_preview.png") + try: + if views == "multi": + preview.render_multi_view(tm, preview_path) + else: + preview.render_single(tm, preview_path) + except Exception as e: + out["error"] = f"Preview render failed ({stl}): {e}" + return out + out["previews"].append(preview_path) + + return out + + +def main(): + parser = argparse.ArgumentParser( + description="Run a CadQuery model script and report a JSON result", + epilog="Exit codes: 0 success, 1 script/preview/--strict failure, " + "2 interpreter not launchable, 3 timeout.", + ) + parser.add_argument("script", help="Path to the CadQuery .py file") + parser.add_argument("--preview", action="store_true", + help="Render a multi-view preview PNG for every STL the script wrote") + parser.add_argument("--strict", action="store_true", + help="Fail with exit code 1 if any STL is not watertight, " + "or if the script produced no STL at all") + parser.add_argument("--views", choices=["iso", "multi"], default="multi", + help="Preview layout: 'iso' (single isometric) or 'multi' (6-view) (default: multi)") + parser.add_argument("--timeout", type=int, default=180, + help="Seconds before killing the model script (default: 180)") + args = parser.parse_args() + + script_path = os.path.abspath(args.script) + script_dir = os.path.dirname(script_path) or "." + + result = { + "success": False, + "script": args.script, + "stls": [], + "stl": None, + "previews": [], + "preview": None, + "threemfs": [], + "threemf": None, + "steps": [], + "step": None, + "watertight": None, + "stdout": "", + "stderr": "", + "returncode": -1, + } + + started = time.time() + + try: + proc = subprocess.run( + [sys.executable, script_path], + capture_output=True, + text=True, + timeout=args.timeout, + cwd=script_dir, + ) + except subprocess.TimeoutExpired as e: + _append_stderr(result, f"Timeout after {args.timeout}s: {e}") + print(json.dumps(result, indent=2)) + sys.exit(3) + except FileNotFoundError as e: + _append_stderr(result, f"Cannot launch interpreter: {e}") + print(json.dumps(result, indent=2)) + sys.exit(2) + + result["stdout"] = proc.stdout + result["stderr"] = proc.stderr + result["returncode"] = proc.returncode + result["success"] = proc.returncode == 0 + + if result["success"]: + found = _new_files_by_ext(script_dir, started, ("stl", "3mf", "step", "stp")) + result["stls"] = found["stl"] + result["threemfs"] = found["3mf"] + # CadQuery writes .step by convention; .stp is accepted too. Both + # are merged into one list (newest-first within each extension). + result["steps"] = found["step"] + found["stp"] + + # --strict implies the run must produce at least one STL. A script that + # exits 0 but forgot to call cq.exporters.export() would otherwise slip + # through with an empty stls list and a null watertight claim. + if args.strict and result["success"] and not result["stls"]: + _append_stderr(result, "No STL files produced by the script (--strict set).") + result["success"] = False + + needs_mesh_pass = args.preview or args.strict + if needs_mesh_pass and result["success"] and result["stls"]: + processed = _process_stls( + result["stls"], args.views, args.strict, + want_preview=args.preview, + ) + result["previews"] = processed["previews"] + if processed["watertights"]: + result["watertight"] = all(processed["watertights"]) + if processed["error"]: + _append_stderr(result, processed["error"]) + result["success"] = False + + result["stl"] = result["stls"][0] if result["stls"] else None + result["preview"] = result["previews"][0] if result["previews"] else None + result["threemf"] = result["threemfs"][0] if result["threemfs"] else None + result["step"] = result["steps"][0] if result["steps"] else None + + print(json.dumps(result, indent=2)) + sys.exit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/stl_to_3mf.py b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/stl_to_3mf.py new file mode 100644 index 0000000..85240d8 --- /dev/null +++ b/plugins/hanzijie/cq-viewer/skills/parametric-3d-printing/stl_to_3mf.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Convert STL files to 3MF via trimesh (Bambu Studio / Orca / PrusaSlicer +prefer 3MF). + +WARNING: trimesh's 3MF writer mangles internal cavities. If the input STL +was generated from a CadQuery solid with a cut() cavity (magnet pockets, +enclosed voids, etc.), the cavity's normals get flipped on export and the +slicer will see the cavity as filled. For parametric CadQuery parts, +export 3MF directly from the CadQuery script via + cq.exporters.export(result, "name.3mf") +which uses CadQuery's native ThreeMFWriter and preserves cavities. This +script is only safe for solid meshes with no enclosed voids. + +Usage: + python3 stl_to_3mf.py model.stl # -> model.3mf + python3 stl_to_3mf.py a.stl b.stl c.stl # multi-file + python3 stl_to_3mf.py --out other.3mf model.stl # explicit output + +Requires trimesh + lxml (lxml is needed for the 3MF writer). +""" +import argparse +import os +import sys + + +def convert(stl_path, out_path=None): + # Lazy import of mesh_io (trimesh + numpy only, no pyrender) keeps + # `import stl_to_3mf` cheap and keeps this CLI headless-safe: the tool + # promises "trimesh + lxml" and must not pull in pyrender / PyOpenGL. + import mesh_io + + if out_path is None: + out_path = os.path.splitext(stl_path)[0] + ".3mf" + mesh = mesh_io.load_mesh(stl_path) + mesh.export(out_path) + return out_path + + +def main(): + parser = argparse.ArgumentParser( + description="Convert one or more STL files to 3MF", + epilog="Exit codes: 0 success, 1 load/export failure, 2 bad arguments.", + ) + parser.add_argument("stl_files", nargs="+", help="Path(s) to STL file(s)") + parser.add_argument("--out", default=None, + help="Output 3MF path (only valid with a single input; " + "multi-file runs write each STL to its sibling .3mf)") + args = parser.parse_args() + + if args.out and len(args.stl_files) > 1: + print("ERROR: --out only works with a single input STL", file=sys.stderr) + sys.exit(2) + + for stl in args.stl_files: + if not os.path.exists(stl): + print(f"ERROR: {stl} not found", file=sys.stderr) + sys.exit(1) + try: + out = convert(stl, args.out) + except ValueError as e: + print(f"ERROR: {stl}: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"ERROR: failed to write 3MF for {stl}: {e}", file=sys.stderr) + sys.exit(1) + print(f"{stl} -> {out}") + + +if __name__ == "__main__": + main() diff --git a/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs b/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs new file mode 100644 index 0000000..945e46e --- /dev/null +++ b/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs @@ -0,0 +1,264 @@ +// Run: node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs +// Starts the real Node entry against a throw-away HOME and dataDir, then drives the MCP endpoint. + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url)); +// a minimal binary STL: 80-byte header, one triangle +const STL = Buffer.concat([Buffer.alloc(80), Buffer.from([1, 0, 0, 0]), Buffer.alloc(50)]); + +let home = ''; +let outside = ''; +let port = 0; +let lifecycle = /** @type {{dispose(): Promise|void} | null} */ (null); +let rpcId = 0; + +before(async () => { + home = await realpath(await mkdtemp(join(tmpdir(), 'cqv-home-'))); + outside = await realpath(await mkdtemp(join(tmpdir(), 'cqv-outside-'))); + process.env.HOME = home; // read by os.homedir() when server.mjs loads + process.env.USERPROFILE = home; + await mkdir(join(home, 'proj', 'parts', 'deep'), { recursive: true }); + await mkdir(join(home, 'loose'), { recursive: true }); + await writeFile(join(home, 'proj', 'bracket.stl'), STL); + await writeFile(join(home, 'proj', 'parts', 'deep', 'pin.step'), 'ISO-10303-21;\n'); + await writeFile(join(home, 'loose', 'cover.stl'), STL); + await writeFile(join(outside, 'scratch part.stl'), STL); + port = await freePort(); + const { start } = await import(new URL('../miniapp/node/server.mjs', import.meta.url).href); + const noop = () => {}; + lifecycle = await start({ + pluginId: 'cq-viewer', + pluginRoot: PLUGIN_ROOT, + dataDir: join(home, 'data'), + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { debug: noop, info: noop, warn: noop, error: noop }, + }); +}); + +after(async () => { + await lifecycle?.dispose(); + await rm(home, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); +}); + +test('initialize and tools/list', async () => { + const init = await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } }); + assert.equal(init.result.serverInfo.name, 'cq-viewer'); + assert.equal(init.result.protocolVersion, '2025-06-18'); + assert.ok(init.result.capabilities.tools); + const list = await rpc('tools/list'); + assert.deepEqual( + list.result.tools.map((t) => t.name), + ['add_model_folder', 'add_model_file', 'list_model_folders'], + ); + const note = await post({ jsonrpc: '2.0', method: 'notifications/initialized' }); + assert.equal(note.status, 202); +}); + +test('add_model_folder registers the folder, lists models, and queues a select request', async () => { + const out = await call('add_model_folder', { path: '~/proj' }); + assert.equal(out.ok, true); + assert.equal(out.folder.path, join(home, 'proj')); + assert.equal(out.model_count, 2); + assert.deepEqual(out.models.map((m) => m.path).sort(), [join(home, 'proj', 'bracket.stl'), join(home, 'proj', 'parts', 'deep', 'pin.step')]); + assert.equal(out.viewer_connected, false); + const roots = JSON.parse(await readFile(join(home, '.cqview', 'roots.json'), 'utf8')); + assert.equal(roots.roots[0].path, join(home, 'proj')); + const reqs = await getJson('/viewer/api/requests'); + const last = reqs.items.at(-1); + assert.equal(last.type, 'folder'); + assert.equal(last.root, out.folder.id); +}); + +test('add_model_folder rejects home itself, hidden top-level folders, and missing folders', async () => { + for (const path of ['~', join(home, 'nope')]) { + const out = await call('add_model_folder', { path }); + assert.equal(out.ok, false, path); + } + await mkdir(join(home, '.secret'), { recursive: true }); + const hidden = await call('add_model_folder', { path: join(home, '.secret') }); + assert.equal(hidden.ok, false); +}); + +test('add_model_file opens in place through the closest registered folder', async () => { + const out = await call('add_model_file', { path: join(home, 'proj', 'parts', 'deep', 'pin.step') }); + assert.equal(out.ok, true); + assert.equal(out.mode, 'reference'); + assert.equal(out.folder.path, join(home, 'proj')); + assert.equal(out.rel, 'parts/deep/pin.step'); + assert.equal(out.kind, 'step'); + const reqs = await getJson('/viewer/api/requests'); + assert.deepEqual( + { type: reqs.items.at(-1).type, root: reqs.items.at(-1).root, rel: reqs.items.at(-1).rel }, + { type: 'file', root: out.folder.id, rel: 'parts/deep/pin.step' }, + ); + // the page can read files of the folder it was pointed at + const ok = await fetchRaw(`/viewer/api/file?root=${out.folder.id}&rel=bracket.stl`); + assert.equal(ok.status, 200); +}); + +test('add_model_file registers the parent folder when no registered folder covers the file', async () => { + const out = await call('add_model_file', { path: '~/loose/cover.stl' }); + assert.equal(out.ok, true); + assert.equal(out.folder.path, join(home, 'loose')); + assert.equal(out.rel, 'cover.stl'); + const list = await call('list_model_folders', {}); + assert.deepEqual(list.folders.map((f) => f.path), [join(home, 'loose'), join(home, 'proj')]); +}); + +test('add_model_file outside home needs copy_to_scene, which copies into the scene with colour', async () => { + const src = join(outside, 'scratch part.stl'); + const refused = await call('add_model_file', { path: src }); + assert.equal(refused.ok, false); + assert.equal(refused.error.code, 'outside_home'); + + const copied = await call('add_model_file', { path: src, copy_to_scene: true, color: '#4F8CFF', opacity: 0.5 }); + assert.equal(copied.ok, true); + assert.equal(copied.mode, 'copy'); + assert.equal(copied.name, 'scratch_part'); + assert.equal(copied.scene_file, join(home, '.cqview', 'scene', 'scratch_part.stl')); + assert.deepEqual(await readFile(copied.scene_file), STL); + const meta = JSON.parse(await readFile(join(home, '.cqview', 'scene', 'scene.json'), 'utf8')); + assert.deepEqual(meta.parts.scratch_part, { color: '#4f8cff', opacity: 0.5 }); + const scene = await getJson('/viewer/api/scene'); + assert.deepEqual(scene.parts.map((p) => p.name), ['scratch_part']); + + // pushing again without colour clears it, like cqview.show_file() + await call('add_model_file', { path: src, copy_to_scene: true }); + const meta2 = JSON.parse(await readFile(join(home, '.cqview', 'scene', 'scene.json'), 'utf8')); + assert.equal(meta2.parts.scratch_part, undefined); + + const reqs = await getJson('/viewer/api/requests'); + assert.equal(reqs.items.at(-1).type, 'scene'); + assert.equal(reqs.items.at(-1).name, 'scratch_part'); +}); + +test('list_model_folders with a folder lists its models; scene is reported', async () => { + const byPath = await call('list_model_folders', { folder: '~/proj' }); + assert.equal(byPath.ok, true); + assert.equal(byPath.model_count, 2); + const byId = await call('list_model_folders', { folder: byPath.folder.id }); + assert.equal(byId.folder.path, join(home, 'proj')); + const all = await call('list_model_folders', {}); + assert.equal(all.scene.models[0].name, 'scratch_part'); + const missing = await call('list_model_folders', { folder: outside }); + assert.equal(missing.ok, false); + assert.equal(missing.error.code, 'root_not_registered'); +}); + +test('argument and file-type errors come back as tool errors', async () => { + const bad = await call('add_model_file', { path: join(home, 'proj', 'bracket.stl'), color: 'red' }); + assert.equal(bad.ok, false); + assert.equal(bad.error.code, 'invalid_arguments'); + const extra = await call('add_model_folder', { path: '~/proj', bogus: 1 }); + assert.equal(extra.error.code, 'invalid_arguments'); + await writeFile(join(home, 'proj', 'notes.txt'), 'x'); + const txt = await call('add_model_file', { path: join(home, 'proj', 'notes.txt') }); + assert.equal(txt.error.code, 'unsupported_type'); + const copyOnly = await call('add_model_file', { path: join(home, 'proj', 'bracket.stl'), name: 'x' }); + assert.equal(copyOnly.error.code, 'copy_only_option'); + const unknown = await rpc('tools/call', { name: 'rm_rf', arguments: {} }); + assert.equal(unknown.error.code, -32602); +}); + +test('the MCP endpoint refuses non-loopback Host/Origin, GET, and non-JSON bodies', async () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }); + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { host: 'evil.example.com' })).status, 403); + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: 'https://evil.example.com' })).status, 403); + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: 'http://127.0.0.1:1234' })).status, 200); + assert.equal((await raw('GET', '/mcp/cq-viewer', undefined, {})).status, 405); + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { 'content-type': 'text/plain' })).status, 415); +}); + +test('requests survive a restart and keep their sequence', async () => { + const before = await getJson('/viewer/api/requests'); + assert.ok(before.epoch && before.seq >= 4); + await lifecycle?.dispose(); + const { start } = await import(new URL('../miniapp/node/server.mjs', import.meta.url).href); + const noop = () => {}; + lifecycle = await start({ + pluginId: 'cq-viewer', + pluginRoot: PLUGIN_ROOT, + dataDir: join(home, 'data'), + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { debug: noop, info: noop, warn: noop, error: noop }, + }); + const after = await getJson('/viewer/api/requests'); + assert.equal(after.epoch, before.epoch); + assert.equal(after.seq, before.seq); + await call('add_model_folder', { path: '~/proj', select: true }); + assert.equal((await getJson('/viewer/api/requests')).seq, before.seq + 1); + await call('add_model_folder', { path: '~/proj', select: false }); + assert.equal((await getJson('/viewer/api/requests')).seq, before.seq + 1); +}); + +// ---------------------------------------------------------------- helpers + +async function call(name, args) { + const reply = await rpc('tools/call', { name, arguments: args }); + assert.ok(reply.result, JSON.stringify(reply)); + return reply.result.structuredContent; +} + +async function rpc(method, params) { + const res = await post({ jsonrpc: '2.0', id: ++rpcId, method, ...(params ? { params } : {}) }); + assert.equal(res.status, 200, res.text); + return JSON.parse(res.text); +} + +function post(payload) { + return raw('POST', '/mcp/cq-viewer', JSON.stringify(payload), {}); +} + +async function getJson(path) { + const res = await fetchRaw(path); + assert.equal(res.status, 200, res.text); + return JSON.parse(res.text); +} + +function fetchRaw(path) { + return raw('GET', path, undefined, {}); +} + +/** @returns {Promise<{status: number, text: string}>} */ +function raw(method, path, body, headers) { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: '127.0.0.1', + port, + method, + path, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8') })); + }, + ); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +function freePort() { + return new Promise((resolve, reject) => { + const s = createServer(); + s.once('error', reject); + s.listen(0, '127.0.0.1', () => { + const { port: p } = /** @type {import('node:net').AddressInfo} */ (s.address()); + s.close(() => resolve(p)); + }); + }); +}