From 97d027173d48b1f8682e603be0625b3537febfd7 Mon Sep 17 00:00:00 2001 From: minimax Date: Thu, 22 Oct 2026 22:33:54 +0800 Subject: [PATCH] cq-viewer 1.4.0: white-model render, conversation folders, orbit around the pressed point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CadQuery Viewer - White-model render: capture the visible models as a clay-white picture cropped to the model; a background Agent turn renders it with MiniMax image generation (image-to-image). Styles, aspect ratio, part colours, the user's own description; progress, approvals relayed to the page (never auto-allowed), compare / copy / download / retry. - Conversation folders: the page reads the conversation it is open in (agent.session(), agentAccess "session.read") and lists every model in that folder, grouped by folder with the extra folders and ~/.cqview/scene; nothing opens until ticked; nothing is listed outside a conversation. Annotations, renders, and AI open requests are kept per conversation folder; MCP tools take a `workspace` argument. - Navigation: pressing on the model makes that point the orbit centre without moving the picture; trackpad (two-finger pan, pinch zoom) and mouse (wheel zoom, right/middle pan) mappings, detected per scroll or fixed in the new 操作方式 popover. - The page shows the plugin icon instead of the old cube logo. - Tests: render.test.mjs (10), workspace.test.mjs (6); 35 in total. Repository contract - agentAccess may also contain "session.read" (MiniMax Code 3.1.4 or later, observed on 3.1.4-preview): validator, minimum-version table, docs/agent.md, docs/package-contract.md, and the test updated. --- README.md | 2 +- README.zh-CN.md | 2 +- docs/agent.md | 6 + docs/package-contract.md | 8 +- .../cq-viewer/.minimax-plugin/plugin.json | 7 +- plugins/hanzijie/cq-viewer/README.md | 84 +- plugins/hanzijie/cq-viewer/README.zh-CN.md | 84 +- .../cq-viewer/miniapp/client/assets/app.js | 964 ++++++++++++++---- .../miniapp/client/assets/host-bridge.js | 2 +- .../cq-viewer/miniapp/client/assets/logo.png | Bin 0 -> 15346 bytes .../cq-viewer/miniapp/client/assets/logo.svg | 90 -- .../miniapp/client/assets/render-panel.js | 631 ++++++++++++ .../cq-viewer/miniapp/client/assets/style.css | 632 +++++++++++- .../cq-viewer/miniapp/client/index.html | 126 ++- .../hanzijie/cq-viewer/miniapp/miniapp.json | 7 +- .../hanzijie/cq-viewer/miniapp/node/mcp.mjs | 32 +- .../cq-viewer/miniapp/node/miniapp-api.d.ts | 402 +++++++- .../cq-viewer/miniapp/node/render.mjs | 607 +++++++++++ .../cq-viewer/miniapp/node/server.mjs | 457 +++++++-- .../skills/parametric-3d-printing/SKILL.md | 10 +- .../hanzijie/cq-viewer/tests/render.test.mjs | 348 +++++++ .../cq-viewer/tests/workspace.test.mjs | 225 ++++ scripts/lib/contract.mjs | 4 +- scripts/lib/rules/miniapp.mjs | 1 + test/miniapp.test.mjs | 7 +- 25 files changed, 4257 insertions(+), 481 deletions(-) create mode 100644 plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.png delete mode 100644 plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.svg create mode 100644 plugins/hanzijie/cq-viewer/miniapp/client/assets/render-panel.js create mode 100644 plugins/hanzijie/cq-viewer/miniapp/node/render.mjs create mode 100644 plugins/hanzijie/cq-viewer/tests/render.test.mjs create mode 100644 plugins/hanzijie/cq-viewer/tests/workspace.test.mjs diff --git a/README.md b/README.md index dbfa0887..9298aab0 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Pomodoro Sit Reminder](plugins/1602winxp/pomodoro-sit-timer/) | A Pomodoro timer that only counts: an in-page sit reminder and sound when a segment ends, with no timer lock and no enforced rest length; reminders from a single file or a folder in sequence or shuffled, with single-track loop and loudness matching | [1602WinXP](https://github.com/1602WinXP) | | [灵签易占 / 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; 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) | +| [CadQuery Viewer](plugins/hanzijie/cq-viewer/) | Opened in a conversation, lists the STL / STEP models in that conversation's folder (STEP keeps assembly part names, colours, and real CAD edges) and orbits around whatever point you press on; watch models pushed from CadQuery scripts, and mark positions on the model to send to the conversation in one click; one more click has a background Agent render the current view's white model into a finished product image with MiniMax image generation, following your own description; 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) | | [MiniMax Bot](plugins/minimax-ai/minimax-bot/) | **Official plugin** that turns MiniMax Code into an always-on team of Bots: a Chief Bot builds the team, group chats hand work between members with `@` mentions, and every Bot has its own memory, scheduled tasks, and usage stats. Requires MiniMax Code 3.1.2 or later | [MiniMax-AI](https://github.com/MiniMax-AI) | | [心屿 / Xinyu Island](plugins/yujiachen-y/xinyu-daily/) | A cozy daily-mood island that plays like an indie game: an inspiration card shaped by your answers, mood stickers, a joy jar, a decision slot machine, a Book of Answers, and 20 hidden secrets guarded by a grumpy crab. Optionally, Xiaoyu writes you a daily letter and chats with you using your model. Requires MiniMax Code 3.1.2 or later | [yujiachen-y](https://github.com/yujiachen-y) | | [MoMo Collab](plugins/amszuidas/momo-collab/) | A project steward that turns stray thoughts, screenshots, and links into ideas, writes clear ideas up as tasks, runs each task in its own Agent session with a full handoff, checks the result against your done criteria, and tracks everything on a board. Requires MiniMax Code 3.1.2 or later | [amszuidas](https://github.com/amszuidas) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 581a6be3..17946852 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,7 +68,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [番茄钟 · 久坐提醒](plugins/1602winxp/pomodoro-sit-timer/README.zh-CN.md) | 只计时的番茄钟:工作段结束在页面内弹出久坐提醒并响铃,不锁状态、不限制休息时长;提醒音支持单文件或文件夹、顺序/随机播放、单曲循环与响度匹配 | [1602WinXP](https://github.com/1602WinXP) | | [灵签易占](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;AI 可通过 MCP 工具添加模型文件夹、打开模型,并内置 CadQuery 参数化建模 skill | [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) | +| [CadQuery Viewer](plugins/hanzijie/cq-viewer/README.zh-CN.md) | 在对话里打开时列出对话所在文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),按住哪里就绕哪里旋转;显示 CadQuery 脚本推送的模型,在模型上标注位置后一键发给 AI;再点一下,后台 Agent 用 MiniMax 图像生成把当前视角的白模渲染成成品图,可补充自己的描述;AI 可通过 MCP 工具添加模型文件夹、打开模型,并内置 CadQuery 参数化建模 skill | [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) | | [MiniMax Bot](plugins/minimax-ai/minimax-bot/) | 【官方插件】把 MiniMax Code 变成常驻的 Bot 团队:总管 Bot 帮你组建团队,群聊里用 `@` 在成员之间交接工作,每个 Bot 有自己的记忆、定时任务和用量统计;需要 MiniMax Code 3.1.2 或更高版本(说明文档为英文) | [MiniMax-AI](https://github.com/MiniMax-AI) | | [心屿](plugins/yujiachen-y/xinyu-daily/README.zh-CN.md) | 像独立小游戏的每日心情小岛:由你的回答决定的今日灵感卡、心情贴纸、小确幸罐子、纠结老虎机、答案之书,还有坏脾气螃蟹藏起来的 20 个秘密;可选开启小屿陪伴,借用你的模型每天写信、陪你聊天。需要 MiniMax Code 3.1.2 或更高版本 | [yujiachen-y](https://github.com/yujiachen-y) | | [协作 · MoMo](plugins/amszuidas/momo-collab/README.zh-CN.md) | 项目管家 MoMo:把随手的想法、截图和链接攒成想法,写清后变成任务,派 Agent 会话带着完整背景去执行,按验收标准核对结果后交你审核,进展在看板上一眼可见。需要 MiniMax Code 3.1.2 或更高版本 | [amszuidas](https://github.com/amszuidas) | diff --git a/docs/agent.md b/docs/agent.md index 39c69c46..061699d3 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -19,6 +19,12 @@ at runtime; a page that only prepares text for the user to send should use - `agentAccess` in `miniapp/miniapp.json` is the only grant. Absent or `[]` means no access: `context.agent` is absent and page calls reject with `not_permitted`. +- `"session.read"` (MiniMax Code 3.1.4 or later, observed on 3.1.4-preview) adds read access to + sessions: on the page, `agent.session()` with no argument returns the chat the Mini App is open in + (`sessionId`, `title`, `agent`, `status`, `workspaceDir`, `isDefaultWorkspace`, `createdAtMs`, + `updatedAtMs`, `model`, `lastAssistantText`) and rejects with `not_permitted` when it is not open + in a chat; `agent.session(sessionId)` reads one session and `agent.sessions()` lists the user's + sessions with their titles and folders. Read only what you need and say so in the README. - `agentConcurrency` (1–32, default 4) caps how many turns of this Mini App run at once. Each session runs one turn at a time. Further `run` / `answer` calls wait in order, up to four times `agentConcurrency`; beyond that they fail with `busy`. diff --git a/docs/package-contract.md b/docs/package-contract.md index faec38b2..5f43cf6e 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -115,9 +115,10 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, - `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching `^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this repository does not document their use. -- `agentAccess` is optional. The only allowed value is `["session.run"]`, which lets the Mini App - run Agent turns; absent or `[]` means no Agent access. See `docs/agent.md` before declaring it. - Requires MiniMax Code 3.1.2 or later. +- `agentAccess` is optional: an array of `"session.run"` (run Agent turns) and `"session.read"` + (read sessions: the page's `agent.session()` returns the chat it is open in, see `docs/agent.md`). + Absent or `[]` means no Agent access. See `docs/agent.md` before declaring it. Requires MiniMax + Code 3.1.2 or later; `"session.read"` requires 3.1.4 or later. - `agentConcurrency` is optional: an integer from 1 to 32, default 4. It caps how many Agent turns of this Mini App run at once; it has no effect without `agentAccess`. Requires MiniMax Code 3.1.2 or later. @@ -172,6 +173,7 @@ the package cannot be installed there at all. | --- | --- | --- | | `agents` | `plugin.json` | 3.1.2 | | `agentAccess` | `miniapp/miniapp.json` | 3.1.2 | +| `agentAccess` with `"session.read"` | `miniapp/miniapp.json` | 3.1.4 | | `agentConcurrency` | `miniapp/miniapp.json` | 3.1.2 | | `surface.window` | `miniapp/miniapp.json` | 3.1.2 | diff --git a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json index 3494bea0..8e76f698 100644 --- a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json +++ b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json @@ -2,15 +2,16 @@ "schemaVersion": 1, "name": "cq-viewer", "displayName": "CadQuery Viewer", - "version": "1.2.1", - "description": "本地 3D 模型查看器:浏览项目文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),接收 CadQuery 脚本推送的模型,在模型上标注位置并一键发给 AI。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;内置 flowful-ai 的 CadQuery 参数化建模 skill。", + "version": "1.4.0", + "description": "本地 3D 模型查看器:在对话里打开时列出对话所在文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),勾选才打开,按住哪里就绕哪里旋转,触控板和鼠标都能平移缩放;接收 CadQuery 脚本推送的模型,在模型上标注位置并一键发给 AI;一键把当前视角的白模交给后台 Agent,用 MiniMax 图像生成渲染成成品图,可补充自己的描述。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,把这个模型渲染成产品图" ], "apps": [], "mcpServers": [ diff --git a/plugins/hanzijie/cq-viewer/README.md b/plugins/hanzijie/cq-viewer/README.md index ac32b634..aaed7074 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. 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. +A local 3D viewer for MiniMax Code. Opened in a conversation, it lists every STL and STEP model in that conversation's folder — STEP assemblies keep their part names, colours, and real CAD edges — and opens the ones you tick; it also shows models pushed from CadQuery scripts. Orbit around any point you press on, pan with two fingers or the right mouse button, 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. One more click turns the current view into a finished product image: the Viewer captures a clean white model and a background Agent renders it with MiniMax image generation, following any description you add. 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. -Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) (bundled CadQuery skill) · Version: `1.2.1` +Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) (bundled CadQuery skill) · Version: `1.4.0` ![CadQuery Viewer: a STEP assembly with three annotations ready to send](docs/preview.jpg) @@ -18,17 +18,20 @@ Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github. | Area | Detail | | --- | --- | | Viewer | Full-window canvas, orthographic camera, Z up (CadQuery convention). Seven preset views, fit, edges, grid, X/Y/Z section plane, axis gizmo. Follows the light/dark theme. | -| Project folders | Lists every `.stl`, `.step`, `.stp` in a folder (recursive); tick a file to open it. Open files reload when they change on disk. | +| Navigation | Press on the model and drag: it orbits around the point you pressed (a ring marks it), nothing jumps. Trackpad: two fingers pan, pinch zooms towards the pointer. Mouse: the wheel zooms towards the cursor, right or middle drag pans. Shift + drag pans on both. **操作方式** (toolbar) tells trackpad and mouse apart on each scroll, or lets you fix one, and lists the gestures. | +| Conversation folder | Opened in a conversation, the Viewer lists every `.stl`, `.step`, `.stp` in that conversation's folder (recursive), hidden folders such as `~/.mavis/workspace` included, and opens nothing until you tick a file. Outside a conversation it lists nothing. Extra folders can be added to a conversation's list (and removed). Open files reload when they change on disk. | | STEP | Converted locally into a mesh that keeps the assembly: part names, placements, colours, and B-rep edges (seam lines hidden). The "Opened" list shows how many parts a file has. Results are cached. | -| 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. | +| CadQuery push | `python/cqview.py` → `show(part_or_assembly, "name")` writes into `~/.cqview/scene/`; the files panel lists that folder like any other (tick to open). Assemblies are pushed as STEP so names and colours survive. | | Annotate | Pick mode (`P`): click the first points on the surface and confirm them (`Enter`). Pick mode then stays on: every further click goes straight into the annotation list with its note box focused, so you can pick a point, write what you want there, and pick the next one. 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)). | +| Send to AI | One button sends the composed message to the conversation (see [Sending to the conversation](#sending-to-the-conversation)). Annotations are kept per conversation folder in `~/.cqview/selections/.json`; the latest write is also in `~/.cqview/selection.json` for scripts. | +| White-model render | **渲染** captures the visible models from the current angle as a clay-white image, cropped to the model, and a background Agent turns it into a photorealistic image with MiniMax image generation (image-to-image). Pick a style (studio, in use, design render, 3D-printed part), an aspect ratio, whether part colours count, and add your own description. Progress, approvals the Agent asks for, and results (compare with the white model, copy, download) stay in the panel (see [White-model rendering](#white-model-rendering)). | +| AI tools (MCP) | `add_model_folder`, `add_model_file`, `list_model_folders` — the AI opens the models it just exported in its own conversation's viewer (it passes its working directory as `workspace`); an open viewer shows them at once, a closed one 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. +- MiniMax Code 3.1.4 or later: the Viewer reads which conversation it is open in (Agent access `session.read`). On a Host that cannot tell, the Viewer only lists a folder when it is opened with `/viewer?root=`. +- White-model rendering needs MiniMax Code 3.1.2 or later (Agent access) and the MiniMax image-generation tools of `mcode-tools` in your account. Each render is one image generation, billed like any other you start yourself. - 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`. @@ -41,12 +44,13 @@ 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 看一下这个项目里的模型". The MCP tools and the skill are available in conversations started after installation. +Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code, then open "CadQuery Viewer" in a conversation 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). 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. +1. **Open models.** The folder icon on the left lists every folder of this conversation as a group you can fold: the conversation folder first, then the folders you added, then `~/.cqview/scene` when it holds pushed models. Tick the models to open (nothing opens on its own), or let the assistant call `add_model_file`. **+** adds another folder to this conversation (inside your home directory, not home itself, `~/Library`, or a hidden top-level folder); **−** on a folder removes it again. The search box filters every folder at once. +2. **Look around.** Press on the model and drag to orbit around that point; pan with two fingers (trackpad), the right or middle button (mouse), or Shift + drag; zoom by pinching or with the wheel. The toolbar has views, fit (`F`), edges, grid, section, and **操作方式**. 3. **Annotate.** Press **选点** (`P`), click points on the model, press **确认** (`Enter`). The panel opens and you stay in pick mode: write the note for a point, click the next point (it joins the list right away and its note box takes the focus), and so on. Write what you want the AI to do whenever you like. Press **完成** or `Esc` to stop picking; pressing **选点** again later continues the same list. 4. **Send.** Press **发送到对话** (`⌘/Ctrl + Enter` in the instruction box). +5. **Render.** Turn the model to the angle you want, press **渲染**, pick a style, optionally describe materials or setting, and press **开始渲染** (`⌘/Ctrl + Enter` in the description box). Keep working; a notice appears when it is done. | Key | Action | | --- | --- | @@ -63,16 +67,32 @@ The Mini App serves an MCP server named `cq-viewer` (Streamable HTTP, `POST /mcp | 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. | +| `add_model_folder` | `{ path, select?, workspace? }` Adds a folder. With `workspace` (the AI's working directory) it becomes an extra folder of that conversation's viewer; a folder inside the conversation folder needs no adding. Outside the conversation folder the **+** rules apply (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, workspace? }` Opens an `.stl` / `.step` / `.stp` in place. With `workspace`, only that conversation's viewer opens it: from the conversation folder when the file is inside it, otherwise its folder becomes an extra folder of that conversation (files outside the home directory need `copy_to_scene`). Without `workspace`, every viewer whose folders contain the file opens it. The viewer ticks it, fits the view, and reloads it whenever the file is re-exported. | +| | `{ path, copy_to_scene: true, name?, color?, opacity?, workspace? }` 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 (registered or a conversation folder), 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. +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 in that conversation. A viewer carries out only its conversation's requests, each once, and the first time only those made since the conversation started (or in the last ten minutes when the Host gives no conversation info). Results include `viewer_connected`: with `workspace`, whether a viewer of that conversation folder is listening. + +### Conversation folder + +On start the page calls `window.mcode.miniapp.agent.session()`, which (with Agent access `session.read`, MiniMax Code 3.1.4 or later) returns the conversation the Viewer is open in. Only its `workspaceDir` and creation time are used; the title, model, and last reply in the same answer are ignored, and `agent.sessions()` (all conversations) is never called. The page then registers that folder with the Node side (`POST /viewer/api/workspace`), which checks it is an existing directory other than the home directory or a filesystem root. If the Host gives no conversation, the page uses `?root=` when present, otherwise it shows nothing. A `?root=` next to a conversation is added to that conversation's list as an extra folder. + +Opened models, hidden parts, and the selected folder live only as long as the page; reopening the Viewer starts empty. + +### White-model rendering + +1. **Capture (in the page).** The visible models are drawn once more off screen: same camera direction, cropped to the model with a 10 % margin (never more than the window shows), one neutral clay material unless **保留零件颜色** is ticked, light grey background, no grid, axes, markers, or gizmo. The long side is 1600 px; **自动** picks the supported ratio (1:1, 4:3, 3:4, 16:9, 9:16) closest to the framed model. The panel previews exactly this picture. +2. **Job (Node).** The picture and options go to `POST /viewer/api/renders`. The Node side checks them (PNG/JPEG bytes, at most 16 MB; known style and ratio; description up to 800 characters) and writes the job folder: the picture and `args.json` for the image tool. The image prompt is a fixed instruction to keep geometry, holes, fillets, and framing, the chosen style, your description marked as the user's requirements, and the visible file names as context. +3. **Background Agent.** One Agent turn per render, in a session of its own (not in your session list), on the default model, at most two at a time and three in progress. Its instructions: load the `mcode-tools-master` skill, upload the picture with `mcode-tools upload_temp_url`, put that URL into `args.json`, call `connector__matrix__generate_image` (2K), fetch the result with `mcode-tools get_asset_url` and `curl`, save it as `cq-render-.` in the job folder (or its own working directory if it cannot write there), and answer `RENDER_DONE ` or `RENDER_FAILED `. Your description is never part of these instructions; it only travels inside `args.json`. +4. **Approvals.** When the Agent needs approval for a command, the render shows the tool, the command, and the reason with **拒绝** / **允许一次**. Nothing is approved on its own; a render waits until you decide, cancel it, or the Viewer stops. **查看过程** opens the background session in the chat side panel (when the Viewer is open in a chat). +5. **Result.** A result counts only if it is a real JPEG / PNG / WebP of sane size with the job's file name, in the job folder or at the reported path (then copied in). Done renders can be viewed next to their white model, copied (as PNG), downloaded, retried with the same picture and settings, or deleted. + +The last 30 renders are kept. A render that was still going when the Viewer stopped is reported as interrupted on the next start, or as done if its image arrived. ### 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`. +`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` (passing its working directory as `workspace`); 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). @@ -101,19 +121,23 @@ The page first performs the standard MCP Apps handshake (`ui/initialize` over `p **What it reads** -- 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. +- The folder of the conversation the Viewer is open in, the extra folders added to it, and folders the AI registers: directory listings plus the contents of `.stl`, `.step`, and `.stp` files only. Hidden directories inside them, `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 conversation folder may be anywhere except the home directory itself or a filesystem root; any other folder must be inside your home directory, and cannot be the home directory itself, `~/Library`, or a hidden top-level folder. +- The conversation the page is open in, through `window.mcode.miniapp.agent.session()` (Agent access `session.read`): only its folder (`workspaceDir`) and creation time are used. The answer also carries the conversation title, model, and last reply; the page does not keep or send them. The list of all conversations (`agent.sessions()`) is never requested. - `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). +- `~/.cqview/roots.json`, `~/.cqview/selections/.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 (uniquely named temp file in the same folder, then rename), one write at a time, keeping the existing file's permissions: -- `~/.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. +- `~/.cqview/roots.json` — registered folders outside conversation folders (when you add an extra folder, or the AI calls `add_model_folder` / `add_model_file` for one). Conversation folders are not written here. +- `~/.cqview/selections/.json` — the annotations of one conversation folder: points, notes, instruction, and the absolute path of each annotated model. Rewritten on every change. +- `~/.cqview/selection.json` — a copy of the latest annotation write from any conversation folder (with its folder ID), for `cqview.selection()`; the last write wins. - `~/.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. +- It creates `~/.cqview/`, `~/.cqview/scene/`, and `~/.cqview/selections/` 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; on the first conversion after each start, entries unused for 30 days, entries beyond 1 GB (least recently used first), and temp files older than a day are removed), `open-requests.json`, the last 20 AI requests from the past 24 hours (folder ID, path relative to that folder, absolute path, the caller's conversation folder, or scene name), `workspaces.json` (the last 100 conversation folders and the extra folders added to each), and `renders/`: `jobs.json` (the last 30 renders: options, your description, status, background session ID, conversation folder ID) and one folder per render with the captured white model, `args.json`, and the result. Deleting a render in the panel removes its folder. -Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file path, size, and modification time (deleting it is safe; on the first conversion after each start, entries unused for 30 days, entries beyond 1 GB (least recently used first), and temp files older than a day are removed), and `open-requests.json`, the last 20 AI requests from the past 24 hours (folder ID, path relative to that folder, or scene name). +**Agent turns.** Each render runs one background Agent turn with the instructions in [White-model rendering](#white-model-rendering): upload the captured picture, call MiniMax image generation, download the result into the render's folder. The Agent has its normal tools and permission prompts; approvals it asks for are shown in the panel and sent only when you click **允许一次** or **拒绝**. The Viewer reads back only the result file, and only after checking its name, size, and image header. **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: @@ -129,22 +153,26 @@ The model path is validated first (registered folder, allowed extension, no symb **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. +**Watching.** While the Viewer runs it watches `~/.cqview/scene/`, each registered folder, and the conversation folders (with their extra folders) of the pages that are open, recursively, for model changes. -**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. +**Browser storage** holds view preferences only: edge/grid toggles, render style / ratio / part-colour choice, trackpad/mouse mode, and, per conversation folder, the number of the last AI request carried out. Opened models and hidden parts are not stored. -**Host page bridge.** On **Send** only, as described above. +**Host page bridge.** `agent.session()` once on start (see above); `message.append` on **Send**, as described above; `agent.open` when you press **查看过程** on a render. -**Network.** None. Three.js is vendored. +**Network.** The Mini App itself makes no requests; Three.js is vendored. A render's Agent turn uses `mcode-tools`, which sends the captured picture to MiniMax temporary storage, runs MiniMax image generation, and downloads the result from the URL MiniMax returns. ## Tested environment +- MiniMax Code `3.1.4-preview.4773` on macOS 26 (Apple Silicon) for 1.4.0: a host probe (removed again) showed that with `agentAccess: ["session.run", "session.read"]` the page's `agent.session()` returns the conversation it is open in, with `workspaceDir`, and that a query string given to `open` reaches the page. Published and opened in a conversation: the page registered that conversation's folder by itself (it was in no registry before), and `list_model_folders` for that folder answered with `viewer_connected: true`. +- Same build for 1.3.0: published to the Host (runtime ready with `agentAccess`) and opened in the Mini App tab. The white-model render instructions, exactly as the Node entry writes them, were run cold by a fresh MiniMax Code Agent session: upload, `args.json` edit, `connector__matrix__generate_image`, `get_asset_url`, download into a folder whose path has spaces, `RENDER_DONE` reply; the result (2048 × 2048 PNG) passed the Viewer's own name/size/header check. - 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.2.0): local install and open; continuous picking itself was exercised in headless Chrome (below). (1.1.1): local install; the page opened with `?root=` reads and registers folders under the new Host/Origin checks, and `list_model_folders` reports `viewer_connected: true` while the panel is open. (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). -- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs` — 19 tests against the real Node entry with a temporary home directory. `tests/mcp.test.mjs`: the three tools, folder rules, copy to scene with colour, request log across a restart, loopback/JSON checks. `tests/server.test.mjs`: 40 concurrent annotation saves and 8 concurrent folder registrations (page and MCP mixed) without errors or lost entries; with a fake Python, dispose during a STEP conversion kills it and starts no fallback, a killed conversion does not fall back, a `SIGSEGV` crash does; step-cache pruning; Host/Origin checks on every route; versions in sync. +- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs` — 35 tests against the real Node entry with a temporary home directory. `tests/workspace.test.mjs` (1.4.0): a conversation folder in a hidden top-level directory is accepted and listed, home, `/`, and missing folders are refused, conversation folders stay out of `roots.json`; extra folders belong to one conversation folder and can be removed; annotations kept per folder with the latest mirrored to `selection.json`; AI requests with `workspace` reach only that conversation, a file outside it adds its folder as an extra folder, requests without `workspace` follow the file's location, hidden subfolders stay unlisted; `viewer_connected` counts only pages of that folder; renders listed per folder. `tests/render.test.mjs` (1.3.0, fake `context.agent`): one Agent turn per render with the description kept out of its instructions and inside `args.json`; result served with the right type and download name; a result in the Agent's own folder is copied in only under the job's name; non-image results refused; `RENDER_FAILED` reasons shown; approvals shown with tool and command, relayed only on request (wrong request ID and `allowAlways` refused), never answered on their own; cancel; no deleting a running render; retry reuses picture and settings; input validation (ratio, style, image bytes vs. label, size, description length, JSON only, foreign Origin); no Agent access → 503; at most three in progress; restart turns unfinished renders into interrupted or done. `tests/mcp.test.mjs`: the three tools, folder rules, copy to scene with colour, request log across a restart, loopback/JSON checks. `tests/server.test.mjs`: 40 concurrent annotation saves and 8 concurrent folder registrations (page and MCP mixed) without errors or lost entries; with a fake Python, dispose during a STEP conversion kills it and starts no fallback, a killed conversion does not fall back, a `SIGSEGV` crash does; step-cache pruning; Host/Origin checks on every route; versions in sync. +- 1.4.0 in headless Chromium (Playwright) against the real Node entry: with no conversation the page lists nothing and says to open it from a conversation; with a simulated `agent.session()` it lists the conversation folder, an added folder, and `~/.cqview/scene` as foldable folder groups, opens nothing until a file is ticked, filters across folders, adds and removes an extra folder; the title chip shows the plugin icon. Pressing on the model leaves the picture unchanged (pixel diff: only anti-aliasing noise), dragging shows the orbit ring exactly at the pressed point; a trackpad-like scroll pans; **操作方式** recognises the trackpad and switches its gesture list to mouse. No console errors. +- Render panel end-to-end in headless Chromium (Playwright) against the real Node entry with a scripted Agent, light and dark at 1400 px, plus the panel at 760 px: open a model, preview of the white model, style / description, start, approval card scrolled into view, allow once, done with thumbnail and notice, viewer with white-model comparison, `Esc` closes it, second render cancelled; no console errors or failed requests. The captured 1600 px picture was checked by eye (clay material, cropped to the model, no grid or markers). - End-to-end in headless Chrome against the same Node entry, at 1400 px and 800 px wide (19 checks each): the first points are confirmed and pick mode stays on; further clicks join the list directly while a note box or the instruction box is being edited, focus moves to the new point's note, notes already written are kept; 撤销上一个 / `Backspace` remove the last point of the session; `⌘Z` restores it and keeps notes edited in between; `Esc` twice and 完成 leave pick mode; pressing 选点 again continues without confirmation; a collapsed panel reopens on a new point; after 清空标注 the confirm step returns; `selection.json` has the points, notes, and instruction; the panel stays clear of the pick bar. From 1.1.0: 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). +- Not verified: the `workspace` argument of the MCP tools through the Host (the conversation used for testing had the tool list from before 1.4.0); trackpad gestures on a real trackpad inside the Electron tab (headless Chromium only); a render started from the page inside MiniMax Code (the Host's background session, its permission prompts, and whether it can write into the Mini App's `dataDir` or falls back to its own folder); **下载** and **复制图片** inside the Electron Mini App tab; **查看过程** opening the side panel; 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 diff --git a/plugins/hanzijie/cq-viewer/README.zh-CN.md b/plugins/hanzijie/cq-viewer/README.zh-CN.md index 8ca9fb51..8a248f99 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 准确知道你说的是哪个面、哪个孔、哪条边。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;包里还内置了 flowful-ai 的 CadQuery 参数化建模 skill。 +MiniMax Code 里的本地 3D 模型查看器。在对话里打开时,列出这个对话所在文件夹里的所有 STL 和 STEP(STEP 装配保留零件名称、颜色和真实 CAD 边线),勾选哪个打开哪个;也能显示 CadQuery 脚本推送的模型。按住模型上任意一点拖动就绕那一点旋转,双指或右键平移,在模型上标出位置后一键发到对话里,让 AI 准确知道你说的是哪个面、哪个孔、哪条边。再点一下,就能把当前视角渲染成成品图:Viewer 截一张干净的白模,后台 Agent 用 MiniMax 图像生成把它渲染出来,并按你补充的描述调整。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;包里还内置了 flowful-ai 的 CadQuery 参数化建模 skill。 -作者:[HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai)(内置的 CadQuery skill) · 版本:`1.2.1` +作者:[HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai)(内置的 CadQuery skill) · 版本:`1.4.0` ![CadQuery Viewer:一个 STEP 装配,已标注三个位置,准备发送](docs/preview.jpg) @@ -18,17 +18,20 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL | 模块 | 说明 | | --- | --- | | 查看 | 全窗口画布,正交相机,Z 轴向上(与 CadQuery 一致)。7 个预设视角、适配、边线、网格、X/Y/Z 剖切、坐标轴指示,跟随亮色/暗色主题。 | -| 项目文件夹 | 递归列出文件夹里的 `.stl`、`.step`、`.stp`,勾选即打开;文件在磁盘上变化时自动重新加载。 | +| 视角操作 | 在模型上按下并拖动,就绕按下的那一点旋转(有一个圆环标出旋转中心),画面不会跳。触控板:双指滑动平移,双指捏合朝指针缩放。鼠标:滚轮朝光标缩放,右键或中键拖动平移。两种方式都可以 Shift + 拖动平移。工具栏的 **操作方式** 每次滚动自动判断是触控板还是鼠标,也可以手动固定,并列出所有手势。 | +| 会话文件夹 | 在对话里打开时,递归列出这个对话所在文件夹里的 `.stl`、`.step`、`.stp`(包括 `~/.mavis/workspace` 这类隐藏目录),勾选之前一个都不打开。不在对话里打开时什么都不列。可以给对话的列表添加额外的文件夹,也可以移除。文件在磁盘上变化时自动重新加载。 | | STEP | 在本机转换成网格,保留装配结构:零件名称、位置、颜色和 B-rep 边线(隐藏缝合线)。「已打开」里显示文件包含几个零件。转换结果会缓存。 | -| CadQuery 推送 | `python/cqview.py` 的 `show(零件或装配, "名字")` 写入 `~/.cqview/scene/`,Viewer 立即显示。装配以 STEP 推送,名称和颜色都保留。 | +| CadQuery 推送 | `python/cqview.py` 的 `show(零件或装配, "名字")` 写入 `~/.cqview/scene/`,文件面板把这个文件夹和其他文件夹一样列出来(勾选即打开)。装配以 STEP 推送,名称和颜色都保留。 | | 标注 | 选点模式(`P`):先在表面上点几个位置并确认(`Enter`)。之后选点模式保持开启,每点一下就直接加入标注列表,并自动聚焦到它的备注框:点一个位置、写一句要求、再点下一个,想一步写一步。每个点记录所在零件、装配里的子零件、坐标和表面法向;每个点可写备注,再写一句想让 AI 做什么。 | -| 发给 AI | 一个按钮把整理好的消息发到对话(见[发送到对话](#发送到对话))。同样的数据也保存在 `~/.cqview/selection.json`,方便 Agent 和脚本读取。 | -| AI 工具(MCP) | `add_model_folder`、`add_model_file`、`list_model_folders`:AI 登记文件夹、打开它刚导出的模型。查看器开着就立刻切过去,没开就在下次打开时执行(见 [AI 工具](#ai-工具mcp))。 | +| 发给 AI | 一个按钮把整理好的消息发到对话(见[发送到对话](#发送到对话))。标注按会话文件夹分开保存在 `~/.cqview/selections/<文件夹 ID>.json`;最近一次写入也在 `~/.cqview/selection.json`,方便脚本读取。 | +| 白模渲染 | 点 **渲染**,把当前角度下可见的模型截成一张裁到模型的白模图,后台 Agent 用 MiniMax 图像生成(图生图)渲染成照片级成品图。可选风格(影棚产品照、使用场景、设计效果图、3D 打印实物)、画幅、是否参考零件颜色,并补充自己的描述。进度、Agent 请求的授权和结果(与白模对比、复制、下载)都在面板里(见[白模渲染](#白模渲染))。 | +| AI 工具(MCP) | `add_model_folder`、`add_model_file`、`list_model_folders`:AI 把它刚导出的模型打开在它自己这个对话的查看器里(把工作目录作为 `workspace` 传入)。查看器开着就立刻显示,没开就在下次打开时执行(见 [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。 +- MiniMax Code 3.1.4 或更高版本:Viewer 需要读取自己是在哪个对话里打开的(Agent 权限 `session.read`)。宿主不提供这个信息时,只有用 `/viewer?root=<文件夹>` 打开才会列出文件夹。 +- 白模渲染需要 MiniMax Code 3.1.2 或更高版本(Agent 权限),以及账号里可用的 `mcode-tools` MiniMax 图像生成工具。每次渲染调用一次图像生成,计费方式和你自己发起的生成一样。 - 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`。 @@ -41,12 +44,13 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL /plugins/cq-viewer/ ``` -保留隐藏目录 `.minimax-plugin/`。重启 MiniMax Code 后,从插件列表打开「CadQuery Viewer」,或直接对助手说「打开 CadQuery Viewer 看一下这个项目里的模型」。MCP 工具和 skill 在安装之后新开的对话里可用。 +保留隐藏目录 `.minimax-plugin/`。重启 MiniMax Code 后,在对话里从插件列表打开「CadQuery Viewer」,或直接对助手说「打开 CadQuery Viewer 看一下这个项目里的模型」。MCP 工具和 skill 在安装之后新开的对话里可用。 -1. **选文件夹**:点左侧的文件夹图标,再点 **+** 添加文件夹(用户目录下的任意项目文件夹)。助手也可以调用 `add_model_folder`,或用 `/viewer?root=<文件夹绝对路径>` 打开页面。 -2. **打开模型**:在列表里勾选文件,或让助手调用 `add_model_file`。底部工具条可以切换视角、适配(`F`)、边线、网格和剖切。 +1. **打开模型**:左侧文件夹图标里按文件夹分组列出这个对话的所有文件夹,每组可以折叠:先是会话文件夹,然后是你添加的文件夹,`~/.cqview/scene` 里有推送的模型时也列在后面。勾选要打开的模型(不会自动打开任何模型),或让助手调用 `add_model_file`。**+** 给这个对话添加别的文件夹(在用户目录下,不能是用户目录本身、`~/Library` 或顶层隐藏目录),文件夹上的 **−** 再移除。搜索框同时筛选所有文件夹。 +2. **看模型**:在模型上按下拖动,绕那一点旋转;双指滑动(触控板)、右键或中键拖动(鼠标)、或 Shift + 拖动平移;捏合或滚轮缩放。底部工具条有视角、适配(`F`)、边线、网格、剖切和 **操作方式**。 3. **标注**:点 **选点**(`P`),在模型上点击位置,再点 **确认**(`Enter`)。标注面板打开后仍在选点模式:给一个点写备注,接着点下一个位置(它会立刻加入列表,光标跳到它的备注框),如此往复;「想让 AI 做什么」随时可以写。点 **完成** 或按 `Esc` 结束选点;之后再点 **选点**,会接着往同一个列表里加。 4. **发送**:点 **发送到对话**(在说明框里可按 `⌘/Ctrl + Enter`)。 +5. **渲染**:把模型转到想要的角度,点 **渲染**,选风格,按需写材质或场景,点 **开始渲染**(在描述框里可按 `⌘/Ctrl + Enter`)。可以继续做别的,完成后会有提示。 | 快捷键 | 作用 | | --- | --- | @@ -63,16 +67,32 @@ Mini App 自己提供一个名为 `cq-viewer` 的 MCP 服务(Streamable HTTP | 工具 | 作用 | | --- | --- | -| `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 个)。只读。 | +| `add_model_folder` | `{ path, select?, workspace? }` 添加一个文件夹。传 `workspace`(AI 的工作目录)时,它成为那个对话查看器里的额外文件夹;会话文件夹里面的子文件夹不用添加。会话文件夹之外的文件夹遵守 **+** 的规则:在用户目录下,不能是用户目录本身、`~/Library` 或顶层隐藏目录。返回文件夹 ID 和最多 200 个模型路径。`select`(默认 `true`)为真时查看器切到这个文件夹。 | +| `add_model_file` | `{ path, workspace? }` 原地打开一个 `.stl` / `.step` / `.stp`。传 `workspace` 时只有那个对话的查看器会打开:文件在会话文件夹里就从那里打开,否则把它所在的文件夹加为那个对话的额外文件夹(用户目录之外的文件需要 `copy_to_scene`)。不传 `workspace` 时,所有文件夹里包含这个文件的查看器都会打开它。查看器会勾选它、适配视图,文件重新导出时自动刷新。 | +| | `{ path, copy_to_scene: true, name?, color?, opacity?, workspace? }` 改为把文件复制到 `~/.cqview/scene/.<扩展名>`(和 `cqview.show_file` 一样),适合 `/tmp` 等其他位置的文件。之后源文件的修改不会同步。 | +| `list_model_folders` | `{ folder? }` 列出已登记的文件夹和推送场景;传文件夹 ID 或路径(已登记的或会话文件夹)时,列出该文件夹里的模型(绝对路径,最多 200 个)。只读。 | -请求会在 `dataDir` 里保留 24 小时(最近 20 条),所以查看器关着时 AI 打开的模型,会在你下次打开查看器时出现。每个浏览器只执行一次同一条请求;你自己取消勾选的文件,刷新后不会被重新打开。工具结果里的 `viewer_connected` 告诉 AI 当前有没有查看器窗口在线。 +请求会在 `dataDir` 里保留 24 小时(最近 20 条),所以查看器关着时 AI 打开的模型,会在你下次在那个对话里打开查看器时出现。查看器只执行自己这个对话的请求,每条只执行一次;第一次打开时只执行对话开始之后的请求(宿主不提供对话信息时,只执行最近十分钟内的)。工具结果里的 `viewer_connected`:传了 `workspace` 时,表示那个会话文件夹有没有查看器在线。 + +### 会话文件夹 + +页面启动时调用 `window.mcode.miniapp.agent.session()`,在有 Agent 权限 `session.read` 时(MiniMax Code 3.1.4 及以上)返回 Viewer 所在的对话。只使用其中的 `workspaceDir` 和创建时间;同一个返回里的标题、模型、最后一条回复都不用,也从不调用列出所有对话的 `agent.sessions()`。随后页面把这个文件夹交给 Node 端登记(`POST /viewer/api/workspace`),Node 检查它是已存在的目录,且不是用户目录本身或磁盘根目录。宿主不提供对话时,页面有 `?root=<文件夹>` 就用它,否则什么都不显示。和对话同时出现的 `?root=` 会作为额外文件夹加到这个对话的列表里。 + +打开的模型、隐藏的零件和选中的文件夹只在页面打开期间有效,重新打开 Viewer 时从空白开始。 + +### 白模渲染 + +1. **截图(页面里)**:把可见的模型在屏幕外再画一遍:相机方向不变,裁到模型并留 10% 边距(不会超出窗口里能看到的范围);除非勾选 **保留零件颜色**,否则统一换成中性的白模材质;浅灰背景,不画网格、坐标轴、标注点和方向指示。长边 1600 px;**自动** 画幅会在 1:1、4:3、3:4、16:9、9:16 里选最接近模型轮廓的一个。面板里的预览就是这张图。 +2. **任务(Node)**:图片和选项提交到 `POST /viewer/api/renders`。Node 先校验(PNG/JPEG 文件头,最大 16 MB;风格和画幅合法;描述最多 800 字),再写入任务文件夹:截图和给图像工具用的 `args.json`。图像提示词由固定说明(保留几何、孔、圆角和构图)、所选风格、标注为用户要求的补充描述,以及可见模型的文件名(仅作参考)组成。 +3. **后台 Agent**:每次渲染跑一轮 Agent,使用单独的会话(不出现在你的会话列表里)和默认模型,最多同时跑 2 个、进行中最多 3 个。给它的指令是:加载 `mcode-tools-master` skill,用 `mcode-tools upload_temp_url` 上传截图,把得到的 URL 填进 `args.json`,调用 `connector__matrix__generate_image`(2K),用 `mcode-tools get_asset_url` 和 `curl` 取回结果,存为任务文件夹里的 `cq-render-.<扩展名>`(写不进去就存到它自己的工作目录),最后回复 `RENDER_DONE <路径>` 或 `RENDER_FAILED <原因>`。你的补充描述不会进入这段指令,只放在 `args.json` 里。 +4. **授权**:Agent 执行命令需要授权时,渲染记录里会显示工具、命令和原因,以及 **拒绝** / **允许一次**。不会自动批准;在你决定、取消或 Viewer 停止之前,渲染会一直等着。**查看过程** 会在对话侧栏打开这个后台会话(需要 Viewer 是在对话里打开的)。 +5. **结果**:只有名字是这个任务的文件名、大小合理、文件头是真实 JPEG / PNG / WebP 的图片才算结果,位置必须在任务文件夹里,或是 Agent 报告的路径(随后复制进来)。完成的渲染可以和白模对比查看、复制(PNG)、下载,也可以用同一张截图和设置重试,或删除。 + +保留最近 30 条渲染。Viewer 停止时还在进行的渲染,下次启动时会标记为中断;如果图片已经到了,就标记为完成。 ### 内置 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`。 +`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` 打开每个导出的模型(把工作目录作为 `workspace` 传入);其余内容没有改动,见 `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)。 @@ -101,19 +121,23 @@ clear() # 清空推送的模型 **读取** -- 你或 AI 登记的文件夹:目录列表,以及 `.stl`、`.step`、`.stp` 文件的内容,不读其他文件。跳过隐藏目录、`node_modules`、`__pycache__`、`venv`、`site-packages`;不跟随符号链接;每个文件夹最多 10 层、5 万个目录项、1000 个模型文件。文件夹必须在用户目录下,且不能是用户目录本身、`~/Library` 或顶层隐藏目录。 +- Viewer 所在对话的文件夹、加到它上面的额外文件夹,以及 AI 登记的文件夹:目录列表,以及 `.stl`、`.step`、`.stp` 文件的内容,不读其他文件。跳过其中的隐藏目录、`node_modules`、`__pycache__`、`venv`、`site-packages`;不跟随符号链接;每个文件夹最多 10 层、5 万个目录项、1000 个模型文件。会话文件夹可以在任何位置,但不能是用户目录本身或磁盘根目录;其他文件夹必须在用户目录下,且不能是用户目录本身、`~/Library` 或顶层隐藏目录。 +- 页面所在的对话,通过 `window.mcode.miniapp.agent.session()`(Agent 权限 `session.read`)读取:只用它的文件夹(`workspaceDir`)和创建时间。返回里还带有对话标题、模型和最后一条回复,页面不保存也不发送它们。从不请求所有对话的列表(`agent.sessions()`)。 - `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`(它自己的文件,见下文)。 +- `~/.cqview/roots.json`、`~/.cqview/selections/<文件夹 ID>.json` 和 `~/.cqview/selection.json`(它自己的文件,见下文)。 **写入**:在 `dataDir` 之外只写自己的交换目录 `~/.cqview/`,都是原子写入(同目录下名字唯一的临时文件,再改名),逐个执行,并保留原文件的权限: -- `~/.cqview/roots.json`:登记过的文件夹(添加文件夹、页面带 `?root=` 打开,或 AI 调用 `add_model_folder` / `add_model_file` 时)。 -- `~/.cqview/selection.json`:当前标注,包括点、备注、说明和每个标注所在模型的绝对路径。每次修改都会重写,以最后一次写入为准。 +- `~/.cqview/roots.json`:会话文件夹之外登记过的文件夹(你添加额外文件夹,或 AI 为它调用 `add_model_folder` / `add_model_file` 时)。会话文件夹不写在这里。 +- `~/.cqview/selections/<文件夹 ID>.json`:一个会话文件夹的标注,包括点、备注、说明和每个标注所在模型的绝对路径。每次修改都会重写。 +- `~/.cqview/selection.json`:任意会话文件夹最近一次标注写入的副本(带文件夹 ID),给 `cqview.selection()` 用,以最后一次写入为准。 - `~/.cqview/scene/.<扩展名>` 和 `~/.cqview/scene/scene.json`:只在 `add_model_file` 带 `copy_to_scene` 时写,包括复制的模型、它的颜色/不透明度,并删除同名的其他格式模型(和 `cqview.py` 一样)。 -- 如果 `~/.cqview/` 和 `~/.cqview/scene/` 不存在会创建。它从不写入你的项目文件夹。 +- 如果 `~/.cqview/`、`~/.cqview/scene/` 和 `~/.cqview/selections/` 不存在会创建。它从不写入你的项目文件夹。 + +在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除(每次启动后第一次转换时,清理 30 天没用过的条目、超出 1 GB 的部分(最久没用的先删),以及超过一天的临时文件);以及 `open-requests.json`,最近 24 小时内最多 20 条 AI 请求(文件夹 ID、相对该文件夹的路径、绝对路径、发起者的会话文件夹,或场景模型名);`workspaces.json`(最近 100 个会话文件夹,以及各自添加的额外文件夹);还有 `renders/`:`jobs.json`(最近 30 条渲染的选项、补充描述、状态、后台会话 ID、会话文件夹 ID),以及每条渲染一个文件夹,存白模截图、`args.json` 和结果。在面板里删除一条渲染会删掉它的文件夹。 -在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除(每次启动后第一次转换时,清理 30 天没用过的条目、超出 1 GB 的部分(最久没用的先删),以及超过一天的临时文件);以及 `open-requests.json`,最近 24 小时内最多 20 条 AI 请求(文件夹 ID、相对该文件夹的路径,或场景模型名)。 +**Agent 运行**:每次渲染跑一轮后台 Agent,指令见[白模渲染](#白模渲染):上传截图、调用 MiniMax 图像生成、把结果下载到这条渲染的文件夹。Agent 使用它平常的工具和权限确认;它请求的授权显示在面板里,只有你点 **允许一次** 或 **拒绝** 才会回复。Viewer 只读回结果文件,并且先检查文件名、大小和图片文件头。 **子进程**:只有打开 STEP 时才启动 Python。参数以数组传递(不经过 shell),忽略标准输入输出,Viewer 关闭时结束所有子进程: @@ -129,22 +153,26 @@ clear() # 清空推送的模型 **内置 skill**:Mini App 本身从不运行它。Agent 按 skill 操作时,会通过自己的终端工具(照常经过权限确认)运行其中的 Python 脚本:`run_cadquery_model.py` 在子 Python 进程里执行模型脚本并收集它写出的文件,`preview.py` 离屏渲染 PNG。脚本不发起网络请求;skill 会让 Agent 用网页搜索查真实尺寸,缺少依赖时它的安装说明会用 `pip`(或 `conda`)安装上面列出的 Python 包。 -**监听**:Viewer 运行期间监听 `~/.cqview/scene/` 和每个登记的文件夹(递归)里的模型变化。 +**监听**:Viewer 运行期间递归监听 `~/.cqview/scene/`、每个登记的文件夹,以及当前打开着的页面的会话文件夹(含额外文件夹)里的模型变化。 -**浏览器存储**:只保存视图偏好,包括选中的文件夹、打开的文件、隐藏的零件、边线和网格开关,以及这个浏览器执行到的最后一条 AI 请求的序号。 +**浏览器存储**:只保存视图偏好,包括边线和网格开关、渲染的风格 / 画幅 / 是否保留零件颜色、触控板 / 鼠标模式,以及每个会话文件夹执行到的最后一条 AI 请求的序号。不保存打开的模型和隐藏的零件。 -**宿主页面桥接**:只在点 **发送** 时使用,见上文。 +**宿主页面桥接**:启动时调用一次 `agent.session()`(见上文);点 **发送** 时用 `message.append`,见上文;在渲染记录上点 **查看过程** 时用 `agent.open`。 -**网络**:无。three.js 已随包内置。 +**网络**:Mini App 本身不发起任何请求,three.js 已随包内置。渲染时后台 Agent 使用 `mcode-tools`:把截图传到 MiniMax 临时存储、调用 MiniMax 图像生成,再从 MiniMax 返回的地址下载结果。 ## 测试环境 +- 1.4.0:MiniMax Code `3.1.4-preview.4773`,macOS 26(Apple Silicon)。用临时探针(已移除)确认:声明 `agentAccess: ["session.run", "session.read"]` 后,页面的 `agent.session()` 返回它所在的对话,包含 `workspaceDir`;`open` 时带的查询参数能传到页面。发布后在一个对话里打开:页面自己登记了这个对话的文件夹(之前不在任何登记表里),对这个文件夹调用 `list_model_folders` 返回 `viewer_connected: true`。 +- 1.3.0:同一版本。已发布到宿主(声明 `agentAccess` 后运行时正常就绪),并在 Mini App 标签页中打开。Node 入口写出的白模渲染指令原样交给一个全新的 MiniMax Code Agent 会话冷启动执行:上传、修改 `args.json`、`connector__matrix__generate_image`、`get_asset_url`、下载到路径含空格的文件夹、回复 `RENDER_DONE`;结果(2048 × 2048 PNG)通过了 Viewer 自己的文件名 / 大小 / 文件头校验。 - 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.2.0):本机安装并打开;连续选点本身在无头 Chrome 中验证(见下)。(1.1.1):本机安装;带 `?root=` 打开的页面在新的 Host/Origin 校验下能正常读取和登记文件夹,面板开着时 `list_model_folders` 返回 `viewer_connected: true`。(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` 握手,因此走降级路径)。 -- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`:共 19 个测试,用临时用户目录跑真实的 Node 入口。`tests/mcp.test.mjs`:三个工具、文件夹规则、带颜色复制到场景、重启后的请求记录、回环与 JSON 检查。`tests/server.test.mjs`:40 个并发保存标注、8 个并发登记文件夹(页面和 MCP 混合)没有报错也没有丢条目;用假 Python 验证转换中 dispose 会结束进程且不启动退回转换、被结束的转换不退回、`SIGSEGV` 崩溃仍会退回;缓存清理;所有路由的 Host/Origin 校验;版本号一致。 +- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`:共 35 个测试,用临时用户目录跑真实的 Node 入口。`tests/workspace.test.mjs`(1.4.0):顶层隐藏目录里的会话文件夹可以登记并列出模型,用户目录、`/` 和不存在的文件夹被拒绝,会话文件夹不进 `roots.json`;额外文件夹只属于一个会话文件夹,可以移除;标注按文件夹分开保存,最近一次写入同步到 `selection.json`;带 `workspace` 的 AI 请求只到那个对话,会话文件夹之外的文件会把它所在的文件夹加为额外文件夹,不带 `workspace` 的请求按文件位置分发,隐藏子目录仍然不列出;`viewer_connected` 只算那个文件夹的页面;渲染按文件夹列出。`tests/render.test.mjs`(1.3.0,假的 `context.agent`):每次渲染一轮 Agent,补充描述不进入指令、只在 `args.json` 里;结果以正确的类型和下载文件名返回;Agent 存在自己目录里的结果只有文件名对得上才会复制进来;非图片结果被拒绝;`RENDER_FAILED` 原因会显示;授权请求显示工具和命令,只按请求转发(错的请求 ID 和 `allowAlways` 被拒绝),从不自动回复;取消;进行中的渲染不能删除;重试沿用截图和设置;输入校验(画幅、风格、图片字节与声明不符、大小、描述长度、只收 JSON、外部 Origin);没有 Agent 权限时返回 503;进行中最多 3 个;重启后未完成的渲染变为中断或完成。`tests/mcp.test.mjs`:三个工具、文件夹规则、带颜色复制到场景、重启后的请求记录、回环与 JSON 检查。`tests/server.test.mjs`:40 个并发保存标注、8 个并发登记文件夹(页面和 MCP 混合)没有报错也没有丢条目;用假 Python 验证转换中 dispose 会结束进程且不启动退回转换、被结束的转换不退回、`SIGSEGV` 崩溃仍会退回;缓存清理;所有路由的 Host/Origin 校验;版本号一致。 +- 1.4.0 在无头 Chromium(Playwright)中对真实 Node 入口测试:不在对话里时页面什么都不列,提示从对话里打开;模拟 `agent.session()` 时把会话文件夹、添加的文件夹和 `~/.cqview/scene` 列成可折叠的文件夹分组,勾选之前不打开任何模型,搜索跨文件夹筛选,额外文件夹能添加和移除;标题栏显示插件图标。在模型上按下时画面不变(像素对比只有抗锯齿噪声),拖动时旋转中心的圆环正好在按下的位置;类似触控板的滚动会平移;**操作方式** 识别为触控板,切到鼠标后手势列表随之变化。没有控制台错误。 +- 渲染面板在无头 Chromium(Playwright)中对真实 Node 入口 + 脚本化的 Agent 做了端到端测试,亮色和暗色各一遍(1400 px),另看了 760 px 下的面板:打开模型、白模预览、选风格和写描述、开始、授权卡片滚动到可见处、允许一次、完成后的缩略图和提示、带白模对比的大图查看、`Esc` 关闭、第二次渲染取消;没有控制台错误或失败请求。1600 px 的截图人工检查过(白模材质、裁到模型、没有网格和标注点)。 - 在无头 Chrome 中对同一个 Node 入口做了端到端测试,分别在 1400 px 和 800 px 宽度下各 19 项:第一批点确认后保持选点模式;正在编辑备注或「想让 AI 做什么」时继续点击,新点直接进入列表,光标跳到新点的备注框,已写的备注不丢;「撤销上一个」/`Backspace` 删除本次最后加的点;`⌘Z` 恢复它并保留期间改过的备注;连按两次 `Esc` 或点「完成」退出选点;再次点「选点」无需确认直接继续;收起的面板在新增点时自动展开;「清空标注」后恢复确认步骤;`selection.json` 中有点、备注和说明;窄屏下面板不遮挡选点栏。1.1.0 时测过: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 及以上)。 +- 未验证:经宿主调用 MCP 工具时的 `workspace` 参数(测试用的对话里工具列表是 1.4.0 之前的);Electron 标签页里真实触控板的手势(只在无头 Chromium 里测过);在 MiniMax Code 里从页面发起的渲染(宿主的后台会话、它的权限确认,以及它能否写入 Mini App 的 `dataDir`,还是退回到自己的目录);Electron Mini App 标签页里的 **下载** 和 **复制图片**;**查看过程** 打开侧栏;Windows;查看器面板开着时经宿主调用 `add_model_file`(页面这一侧只在无头 Chrome 里验证过);这个版本里发送的降级路径是否真的填进了对话输入框;其他 CAD 软件导出的带面级颜色的 STEP;skill 的 Gridfinity 生成器在 CadQuery 2.5 下的表现(skill 要求 2.6.1 及以上)。 ## 第三方 diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js b/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js index 79092928..a52cce56 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/app.js @@ -5,6 +5,7 @@ import * as THREE from './vendor/three.module.min.js'; import { OrbitControls } from './vendor/OrbitControls.js'; import { STLLoader } from './vendor/STLLoader.js'; import { createHostBridge } from './host-bridge.js'; +import { createRenderPanel } from './render-panel.js'; const API_BASE = new URL('../api/', import.meta.url); /** @param {string} path @param {Record} [query] */ @@ -22,36 +23,42 @@ document.documentElement.dataset.theme = darkQuery.matches ? 'dark' : 'light'; const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888888'; // ---------------------------------------------------------------- prefs (view-only) +// Opened models, hidden parts and the selected folder live only as long as the page: the Viewer +// opens with nothing shown. Only view preferences are kept in browser storage. const prefs = loadPrefs(); function loadPrefs() { + /** @type {Record} */ + let raw = {}; try { - const raw = JSON.parse(localStorage.getItem('cqv.prefs.v2') || '{}'); - return { - hidden: new Set(Array.isArray(raw.hidden) ? raw.hidden.filter((k) => typeof k === 'string') : []), - opened: Array.isArray(raw.opened) ? raw.opened.filter((k) => typeof k === 'string' && parseFileKey(k)) : [], - 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, - }; + raw = JSON.parse(localStorage.getItem('cqv.prefs.v3') || '{}') || {}; } catch { - return { hidden: new Set(), opened: [], root: null, edges: true, grid: true, reqEpoch: null, reqSeq: 0 }; - } + /* storage unavailable */ + } + return { + hidden: /** @type {Set} */ (new Set()), + opened: /** @type {string[]} */ ([]), + root: /** @type {string|null} */ (null), + edges: raw.edges !== false, + grid: raw.grid !== false, + // white-model render options + renderStyle: typeof raw.renderStyle === 'string' ? raw.renderStyle : null, + renderAspect: typeof raw.renderAspect === 'string' ? raw.renderAspect : 'auto', + renderColors: raw.renderColors === true, + // wheel / trackpad mapping + navMode: raw.navMode === 'trackpad' || raw.navMode === 'mouse' ? raw.navMode : 'auto', + }; } function savePrefs() { try { localStorage.setItem( - 'cqv.prefs.v2', + 'cqv.prefs.v3', JSON.stringify({ - hidden: [...prefs.hidden], - opened: prefs.opened, - root: prefs.root, edges: prefs.edges, grid: prefs.grid, - reqEpoch: prefs.reqEpoch, - reqSeq: prefs.reqSeq, + renderStyle: prefs.renderStyle, + renderAspect: prefs.renderAspect, + renderColors: prefs.renderColors, + navMode: prefs.navMode, }), ); } catch { @@ -70,15 +77,13 @@ const el = { overlayHint: $('overlay-hint'), toast: $('toast'), rootName: $('root-name'), - rootSelect: /** @type {HTMLSelectElement} */ ($('root-select')), - rootPath: $('root-path'), - rootAddToggle: $('root-add-toggle'), + rootAddToggle: /** @type {HTMLButtonElement} */ ($('root-add-toggle')), rootAddForm: /** @type {HTMLFormElement} */ ($('root-add-form')), rootAddInput: /** @type {HTMLInputElement} */ ($('root-add-input')), rootAddCancel: $('root-add-cancel'), rootAddError: $('root-add-error'), fileFilter: /** @type {HTMLInputElement} */ ($('file-filter')), - files: $('files'), + folders: $('folders'), filesStatus: $('files-status'), parts: $('parts'), partsEmpty: $('parts-empty'), @@ -88,9 +93,11 @@ const el = { files: $('panel-files'), parts: $('panel-parts'), info: $('panel-info'), + render: $('panel-render'), }, markers: $('markers'), hoverDot: $('hover-dot'), + pivotDot: $('pivot-dot'), annot: $('annot'), annotCount: $('annot-count'), annotCollapse: $('annot-collapse'), @@ -125,6 +132,11 @@ const el = { clipButtons: /** @type {HTMLButtonElement[]} */ ([...document.querySelectorAll('[data-clip]')]), clipPos: /** @type {HTMLInputElement} */ ($('clip-pos')), clipVal: $('clip-val'), + navToggle: /** @type {HTMLButtonElement} */ ($('nav-toggle')), + navPop: $('nav-pop'), + navButtons: /** @type {HTMLButtonElement[]} */ ([...document.querySelectorAll('[data-nav]')]), + navDetected: $('nav-detected'), + navKeys: $('nav-keys'), viewButtons: /** @type {HTMLButtonElement[]} */ ([...document.querySelectorAll('[data-view]')]), }; @@ -163,6 +175,8 @@ function hideToast() { } // ---------------------------------------------------------------- renderer +/** White-model render panel; created after everything it calls into exists. @type {ReturnType | null} */ +let renderPanel = null; let renderer; try { renderer = new THREE.WebGLRenderer({ canvas: el.canvas, antialias: true }); @@ -183,9 +197,12 @@ const controls = new OrbitControls(camera, el.canvas); controls.enableDamping = false; controls.screenSpacePanning = true; controls.zoomToCursor = true; +// left: orbit (Shift/Ctrl/⌘ + left: pan), middle and right: pan; the wheel is routed in setupWheel() +controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; controls.addEventListener('change', () => { updateDepthRange(); requestRender(); + renderPanel?.viewChanged(); }); controls.addEventListener('start', () => setViewPressed(null)); @@ -292,17 +309,28 @@ function renderNow() { renderer.autoClear = true; positionOverlays(w, h); } -function resize() { +/** + * Centre of the window in camera space. Non-zero after the orbit centre moved to a point that is not + * in the middle of the window (orbit around the point under the cursor): the camera then looks at + * that point and the frustum is shifted so nothing on screen moves. + */ +const viewOffset = { x: 0, y: 0 }; +/** Drawing buffer and camera frustum follow the window. Does not notify (the capture uses it to restore). */ +function applySize() { const w = Math.max(1, el.viewport.clientWidth); const h = Math.max(1, el.viewport.clientHeight); renderer.setSize(w, h, false); const aspect = w / h; - camera.left = -frustumHalf * aspect; - camera.right = frustumHalf * aspect; - camera.top = frustumHalf; - camera.bottom = -frustumHalf; + camera.left = viewOffset.x - frustumHalf * aspect; + camera.right = viewOffset.x + frustumHalf * aspect; + camera.top = viewOffset.y + frustumHalf; + camera.bottom = viewOffset.y - frustumHalf; camera.updateProjectionMatrix(); +} +function resize() { + applySize(); requestRender(); + renderPanel?.viewChanged(); } new ResizeObserver(resize).observe(el.viewport); @@ -343,6 +371,7 @@ function fitTo(box, dirArray) { camera.position.copy(center).addScaledVector(dir, Math.max(radius, sceneRadius) * 4); camera.zoom = 1; frustumHalf = radius * 1.08; + viewOffset.x = viewOffset.y = 0; resize(); controls.update(); updateDepthRange(); @@ -367,6 +396,104 @@ function setViewPressed(name) { for (const b of el.viewButtons) b.setAttribute('aria-pressed', String(b.dataset.view === name)); } +/** + * Make `point` the orbit centre without moving anything on screen: the camera keeps its direction + * and distance but looks at `point`, and the frustum shifts by the same amount the other way. + * @param {THREE.Vector3} point + */ +function setPivot(point) { + camera.updateMatrixWorld(); + const p = point.clone().applyMatrix4(camera.matrixWorldInverse); // camera space + const dist = camera.position.distanceTo(controls.target); + const dir = controls.target.clone().sub(camera.position).normalize(); + controls.target.copy(point); + camera.position.copy(point).addScaledVector(dir, -dist); + viewOffset.x -= p.x; + viewOffset.y -= p.y; + applySize(); + controls.update(); +} + +// Orbit around the point under the cursor: pressing on the model moves the orbit centre there; on +// empty space the centre stays. A dot marks the centre while dragging. +/** @type {THREE.Vector3 | null} */ +let pivotPoint = null; +let pivotDown = /** @type {{x: number, y: number} | null} */ (null); +el.canvas.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || !controls.enabled) return; + const hit = hitAt(e.clientX, e.clientY); + if (!hit) return; + setPivot(hit.point); + pivotPoint = hit.point.clone(); + pivotDown = { x: e.clientX, y: e.clientY }; +}); +el.canvas.addEventListener('pointermove', (e) => { + if (!pivotDown || !pivotPoint || !(e.buttons & 1)) return; + if (Math.hypot(e.clientX - pivotDown.x, e.clientY - pivotDown.y) > 4 && el.pivotDot.hidden) { + el.pivotDot.hidden = false; + requestRender(); + } +}); +const endPivot = () => { + pivotDown = null; + pivotPoint = null; + if (!el.pivotDot.hidden) el.pivotDot.hidden = true; +}; +window.addEventListener('pointerup', endPivot); +window.addEventListener('pointercancel', endPivot); +el.canvas.addEventListener('lostpointercapture', endPivot); + +// ---- wheel: mouse wheel zooms; on a trackpad two fingers pan and a pinch zooms. +// 'auto' tells them apart per gesture; the 操作 popover can force either. +let wheelGesture = /** @type {{at: number, kind: 'pan'|'zoom'} | null} */ (null); +/** Last input device seen by 'auto', shown in the 操作 popover. */ +let detectedDevice = /** @type {'trackpad'|'mouse'|null} */ (null); + +/** @param {WheelEvent} e @returns {'trackpad'|'mouse'} */ +function classifyWheel(e) { + const legacy = /** @type {any} */ (e).wheelDeltaY; + if (e.ctrlKey) return 'trackpad'; // pinch + if (e.deltaMode !== 0) return 'mouse'; // lines / pages: a notched wheel + if (e.deltaX !== 0) return 'trackpad'; // two fingers rarely move perfectly vertically + if (!Number.isInteger(e.deltaY)) return 'trackpad'; + if (typeof legacy === 'number' && legacy !== 0) { + if (Math.abs(legacy) % 120 === 0) return 'mouse'; // whole notches + if (legacy === -3 * e.deltaY) return 'trackpad'; // Chromium's pixel-scroll signature + } + return Math.abs(e.deltaY) >= 50 ? 'mouse' : 'trackpad'; +} + +el.canvas.addEventListener( + 'wheel', + (e) => { + if (!controls.enabled) return; + const now = performance.now(); + /** @type {'pan'|'zoom'} */ + let kind; + if (e.ctrlKey) kind = 'zoom'; // pinch, or Ctrl + wheel + else if (prefs.navMode === 'mouse') kind = 'zoom'; + else if (prefs.navMode === 'trackpad') kind = 'pan'; + else if (wheelGesture && now - wheelGesture.at < 180) kind = wheelGesture.kind; // same gesture + else { + const device = classifyWheel(e); + if (device !== detectedDevice) { + detectedDevice = device; + renderNavHelp(); + } + kind = device === 'trackpad' ? 'pan' : 'zoom'; + } + wheelGesture = { at: now, kind }; + if (kind === 'zoom') return; // OrbitControls zooms towards the cursor + // pan: the model follows the fingers + e.preventDefault(); + e.stopImmediatePropagation(); + const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1; + /** @type {any} */ (controls)._pan(-e.deltaX * scale, -e.deltaY * scale); + controls.update(); + }, + { passive: false, capture: true }, +); + // ---------------------------------------------------------------- parts (scene + files) /** * @typedef {{size:number,mtime:number,color?:string,opacity?:number,edges?:boolean,kind?:'stl'|'step'}} PartInfo @@ -417,7 +544,7 @@ const CQVM_TYPE = 'application/x-cqview-mesh'; function makeEntry(key, ref, info) { const label = ref.source === 'scene' ? ref.name : baseName(ref.rel); const root = ref.source === 'file' ? roots.find((r) => r.id === ref.root) : null; - const title = ref.source === 'scene' ? `脚本推送:${ref.name}` : `${root ? root.display : ''}/${ref.rel}`; + const title = ref.source === 'scene' ? `~/.cqview/scene/${ref.name}` : `${root ? root.display : ''}/${ref.rel}`; /** @type {PartEntry} */ const entry = { key, ref, label, title, info, loadedKey: null, status: 'loading', mesh: null, back: null, edges: null, tris: 0 }; parts.set(key, entry); @@ -763,100 +890,135 @@ async function refreshScene() { await syncScene(); } while (refreshAgain); } catch (error) { - showWarning(`脚本推送目录读取失败:${error instanceof Error ? error.message : String(error)}`); + showWarning(`~/.cqview/scene 读取失败:${error instanceof Error ? error.message : String(error)}`); } finally { refreshing = false; } } +/** Parts pushed to ~/.cqview/scene, listed in the files panel; only the ticked ones are loaded. */ +let sceneParts = /** @type {(PartInfo & {name: string, file?: string})[]} */ ([]); +/** @type {Set} */ +const openScene = new Set(); + async function syncScene() { const res = await fetch(apiUrl('scene'), { cache: 'no-store' }); if (!res.ok) throw new Error(await errorMessage(res)); const data = await res.json(); if (!data || !Array.isArray(data.parts)) throw new Error('场景数据格式不正确'); - if (typeof data.selectionFile === 'string') selectionFile = data.selectionFile; showWarning(Array.isArray(data.warnings) ? data.warnings.join(';') : ''); const incoming = data.parts.filter((p) => p && typeof p.name === 'string'); - const keep = new Set(incoming.map((p) => sceneKey(p.name))); + sceneParts = incoming; + const available = new Map(incoming.map((p) => [p.name, p])); + for (const name of [...openScene]) if (!available.has(name)) openScene.delete(name); for (const entry of [...parts.values()]) { - if (entry.ref.source === 'scene' && !keep.has(entry.key)) removeEntry(entry.key); + if (entry.ref.source === 'scene' && !openScene.has(entry.ref.name)) removeEntry(entry.key); } const loads = []; - for (const info of incoming) { - const key = sceneKey(info.name); - const entry = parts.get(key) ?? makeEntry(key, { source: 'scene', name: info.name }, info); + for (const name of openScene) { + const info = available.get(name); + const key = sceneKey(name); + const entry = parts.get(key) ?? makeEntry(key, { source: 'scene', name }, info); // scene.json may drop a colour: rebuild meta from the fresh record rather than merging entry.info = { size: entry.info.size, mtime: entry.info.mtime }; const job = ensureLoaded(entry, info); if (job) loads.push(job); } renderParts(); + renderFileList(); await Promise.all(loads); afterSceneChange(true); } +async function openScenePart(name) { + if (openScene.has(name)) return; + openScene.add(name); + prefs.hidden.delete(sceneKey(name)); + const refit = hadContent; + await refreshScene(); + if (refit && parts.get(sceneKey(name))?.mesh) fitTo(visibleBox()); +} +function closeScenePart(name) { + openScene.delete(name); + const key = sceneKey(name); + prefs.hidden.delete(key); + removeEntry(key); + const kept = picks.filter((p) => !(p.ref && p.ref.source === 'scene' && p.ref.name === name)); + if (kept.length !== picks.length) setPicks(kept); + renderParts(); + renderFileList(); + afterSceneChange(false); +} + // ---------------------------------------------------------------- folders & files -/** @type {{id:string,label:string,display:string}[]} */ +/** + * The chat folder this page works in: from the Host (agent.session()), or from ?root= when the + * Host cannot tell. null when the Viewer is not opened in a chat: then nothing is listed. + * @type {{dir: string, source: 'session'|'param', since: number, folder: {id: string, label: string, display: string} | null} | null} + */ +let workspace = null; +/** The session folder first, then the extra folders added to it. @type {{id:string,label:string,display:string,kind?:string,missing?:boolean}[]} */ let roots = []; -/** @type {string|null} */ -let currentRoot = null; /** @type {Map} */ const fileLists = new Map(); - -async function loadRoots() { - const res = await fetch(apiUrl('roots'), { cache: 'no-store' }); - if (!res.ok) throw new Error(await errorMessage(res)); - const data = await res.json(); - roots = Array.isArray(data.roots) ? data.roots.filter((r) => r && ROOT_ID_RE.test(r.id)) : []; - renderRootSelect(); +/** Folder groups the user folded in the files panel (page lifetime only). @type {Set} */ +const collapsedFolders = new Set(); +/** The pushed-model folder (~/.cqview/scene) is listed as one more folder, under this id. */ +const SCENE_FOLDER = 'scene'; + +/** @param {any} data */ +function applyWorkspace(data) { + if (!workspace || !data || !data.folder || !ROOT_ID_RE.test(data.folder.id)) throw new Error('会话文件夹数据格式不正确'); + workspace.folder = data.folder; + const before = new Set(roots.map((r) => r.id)); + roots = [data.folder, ...(Array.isArray(data.extras) ? data.extras : [])].filter((r) => r && ROOT_ID_RE.test(r.id)); + if (typeof data.selectionFile === 'string') selectionFile = data.selectionFile; + for (const id of [...fileLists.keys()]) if (!roots.some((r) => r.id === id)) fileLists.delete(id); + for (const r of roots) if (!before.has(r.id) && !r.missing) void syncFiles(r.id); + renderChip(); + renderFileList(); } - -/** @param {string} path @returns {Promise} root id */ -async function registerRoot(path) { - const res = await fetch(apiUrl('roots'), { +/** @param {string} path @param {Record} body */ +async function postWorkspace(path, body) { + if (!workspace) throw new Error('不在对话中'); + const res = await fetch(apiUrl(path), { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ path }), + body: JSON.stringify({ dir: workspace.dir, ...body }), }); if (!res.ok) throw new Error(await errorMessage(res)); - const data = await res.json(); - await loadRoots(); - return data.root.id; + return res.json(); +} +async function loadWorkspace() { + applyWorkspace(await postWorkspace('workspace', {})); +} +/** @param {string} path @returns {Promise} id of the added folder */ +async function addExtraFolder(path) { + applyWorkspace(await postWorkspace('workspace/folders', { path })); + return roots[roots.length - 1].id; // the server keeps the newest last +} +/** @param {string} id */ +async function removeExtraFolder(id) { + applyWorkspace(await postWorkspace('workspace/folders', { remove: id })); + for (const key of [...prefs.opened]) if (parseFileKey(key)?.root === id) closeFile(key); + collapsedFolders.delete(id); + renderFileList(); } -function selectRoot(id) { - currentRoot = id; - prefs.root = id; - savePrefs(); - renderRootSelect(); +/** Unfold a folder group, refresh its list, and bring it into view. */ +function revealFolder(id) { + collapsedFolders.delete(id); renderFileList(); - return syncFiles(id); + requestAnimationFrame(() => el.folders.querySelector(`[data-folder="${id}"]`)?.scrollIntoView({ block: 'nearest' })); + return id === SCENE_FOLDER ? refreshScene() : syncFiles(id); } -function renderRootSelect() { - el.rootSelect.replaceChildren(); - if (!roots.length) { - const opt = document.createElement('option'); - opt.textContent = '还没有文件夹'; - opt.value = ''; - el.rootSelect.append(opt); - el.rootSelect.disabled = true; - } else { - el.rootSelect.disabled = false; - for (const r of roots) { - const opt = document.createElement('option'); - opt.value = r.id; - opt.textContent = r.label; - opt.title = r.display; - el.rootSelect.append(opt); - } - if (currentRoot) el.rootSelect.value = currentRoot; - } - const cur = roots.find((r) => r.id === currentRoot); - el.rootPath.textContent = cur ? cur.display : ''; - el.rootPath.title = cur ? cur.display : ''; - el.rootName.textContent = cur ? cur.label : '选择文件夹'; +/** The title chip names the session folder. */ +function renderChip() { + el.rootAddToggle.disabled = !workspace; + const cur = roots[0]; + el.rootName.textContent = cur ? cur.label : workspace ? '正在读取…' : '不在对话中'; el.rootName.title = cur ? cur.display : ''; } @@ -864,7 +1026,7 @@ async function syncFiles(rootId) { const state = fileLists.get(rootId) ?? { files: [], truncated: false, error: '', loading: true }; state.loading = true; fileLists.set(rootId, state); - if (rootId === currentRoot) renderFileList(); + renderFileList(); try { const res = await fetch(apiUrl('files', { root: rootId }), { cache: 'no-store' }); if (!res.ok) throw new Error(await errorMessage(res)); @@ -877,7 +1039,7 @@ async function syncFiles(rootId) { state.error = error instanceof Error ? error.message : String(error); } state.loading = false; - if (rootId === currentRoot) renderFileList(); + renderFileList(); await reconcileOpened(rootId, state); } @@ -912,7 +1074,6 @@ async function openFile(rootId, file) { if (prefs.opened.includes(key)) return; prefs.opened.push(key); prefs.hidden.delete(key); - savePrefs(); const entry = makeEntry(key, { source: 'file', root: rootId, rel: file.rel }, file); renderFileList(); const refit = hadContent; @@ -924,7 +1085,6 @@ async function openFile(rootId, file) { function closeFile(key) { prefs.opened = prefs.opened.filter((k) => k !== key); prefs.hidden.delete(key); - savePrefs(); removeEntry(key); const kept = picks.filter((p) => !(p.ref && p.ref.source === 'file' && fileKey(p.ref.root, p.ref.rel) === key)); if (kept.length !== picks.length) setPicks(kept); @@ -933,73 +1093,200 @@ function closeFile(key) { afterSceneChange(false); } +/** + * @param {{checked: boolean, name: string, step: boolean, dir?: string, size: number, title: string, + * label: string, onChange: (checked: boolean) => void}} f + */ +function fileRow(f) { + const li = document.createElement('li'); + const label = document.createElement('label'); + label.className = 'file-row'; + label.title = f.title; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = f.checked; + cb.setAttribute('aria-label', f.label); + cb.addEventListener('change', () => f.onChange(cb.checked)); + const main = document.createElement('span'); + main.className = 'file-main'; + const name = document.createElement('span'); + name.className = 'file-name'; + name.textContent = f.name; + if (f.step) { + const tag = document.createElement('span'); + tag.className = 'tag'; + tag.textContent = 'STEP'; + name.append(tag); + } + main.append(name); + if (f.dir !== undefined) { + const dir = document.createElement('span'); + dir.className = 'file-dir'; + dir.textContent = f.dir; + main.append(dir); + } + const size = document.createElement('span'); + size.className = 'file-size'; + size.textContent = fmtSize(f.size); + label.append(cb, main, size); + li.append(label); + return li; +} + +const chevronIcon = + ''; +const minusIcon = + ''; + +/** + * One group per folder: the session folder, its extra folders, and ~/.cqview/scene when it holds + * pushed models. Each group folds; ticking a model opens it. + * @param {{id: string, label: string, display: string, kind?: string, removable: boolean}} g + * @param {HTMLLIElement[]} rows @param {string} status @param {boolean} [error] + */ +function folderGroup(g, rows, status, error = false) { + const open = !collapsedFolders.has(g.id); + const section = document.createElement('section'); + section.className = 'folder'; + section.dataset.folder = g.id; + section.setAttribute('aria-label', g.label); + const head = document.createElement('div'); + head.className = 'folder-head'; + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'folder-toggle'; + toggle.setAttribute('aria-expanded', String(open)); + toggle.setAttribute('aria-controls', `folder-${g.id}`); + toggle.title = g.display; + toggle.innerHTML = chevronIcon; + const name = document.createElement('span'); + name.className = 'folder-name'; + name.textContent = g.label; + toggle.append(name); + if (g.kind === 'session') { + const tag = document.createElement('span'); + tag.className = 'tag'; + tag.textContent = '会话文件夹'; + toggle.append(tag); + } + const count = document.createElement('span'); + count.className = 'folder-count'; + count.textContent = String(rows.length); + toggle.append(count); + toggle.addEventListener('click', () => { + if (collapsedFolders.has(g.id)) collapsedFolders.delete(g.id); + else collapsedFolders.add(g.id); + renderFileList(); + }); + head.append(toggle); + if (g.removable) { + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'icon-btn'; + remove.innerHTML = minusIcon; + remove.title = '从这个对话的列表里移除'; + remove.setAttribute('aria-label', `从这个对话的列表里移除 ${g.label}`); + remove.addEventListener('click', async () => { + try { + await removeExtraFolder(g.id); + showToast(`已从这个对话移除「${g.label}」`, 'info', 2400); + } catch (err) { + showToast(`没有移除:${err instanceof Error ? err.message : String(err)}`, 'error', 4000); + } + }); + head.append(remove); + } + section.append(head); + if (open) { + const path = document.createElement('p'); + path.className = 'folder-path'; + path.textContent = g.display; + path.title = g.display; + const list = document.createElement('ul'); + list.className = 'files'; + list.id = `folder-${g.id}`; + list.append(...rows); + section.append(path, list); + if (status) { + const note = document.createElement('p'); + note.className = error ? 'folder-status error' : 'folder-status'; + note.textContent = status; + section.append(note); + } + } + return section; +} + function renderFileList() { - el.files.replaceChildren(); + el.folders.replaceChildren(); el.filesStatus.classList.remove('error'); - if (!currentRoot) { - el.filesStatus.textContent = '点右上角的 + 添加一个项目文件夹。'; + if (!workspace) { + el.filesStatus.textContent = '在对话里打开 CadQuery Viewer,这里会列出对话所在文件夹里的模型。'; return; } - const state = fileLists.get(currentRoot); - if (!state || (state.loading && state.files.length === 0 && !state.error)) { - el.filesStatus.textContent = '正在扫描文件夹…'; - return; - } - if (state.error) { - el.filesStatus.classList.add('error'); - el.filesStatus.textContent = state.error; + if (!roots.length) { + el.filesStatus.textContent = '正在读取会话文件夹…'; return; } const filter = el.fileFilter.value.trim().toLowerCase(); - const shown = filter ? state.files.filter((f) => f.rel.toLowerCase().includes(filter)) : state.files; - for (const f of shown) { - const key = fileKey(currentRoot, f.rel); - const li = document.createElement('li'); - const label = document.createElement('label'); - label.className = 'file-row'; - label.title = f.rel; - - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.checked = prefs.opened.includes(key); - const rootId = currentRoot; - cb.addEventListener('change', () => { - if (cb.checked) void openFile(rootId, f); - else closeFile(key); + let total = 0; + let found = 0; + for (const r of roots) { + const state = fileLists.get(r.id); + const files = state?.files ?? []; + const shown = filter ? files.filter((f) => f.rel.toLowerCase().includes(filter)) : files; + total += files.length; + found += shown.length; + const rows = shown.map((f) => { + const key = fileKey(r.id, f.rel); + return fileRow({ + checked: prefs.opened.includes(key), + name: baseName(f.rel), + step: f.kind === 'step', + dir: dirName(f.rel) || '(根目录)', + size: f.size, + title: f.rel, + label: `打开 ${f.rel}`, + onChange: (on) => (on ? void openFile(r.id, f) : closeFile(key)), + }); }); - - const main = document.createElement('span'); - main.className = 'file-main'; - const name = document.createElement('span'); - name.className = 'file-name'; - name.textContent = baseName(f.rel); - if (f.kind === 'step') { - const tag = document.createElement('span'); - tag.className = 'tag'; - tag.textContent = 'STEP'; - name.append(tag); - } - const dir = document.createElement('span'); - dir.className = 'file-dir'; - dir.textContent = dirName(f.rel) || '(根目录)'; - main.append(name, dir); - - const size = document.createElement('span'); - size.className = 'file-size'; - size.textContent = fmtSize(f.size); - - label.append(cb, main, size); - li.append(label); - el.files.append(li); + let status = ''; + let error = false; + if (r.missing) status = '文件夹已不存在'; + else if (!state || (state.loading && !files.length && !state.error)) status = '正在扫描…'; + else if (state.error) { + status = state.error; + error = true; + } else if (!files.length) status = '没有 STL 或 STEP 文件'; + else if (filter && !shown.length) status = '没有匹配的模型'; + else if (state.truncated) status = '只列出前 1000 个'; + el.folders.append( + folderGroup({ id: r.id, label: r.label, display: r.display, kind: r.kind, removable: r.kind !== 'session' }, rows, status, error), + ); } - if (!state.files.length) { - el.filesStatus.textContent = '这个文件夹里还没有 STL 或 STEP 文件'; - } else { - const bits = [`共 ${state.files.length} 个模型`]; - if (filter) bits.push(`找到 ${shown.length} 个`); - if (state.truncated) bits.push('只显示前 1000 个'); - el.filesStatus.textContent = bits.join(','); + if (sceneParts.length) { + const shown = filter ? sceneParts.filter((p) => p.name.toLowerCase().includes(filter)) : sceneParts; + total += sceneParts.length; + found += shown.length; + const rows = shown.map((p) => + fileRow({ + checked: openScene.has(p.name), + name: p.name, + step: p.kind === 'step', + size: p.size, + title: `~/.cqview/scene/${p.file ?? p.name}`, + label: `打开 ${p.name}`, + onChange: (on) => (on ? void openScenePart(p.name) : closeScenePart(p.name)), + }), + ); + el.folders.append( + folderGroup({ id: SCENE_FOLDER, label: 'scene', display: '~/.cqview/scene', removable: false }, rows, filter && !shown.length ? '没有匹配的模型' : ''), + ); } + const folders = roots.length + (sceneParts.length ? 1 : 0); + el.filesStatus.textContent = filter + ? `${folders} 个文件夹,找到 ${found} / ${total} 个模型` + : `${folders} 个文件夹,共 ${total} 个模型`; } // ---------------------------------------------------------------- after any change @@ -1011,11 +1298,18 @@ function afterSceneChange(fromLoad) { updateClipRange(); updateInfo(vis); - if (parts.size === 0) { - showOverlay('empty', '打开一个模型开始', '从项目文件夹中选择 STL 或 STEP 文件。', { + if (!workspace) { + showOverlay( + 'empty', + '在对话里打开 Viewer', + '查看器只显示对话所在文件夹里的模型。从一个对话里打开 CadQuery Viewer,就能看到那个文件夹里的 STL 和 STEP。', + ); + hadContent = false; + } else if (parts.size === 0) { + showOverlay('empty', '打开一个模型开始', '在左侧的模型文件里勾选会话文件夹中的 STL 或 STEP。', { action: '选择模型', onAction: () => openPanel('files'), - hint: '也可以在 CadQuery 脚本里调用 show(),模型会直接显示在这里。', + hint: '让 AI 导出模型后用 add_model_file 打开,模型也会出现在这里。', }); hadContent = false; } else { @@ -1037,6 +1331,7 @@ function afterSceneChange(fromLoad) { } updateDepthRange(); requestRender(); + renderPanel?.viewChanged(); } function showWarning(text) { @@ -1112,6 +1407,139 @@ function applyClip() { requestRender(); } +// ---------------------------------------------------------------- white-model capture (for rendering) +// One neutral clay material, a clean light background, no grid / axes / markers / gizmo: the picture +// the background render turns into a finished image. +const CLAY = { color: '#dededa', roughness: 0.82, metalness: 0, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }; +const CAPTURE_BG = '#f1f2f4'; + +const hasVisibleModel = () => [...parts.values()].some((p) => p.mesh && p.mesh.visible); +const visibleModelNames = () => orderedEntries().filter((e) => e.mesh && e.mesh.visible).map((e) => e.label); +const FRAME_MARGIN = 0.1; +const FRAME_SAMPLES = 40_000; + +/** + * Where the visible models sit in camera space (x right, y up), clipped to what the window shows, + * plus a margin: the camera keeps its direction, the picture is cropped to the model. Zoomed in on + * a detail, the window itself is the limit, so the picture never shows more than the window does. + * @returns {{cx: number, cy: number, hw: number, hh: number}} + */ +function contentFrame() { + const va = Math.max(1, el.viewport.clientWidth) / Math.max(1, el.viewport.clientHeight); + const viewHH = frustumHalf / camera.zoom; + const viewHW = viewHH * va; + camera.updateMatrixWorld(); + const toCam = camera.matrixWorldInverse; + let x0 = Infinity; + let x1 = -Infinity; + let y0 = Infinity; + let y1 = -Infinity; + const v = new THREE.Vector3(); + const meshes = [...parts.values()].filter((p) => p.mesh && p.mesh.visible).map((p) => /** @type {THREE.Mesh} */ (p.mesh)); + const total = meshes.reduce((s, m) => s + m.geometry.getAttribute('position').count, 0); + const stride = Math.max(1, Math.ceil(total / FRAME_SAMPLES)); + for (const mesh of meshes) { + mesh.updateMatrixWorld(); + const pos = mesh.geometry.getAttribute('position'); + for (let i = 0; i < pos.count; i += stride) { + v.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld).applyMatrix4(toCam); + if (v.x < x0) x0 = v.x; + if (v.x > x1) x1 = v.x; + if (v.y < y0) y0 = v.y; + if (v.y > y1) y1 = v.y; + } + } + // the window spans viewOffset ± (viewHW, viewHH) in camera space + x0 = Math.max(x0, viewOffset.x - viewHW); + x1 = Math.min(x1, viewOffset.x + viewHW); + y0 = Math.max(y0, viewOffset.y - viewHH); + y1 = Math.min(y1, viewOffset.y + viewHH); + if (!(x1 > x0 && y1 > y0)) return { cx: viewOffset.x, cy: viewOffset.y, hw: viewHW, hh: viewHH }; + const hw = ((x1 - x0) / 2) * (1 + 2 * FRAME_MARGIN); + const hh = ((y1 - y0) / 2) * (1 + 2 * FRAME_MARGIN); + return { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, hw, hh }; +} +/** Width / height of the framed model, for the automatic aspect ratio. */ +const frameAspect = () => { + const f = contentFrame(); + return f.hw / f.hh; +}; + +/** + * PNG data URL of the visible models at `aspect` (width / height): same camera direction, cropped + * to the model (contentFrame). Synchronous, so the window is restored before the browser paints. + * @param {{longSide: number, aspect: number, keepColors: boolean}} opts + */ +function captureView({ longSide, aspect, keepColors }) { + const w = Math.round(aspect >= 1 ? longSide : longSide * aspect); + const h = Math.round(aspect >= 1 ? longSide / aspect : longSide); + const frame = contentFrame(); + // grow the frame to the requested aspect around its centre (never crop the model) + const hh = Math.max(frame.hh, frame.hw / aspect); + const hw = hh * aspect; + const zoom = camera.zoom; // the projection divides the extent (not the centre) by zoom + + const ratio = renderer.getPixelRatio(); + const clear = renderer.getClearColor(new THREE.Color()); + const clearAlpha = renderer.getClearAlpha(); + const helpersVisible = helpersGroup.visible; + const pickVisible = pickGroup.visible; + const edgeColor = edgeMaterial.color.clone(); + const edgeOpacity = edgeMaterial.opacity; + const sectionColor = sectionMaterial.color.clone(); + /** @type {[THREE.Mesh, THREE.Material][]} */ + const swapped = []; + try { + camera.left = frame.cx - hw * zoom; + camera.right = frame.cx + hw * zoom; + camera.top = frame.cy + hh * zoom; + camera.bottom = frame.cy - hh * zoom; + camera.updateProjectionMatrix(); + helpersGroup.visible = false; + pickGroup.visible = false; + edgeMaterial.color.set('#3d4148'); + edgeMaterial.opacity = 0.4; + if (!keepColors) { + sectionMaterial.color.set('#c9c9c5'); + for (const p of parts.values()) { + if (!p.mesh) continue; + const old = /** @type {THREE.MeshStandardMaterial} */ (p.mesh.material); + const opacity = old.opacity ?? 1; + p.mesh.material = new THREE.MeshStandardMaterial({ + ...CLAY, + transparent: opacity < 1, + opacity, + depthWrite: opacity >= 1, + clippingPlanes: clipList(), + }); + swapped.push([p.mesh, old]); + } + } + renderer.setPixelRatio(1); + renderer.setSize(w, h, false); + renderer.setScissorTest(false); + renderer.setViewport(0, 0, w, h); + renderer.autoClear = true; + renderer.setClearColor(CAPTURE_BG, 1); + renderer.render(scene, camera); + return el.canvas.toDataURL('image/png'); + } finally { + for (const [mesh, old] of swapped) { + /** @type {THREE.Material} */ (mesh.material).dispose(); + mesh.material = old; + } + helpersGroup.visible = helpersVisible; + pickGroup.visible = pickVisible; + edgeMaterial.color.copy(edgeColor); + edgeMaterial.opacity = edgeOpacity; + sectionMaterial.color.copy(sectionColor); + renderer.setClearColor(clear, clearAlpha); + renderer.setPixelRatio(ratio); + applySize(); // drawing buffer and frustum back to the window + renderNow(); + } +} + // ---------------------------------------------------------------- annotations (positions on parts) /** * @typedef {{id:number,x:number,y:number,z:number,part:string|null,body?:string,ref?:PartRef, @@ -1381,6 +1809,10 @@ function positionOverlays(w, h) { const hs = hoverPoint ? screenOf(hoverPoint.x, hoverPoint.y, hoverPoint.z, w, h) : null; el.hoverDot.hidden = !hs; if (hs) el.hoverDot.style.transform = `translate(${hs[0].toFixed(1)}px, ${hs[1].toFixed(1)}px)`; + if (!el.pivotDot.hidden && pivotPoint) { + const ps = screenOf(pivotPoint.x, pivotPoint.y, pivotPoint.z, w, h); + if (ps) el.pivotDot.style.transform = `translate(${ps[0].toFixed(1)}px, ${ps[1].toFixed(1)}px)`; + } } function syncMarkers() { @@ -1505,6 +1937,8 @@ function focusPick(i) { const target = new THREE.Vector3(p.x, p.y, p.z); camera.position.add(target.clone().sub(controls.target)); controls.target.copy(target); + viewOffset.x = viewOffset.y = 0; // the point goes to the middle of the window + applySize(); controls.update(); updateDepthRange(); requestRender(); @@ -1622,12 +2056,13 @@ async function postSelection() { } } async function writeSelectionOnce() { + if (!workspace?.folder) return; // not in a chat: nothing to annotate try { const points = picks.map(({ x, y, z, part, body, ref, normal, note }) => ({ x, y, z, part, body, ref, normal, note })); const res = await fetch(apiUrl('selection'), { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ client: clientId, instruction, points }), + body: JSON.stringify({ client: clientId, folder: workspace.folder.id, instruction, points }), }); if (!res.ok) throw new Error(await errorMessage(res)); const doc = await res.json(); @@ -1637,8 +2072,9 @@ async function writeSelectionOnce() { } } async function loadSelection() { + if (!workspace?.folder) return; try { - const res = await fetch(apiUrl('selection'), { cache: 'no-store' }); + const res = await fetch(apiUrl('selection', { folder: workspace.folder.id }), { cache: 'no-store' }); if (!res.ok) return; const doc = await res.json(); if (!doc || !Array.isArray(doc.points)) return; @@ -1732,6 +2168,7 @@ function openPanel(name) { openPanelName = name; for (const [key, panel] of Object.entries(el.panels)) panel.hidden = key !== name; for (const b of el.railButtons) b.setAttribute('aria-pressed', String(b.dataset.panel === name)); + renderPanel?.panelToggled(name === 'render'); } for (const b of el.railButtons) { b.addEventListener('click', () => openPanel(openPanelName === b.dataset.panel ? null : b.dataset.panel || null)); @@ -1777,6 +2214,7 @@ el.optGrid.addEventListener('click', () => { function setClipPopover(open) { el.clipPop.hidden = !open; el.clipToggle.setAttribute('aria-expanded', String(open)); + if (open) setNavPopover(false); } el.clipToggle.addEventListener('click', (e) => { e.stopPropagation(); @@ -1785,7 +2223,63 @@ el.clipToggle.addEventListener('click', (e) => { el.clipPop.addEventListener('click', (e) => e.stopPropagation()); document.addEventListener('click', () => { if (!el.clipPop.hidden) setClipPopover(false); + if (!el.navPop.hidden) setNavPopover(false); }); + +// 操作方式: which gestures pan and zoom, and what everything does +const NAV_HELP = { + trackpad: [ + ['按下拖动', '旋转,绕按下的点'], + ['双指滑动', '平移'], + ['双指捏合', '缩放,朝向指针'], + ['Shift + 拖动', '平移'], + ['F', '适配视图'], + ], + mouse: [ + ['左键拖动', '旋转,绕按下的点'], + ['右键 / 中键拖动', '平移'], + ['滚轮', '缩放,朝向光标'], + ['Shift + 左键拖动', '平移'], + ['F', '适配视图'], + ], +}; +function setNavPopover(open) { + el.navPop.hidden = !open; + el.navToggle.setAttribute('aria-expanded', String(open)); + if (open) { + setClipPopover(false); + renderNavHelp(); + } +} +function renderNavHelp() { + const mode = prefs.navMode; + for (const b of el.navButtons) b.setAttribute('aria-pressed', String(b.dataset.nav === mode)); + const device = mode !== 'auto' ? mode : detectedDevice ?? (/Mac/i.test(navigator.platform) ? 'trackpad' : 'mouse'); + el.navDetected.textContent = + mode !== 'auto' + ? '' + : detectedDevice + ? `当前识别为${detectedDevice === 'trackpad' ? '触控板' : '鼠标'},每次滚动重新判断` + : '滚动一下,就会识别是触控板还是鼠标'; + el.navKeys.replaceChildren( + ...NAV_HELP[device].flatMap(([k, v]) => [ + Object.assign(document.createElement('dt'), { textContent: k }), + Object.assign(document.createElement('dd'), { textContent: v }), + ]), + ); +} +el.navToggle.addEventListener('click', (e) => { + e.stopPropagation(); + setNavPopover(el.navPop.hidden); +}); +el.navPop.addEventListener('click', (e) => e.stopPropagation()); +for (const b of el.navButtons) { + b.addEventListener('click', () => { + prefs.navMode = /** @type {'auto'|'trackpad'|'mouse'} */ (b.dataset.nav); + savePrefs(); + renderNavHelp(); + }); +} for (const b of el.clipButtons) { b.addEventListener('click', () => { clipAxis = b.dataset.clip || 'off'; @@ -1828,9 +2322,6 @@ el.clearPick.addEventListener('click', () => { }); // files panel -el.rootSelect.addEventListener('change', () => { - if (el.rootSelect.value) selectRoot(el.rootSelect.value); -}); el.fileFilter.addEventListener('input', () => renderFileList()); function setAddFormOpen(open) { @@ -1850,10 +2341,10 @@ el.rootAddForm.addEventListener('submit', async (e) => { return; } try { - const id = await registerRoot(value); + const id = await addExtraFolder(value); el.rootAddInput.value = ''; setAddFormOpen(false); - selectRoot(id); + void revealFolder(id); } catch (error) { el.rootAddError.hidden = false; el.rootAddError.textContent = error instanceof Error ? error.message : String(error); @@ -1862,6 +2353,7 @@ el.rootAddForm.addEventListener('submit', async (e) => { // keyboard window.addEventListener('keydown', (e) => { + if (renderPanel?.isDialogOpen()) return; // the result viewer is modal and handles Esc itself const t = /** @type {HTMLElement} */ (e.target); const typing = t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA'); if (e.key === 'Escape') { @@ -1870,6 +2362,7 @@ window.addEventListener('keydown', (e) => { return; } if (!el.clipPop.hidden) setClipPopover(false); + else if (!el.navPop.hidden) setNavPopover(false); else if (pickMode) setPickMode(false); else if (openPanelName) openPanel(null); return; @@ -1920,21 +2413,44 @@ function applyHostContext(ctx) { } // ---------------------------------------------------------------- requests from the AI (MCP tools) -// The Node side keeps the last requests in dataDir, so they still apply when the page opens later. +// The Node side keeps the last requests in dataDir and hands each workspace only its own, pointed at +// its folders. Progress is remembered per session folder; the first time, only requests made since +// the chat started (or the last ten minutes, without Host session info) are carried out. let requestChain = Promise.resolve(); function applyRequests() { + if (!workspace?.folder) return requestChain; requestChain = requestChain.then(applyRequestsNow, applyRequestsNow).catch(() => {}); return requestChain; } +const reqKey = () => `cqv.req.v3.${workspace?.folder?.id ?? ''}`; +function loadReqState() { + try { + const s = JSON.parse(localStorage.getItem(reqKey()) || 'null'); + return s && typeof s.epoch === 'string' && Number.isInteger(s.seq) ? s : null; + } catch { + return null; + } +} +function saveReqState(epoch, seq) { + try { + localStorage.setItem(reqKey(), JSON.stringify({ epoch, seq })); + } catch { + /* storage unavailable */ + } +} async function applyRequestsNow() { - const res = await fetch(apiUrl('requests'), { cache: 'no-store' }); + if (!workspace) return; + const res = await fetch(apiUrl('requests', { dir: workspace.dir }), { cache: 'no-store' }); if (!res.ok) return; const data = await res.json(); if (!data || !Array.isArray(data.items) || typeof data.epoch !== 'string') return; + const state = loadReqState(); // 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 fresh = !state || state.epoch !== data.epoch; + const after = fresh ? 0 : state.seq; const todo = data.items .filter((it) => it && Number.isInteger(it.seq) && it.seq > after) + .filter((it) => !fresh || Date.parse(it.at) >= workspace.since) .sort((a, b) => a.seq - b.seq); for (const item of todo) { try { @@ -1942,33 +2458,27 @@ async function applyRequestsNow() { } 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(); + saveReqState(data.epoch, item.seq); } + if (fresh || !todo.length) saveReqState(data.epoch, Number.isInteger(data.seq) ? data.seq : 0); } async function applyRequest(item) { if (item.type === 'scene' && typeof item.name === 'string') { - const key = sceneKey(item.name); - if (prefs.hidden.delete(key)) savePrefs(); + prefs.hidden.delete(sceneKey(item.name)); + openScene.add(item.name); await refreshScene(); - const entry = await settled(key); + const entry = await settled(sceneKey(item.name)); if (entry) applyMaterial(entry); revealAll(); - showToast(`AI 推送了 ${item.name}`, 'info', 3000); + 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(); + if (!roots.some((r) => r.id === item.root)) await loadWorkspace(); // the AI may just have added it const root = roots.find((r) => r.id === item.root); if (!root) throw new Error('文件夹已不在列表里'); - await selectRoot(item.root); + await revealFolder(item.root); if (item.type === 'folder') { openPanel('files'); showToast(`AI 添加了文件夹「${root.label}」`, 'info', 3000); @@ -1981,7 +2491,7 @@ async function applyRequest(item) { 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(); + prefs.hidden.delete(key); const entry = parts.get(key); if (entry) applyMaterial(entry); renderParts(); @@ -2013,12 +2523,10 @@ let events = null; let offlineTimer = null; let wasOffline = false; function rootsInUse() { - const ids = new Set(prefs.opened.map((k) => parseFileKey(k)?.root).filter(Boolean)); - if (currentRoot) ids.add(currentRoot); - return ids; + return new Set(roots.map((r) => r.id)); } function connect() { - events = new EventSource(apiUrl('events')); + events = new EventSource(apiUrl('events', workspace ? { dir: workspace.dir } : undefined)); events.onopen = () => { if (offlineTimer) clearTimeout(offlineTimer); offlineTimer = null; @@ -2049,11 +2557,15 @@ function connect() { void refreshScene(); for (const id of rootsInUse()) void syncFiles(id); void applyRequests(); // the server may have restarted while we were away + void renderPanel?.refresh(); } else if (msg.type === 'scene') void refreshScene(); + else if (msg.type === 'renders') void renderPanel?.refresh(); 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 === 'workspace' && msg.folder === workspace?.folder?.id) { + // extra folders changed (added here, in another page of this folder, or by the AI) + void loadWorkspace().then(() => renderFileList(), () => {}); + } else if (msg.type === 'request') void applyRequests(); + else if (msg.type === 'selection' && msg.client !== clientId && msg.folder === workspace?.folder?.id) void loadSelection(); // ignore our own echo else if (msg.type === 'watch-lost') { events?.close(); setTimeout(connect, 2000); @@ -2085,6 +2597,33 @@ function detectHostBridge() { return bridgeDetection; } +/** + * Which chat folder this page works in. MiniMax Code tells the page the chat it is open in + * (agent.session(), granted by agentAccess "session.read"); a Host that cannot, or a page opened + * outside a chat, falls back to ?root= (the AI can open the Viewer with it), else null. + */ +async function resolveWorkspace() { + const agent = /** @type {any} */ (window).mcode?.miniapp?.agent; + if (typeof agent?.session === 'function') { + try { + const s = await Promise.race([agent.session(), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]); + if (s && typeof s.workspaceDir === 'string' && s.workspaceDir) { + return { + dir: s.workspaceDir, + source: /** @type {const} */ ('session'), + since: typeof s.createdAtMs === 'number' ? s.createdAtMs : Date.now() - 10 * 60_000, + folder: null, + }; + } + } catch { + /* not opened in a chat, or this Host has no session info: try ?root= */ + } + } + const root = new URLSearchParams(location.search).get('root'); + if (root) return { dir: root, source: /** @type {const} */ ('param'), since: Date.now() - 10 * 60_000, folder: null }; + return null; +} + async function boot() { void detectHostBridge(); renderer.setClearColor(css('--cqv-canvas'), 1); @@ -2092,39 +2631,58 @@ async function boot() { renderPicks(); // includes the pick bar renderParts(); - try { - await loadRoots(); - } catch (error) { - el.filesStatus.textContent = `文件夹列表没有读取成功:${error instanceof Error ? error.message : String(error)}`; + renderPanel = createRenderPanel({ + apiUrl, + errorMessage, + capture: captureView, + hasModel: hasVisibleModel, + modelNames: visibleModelNames, + frameAspect, + folder: () => workspace?.folder?.id ?? null, + isOpen: () => openPanelName === 'render', + open: () => openPanel(openPanelName === 'render' ? null : 'render'), + showToast, + prefs, + savePrefs, + }); + renderPanel.init(); + + showOverlay('loading', '正在读取会话文件夹', '请稍候…'); + workspace = await resolveWorkspace(); + if (workspace) { + try { + await loadWorkspace(); + } catch (error) { + const why = error instanceof Error ? error.message : String(error); + workspace = null; + renderChip(); + renderFileList(); + showOverlay('error', '会话文件夹打不开', why); + return; + } + } + renderChip(); + if (!workspace) { + renderFileList(); + afterSceneChange(false); // "open the Viewer from a chat" + void renderPanel.refresh(); + return; } - // ?root= (how a session opens the viewer on its own folder) or ?root= + // ?root= next to Host session info: the AI asked for that folder, add it to this chat's list const requested = new URLSearchParams(location.search).get('root'); - let target = null; - if (requested) { - if (ROOT_ID_RE.test(requested) && roots.some((r) => r.id === requested)) target = requested; - else { - try { - target = await registerRoot(requested); - } catch (error) { - showToast(`无法打开这个文件夹:${error instanceof Error ? error.message : String(error)}`, 'error', 5000); - } + if (workspace.source === 'session' && requested) { + try { + void revealFolder(await addExtraFolder(requested)); + } catch (error) { + showToast(`无法打开这个文件夹:${error instanceof Error ? error.message : String(error)}`, 'error', 5000); } } - if (!target && prefs.root && roots.some((r) => r.id === prefs.root)) target = prefs.root; - if (!target && roots.length) target = roots[0].id; - - // forget files whose folder is no longer registered - prefs.opened = prefs.opened.filter((k) => roots.some((r) => r.id === parseFileKey(k)?.root)); - savePrefs(); - - if (target) selectRoot(target); - else renderFileList(); - for (const id of rootsInUse()) if (id !== target) void syncFiles(id); await refreshScene(); afterSceneChange(false); await loadSelection(); + void renderPanel.refresh(); connect(); await applyRequests(); // models the AI asked for while the viewer was closed } diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js b/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js index 59c90bcd..51737bfd 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js @@ -9,7 +9,7 @@ // delivered to our listener; it is recognised by the "cqv-" id prefix and ignored. const PROTOCOL_VERSION = '2026-01-26'; -const APP_INFO = { name: 'cq-viewer', version: '1.2.1' }; // keep equal to plugin.json (tests check) +const APP_INFO = { name: 'cq-viewer', version: '1.4.0' }; // keep equal to plugin.json (tests check) const OWN_NOTIFICATIONS = new Set(['ui/notifications/initialized', 'ui/notifications/size-changed']); /** diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.png b/plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d943c7d0ee1db80652381ae899b778472f32327f GIT binary patch literal 15346 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 KugYGUef}RQtn^C& literal 0 HcmV?d00001 diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.svg b/plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.svg deleted file mode 100644 index 8b0931f0..00000000 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/logo.svg +++ /dev/null @@ -1,90 +0,0 @@ - - CadQuery Viewer - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/render-panel.js b/plugins/hanzijie/cq-viewer/miniapp/client/assets/render-panel.js new file mode 100644 index 00000000..5b29585f --- /dev/null +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/render-panel.js @@ -0,0 +1,631 @@ +// CadQuery Viewer — white-model render panel. +// +// Captures the current view as a white model, asks the Node side to start a background render (one +// Agent turn per render), and follows the renders: progress, approvals the Agent asks for, results. +// Approvals are decided only by the user's click here; this page never answers one on its own. + +const STATUS_TEXT = { + queued: '排队中', + running: '渲染中', + approval: '等待授权', + done: '已完成', + error: '失败', + cancelled: '已取消', +}; +const ASPECT_VALUES = { '1:1': 1, '4:3': 4 / 3, '3:4': 3 / 4, '16:9': 16 / 9, '9:16': 9 / 16 }; +const ACTIVE = new Set(['queued', 'running', 'approval']); +const CAPTURE_LONG_SIDE = 1600; +const PREVIEW_LONG_SIDE = 640; +const MAX_PROMPT = 800; + +/** + * @param {{ + * apiUrl: (path: string, query?: Record) => string, + * errorMessage: (res: Response) => Promise, + * capture: (opts: {longSide: number, aspect: number, keepColors: boolean}) => string, + * hasModel: () => boolean, + * modelNames: () => string[], + * frameAspect: () => number, + * folder: () => string | null, + * isOpen: () => boolean, + * open: () => void, + * showToast: (text: string, kind?: string, ms?: number) => void, + * prefs: {renderStyle: string|null, renderAspect: string, renderColors: boolean}, + * savePrefs: () => void, + * }} api + */ +export function createRenderPanel(api) { + const $ = (id) => /** @type {HTMLElement} */ (document.getElementById(id)); + const el = { + toggle: $('render-toggle'), + badge: $('render-badge'), + unavailable: $('render-unavailable'), + form: /** @type {HTMLFormElement} */ ($('render-form')), + preview: $('render-preview'), + previewImg: /** @type {HTMLImageElement} */ ($('render-preview-img')), + previewCaption: $('render-preview-caption'), + styles: $('render-styles'), + styleHint: $('render-style-hint'), + aspect: /** @type {HTMLSelectElement} */ ($('render-aspect')), + colors: /** @type {HTMLInputElement} */ ($('render-colors')), + prompt: /** @type {HTMLTextAreaElement} */ ($('render-prompt')), + promptCount: $('render-prompt-count'), + start: /** @type {HTMLButtonElement} */ ($('render-start')), + startLabel: $('render-start-label'), + status: $('render-status'), + jobs: $('render-jobs'), + jobsEmpty: $('render-jobs-empty'), + jobsCount: $('render-jobs-count'), + dialog: /** @type {HTMLDialogElement} */ ($('render-dialog')), + dialogImg: /** @type {HTMLImageElement} */ ($('render-dialog-img')), + dialogMeta: $('render-dialog-meta'), + dialogCopy: /** @type {HTMLButtonElement} */ ($('render-dialog-copy')), + dialogDownload: /** @type {HTMLAnchorElement} */ ($('render-dialog-download')), + dialogClose: $('render-dialog-close'), + dialogViews: /** @type {HTMLButtonElement[]} */ ([...document.querySelectorAll('[data-render-view]')]), + }; + + /** @type {{available: boolean|null, styles: {id: string, label: string, hint: string}[], jobs: any[]}} */ + const data = { available: null, styles: [], jobs: [] }; + let style = api.prefs.renderStyle || 'studio'; + let submitting = false; + let loaded = false; + /** @type {Map} last status seen per render, to announce changes */ + const lastStatus = new Map(); + /** @type {Map} */ + const items = new Map(); + /** @type {ReturnType | null} */ + let ticker = null; + /** @type {{job: any, view: 'output'|'input'} | null} */ + let dialogState = null; + + // ---------------------------------------------------------------- helpers + const fileUrl = (job, which, extra = {}) => api.apiUrl(`renders/${job.id}/${which}`, { v: job.updatedAt, ...extra }); + const styleLabel = (id) => data.styles.find((s) => s.id === id)?.label ?? id; + const agentBridge = () => /** @type {any} */ (window).mcode?.miniapp?.agent; + /** Nearest supported ratio to the framed model. */ + const autoAspectName = () => { + const va = api.frameAspect(); + let best = '4:3'; + let bestErr = Infinity; + for (const [name, v] of Object.entries(ASPECT_VALUES)) { + const err = Math.abs(Math.log(v) - Math.log(va)); + if (err < bestErr) { + best = name; + bestErr = err; + } + } + return best; + }; + const resolveAspect = () => (el.aspect.value === 'auto' ? autoAspectName() : el.aspect.value); + + function setStatus(text, kind = 'info') { + el.status.textContent = text; + el.status.dataset.kind = kind; + } + + function fmtTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const time = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toDateString() === new Date().toDateString() ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`; + } + function fmtElapsed(fromIso) { + const s = Math.max(0, Math.round((Date.now() - Date.parse(fromIso)) / 1000)); + return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`; + } + + /** Small DOM builder. */ + function h(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (v === undefined || v === null || v === false) continue; + if (k === 'className') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k === 'dataset') Object.assign(node.dataset, v); + else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v === true ? '' : String(v)); + } + for (const c of children) if (c !== null && c !== undefined && c !== false) node.append(c); + return node; + } + + // ---------------------------------------------------------------- form + function renderStyles() { + el.styles.replaceChildren( + ...data.styles.map((s) => + h( + 'label', + { className: 'style-chip', title: s.hint }, + h('input', { + type: 'radio', + name: 'render-style', + value: s.id, + checked: s.id === style, + onchange: () => { + style = s.id; + api.prefs.renderStyle = style; + api.savePrefs(); + el.styleHint.textContent = s.hint; + }, + }), + h('span', { text: s.label }), + ), + ), + ); + if (!data.styles.some((s) => s.id === style) && data.styles[0]) { + style = data.styles[0].id; + /** @type {HTMLInputElement|null} */ (el.styles.querySelector('input'))?.click(); + } + el.styleHint.textContent = data.styles.find((s) => s.id === style)?.hint ?? ''; + } + + function updateForm() { + const hasModel = api.hasModel(); + const ok = data.available === true; + el.start.disabled = submitting || !ok || !hasModel; + el.startLabel.textContent = submitting ? '正在启动…' : '开始渲染'; + el.unavailable.hidden = data.available !== false; + el.unavailable.textContent = '后台渲染需要 MiniMax Code 3.1.2 或更高版本,并且这个 Mini App 获得了运行 Agent 的权限。'; + const n = el.prompt.value.length; + el.promptCount.textContent = `${n} / ${MAX_PROMPT}`; + } + + let previewTimer = null; + function schedulePreview() { + if (!api.isOpen()) return; + if (previewTimer) clearTimeout(previewTimer); + previewTimer = setTimeout(() => { + previewTimer = null; + updatePreview(); + }, 160); + } + function updatePreview() { + updateForm(); + const aspect = resolveAspect(); + el.preview.style.aspectRatio = aspect.replace(':', ' / '); + if (!api.hasModel()) { + el.preview.dataset.empty = 'true'; + el.previewImg.hidden = true; + el.previewImg.removeAttribute('src'); + el.previewCaption.textContent = '先打开一个模型'; + return; + } + try { + el.previewImg.src = api.capture({ longSide: PREVIEW_LONG_SIDE, aspect: ASPECT_VALUES[aspect], keepColors: el.colors.checked }); + el.previewImg.hidden = false; + el.preview.dataset.empty = 'false'; + el.previewCaption.textContent = `当前角度 · 按模型构图 · ${aspect}${el.aspect.value === 'auto' ? '(自动)' : ''}`; + } catch { + el.preview.dataset.empty = 'true'; + el.previewImg.hidden = true; + el.previewCaption.textContent = '预览生成失败'; + } + } + + async function submit(event) { + event.preventDefault(); + if (submitting || data.available !== true) return; + if (!api.hasModel()) { + setStatus('先打开一个模型', 'error'); + return; + } + const aspect = resolveAspect(); + let image; + try { + image = api.capture({ longSide: CAPTURE_LONG_SIDE, aspect: ASPECT_VALUES[aspect], keepColors: el.colors.checked }); + } catch { + setStatus('白模截图没有生成成功,请重试', 'error'); + return; + } + submitting = true; + updateForm(); + setStatus('正在启动后台渲染…'); + try { + const res = await fetch(api.apiUrl('renders'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + image, + style, + aspect, + keepColors: el.colors.checked, + prompt: el.prompt.value, + models: api.modelNames(), + ...(api.folder() ? { folder: api.folder() } : {}), + }), + }); + if (!res.ok) throw new Error(await api.errorMessage(res)); + const { job } = await res.json(); + lastStatus.set(job.id, job.status); + upsert(job); + revealJob(job.id); + setStatus('已在后台开始,可以继续操作,完成后会提示你', 'ok'); + } catch (error) { + setStatus(`没有开始:${error instanceof Error ? error.message : String(error)}`, 'error'); + } finally { + submitting = false; + updateForm(); + } + } + + // ---------------------------------------------------------------- jobs + function upsert(job) { + const i = data.jobs.findIndex((j) => j.id === job.id); + if (i >= 0) data.jobs[i] = job; + else data.jobs.unshift(job); + renderJobs(); + } + + async function refresh() { + try { + const folder = api.folder(); + const res = await fetch(api.apiUrl('renders', folder ? { folder } : undefined), { cache: 'no-store' }); + if (!res.ok) throw new Error(await api.errorMessage(res)); + const next = await res.json(); + if (!next || !Array.isArray(next.jobs)) throw new Error('渲染记录格式不正确'); + const firstStyles = data.styles.length === 0; + data.available = next.available === true; + data.styles = Array.isArray(next.styles) ? next.styles : []; + // renders belong to a session folder: outside a chat there are none to show + data.jobs = folder ? next.jobs.filter((j) => j && typeof j.id === 'string') : []; + if (firstStyles) renderStyles(); + announce(); + renderJobs(); + updateForm(); + loaded = true; + } catch (error) { + if (!loaded) setStatus(`渲染记录没有读取成功:${error instanceof Error ? error.message : String(error)}`, 'error'); + } + } + + /** Tell the user about changes that happened while they were doing something else. */ + function announce() { + for (const job of data.jobs) { + const before = lastStatus.get(job.id); + lastStatus.set(job.id, job.status); + if (!loaded || before === job.status) continue; + if (job.status === 'approval') { + if (api.isOpen()) requestAnimationFrame(() => revealJob(job.id)); + else api.showToast('后台渲染在等你授权,打开「白模渲染」处理', 'warning', 6000); + } else if (job.status === 'done' && before && ACTIVE.has(before)) { + api.showToast('渲染完成', 'info', 3000); + if (!data.jobs.some((j) => ACTIVE.has(j.status))) setStatus(''); + } else if (job.status === 'error' && before && ACTIVE.has(before)) { + api.showToast('渲染失败,详情见「白模渲染」', 'error', 4000); + if (!data.jobs.some((j) => ACTIVE.has(j.status))) setStatus(''); + } + } + } + + /** Scroll a render into view inside the panel (the form above can push it below the fold). */ + function revealJob(id) { + const li = items.get(id)?.li; + if (!li || !api.isOpen()) return; + li.scrollIntoView({ block: 'nearest', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); + } + + function renderJobs() { + let prev = null; + const seen = new Set(); + for (const job of data.jobs) { + seen.add(job.id); + const stamp = `${job.updatedAt}|${job.status}|${agentBridge() ? 1 : 0}`; + let entry = items.get(job.id); + if (!entry || entry.stamp !== stamp) { + const active = /** @type {HTMLElement|null} */ (document.activeElement); + const refocus = entry && active && entry.li.contains(active) ? active.dataset.action : null; + const li = jobItem(job); + if (entry) entry.li.replaceWith(li); + entry = { li, stamp }; + items.set(job.id, entry); + if (refocus) /** @type {HTMLElement|null} */ (li.querySelector(`[data-action="${refocus}"]`))?.focus(); + } + const want = prev ? prev.nextSibling : el.jobs.firstChild; + if (entry.li !== want) el.jobs.insertBefore(entry.li, want); + prev = entry.li; + } + for (const [id, entry] of items) { + if (!seen.has(id)) { + entry.li.remove(); + items.delete(id); + } + } + const active = data.jobs.filter((j) => ACTIVE.has(j.status)); + el.jobsEmpty.hidden = data.jobs.length > 0; + el.jobsCount.textContent = data.jobs.length ? String(data.jobs.length) : ''; + el.badge.hidden = active.length === 0; + el.badge.textContent = String(active.length); + el.badge.dataset.kind = active.some((j) => j.status === 'approval') ? 'warning' : ''; + if (active.length && !ticker) ticker = setInterval(tick, 1000); + if (!active.length && ticker) { + clearInterval(ticker); + ticker = null; + } + if (dialogState) { + const fresh = data.jobs.find((j) => j.id === dialogState.job.id); + if (!fresh || fresh.status !== 'done') closeDialog(); + } + } + + function tick() { + for (const span of el.jobs.querySelectorAll('[data-since]')) { + span.textContent = `已用 ${fmtElapsed(/** @type {HTMLElement} */ (span).dataset.since || '')}`; + } + } + + function jobItem(job) { + const active = ACTIVE.has(job.status); + const done = job.status === 'done'; + const li = /** @type {HTMLLIElement} */ (h('li', { className: 'job', dataset: { status: job.status } })); + + const thumb = h( + 'button', + { + type: 'button', + className: 'job-thumb', + 'aria-label': done ? '查看成品图' : '查看白模截图', + onclick: () => openDialog(job, done ? 'output' : 'input'), + }, + h('img', { src: fileUrl(job, done ? 'output' : 'input'), alt: '', loading: 'lazy', decoding: 'async' }), + ); + + const head = h( + 'div', + { className: 'job-head' }, + h('span', { className: 'pill', dataset: { status: job.status }, text: STATUS_TEXT[job.status] ?? job.status }), + h('span', { className: 'job-meta', text: [fmtTime(job.createdAt), styleLabel(job.style), job.aspect].filter(Boolean).join(' · ') }), + ); + const promptText = job.prompt ? job.prompt : '没有补充描述'; + const prompt = h('p', { className: job.prompt ? 'job-prompt' : 'job-prompt muted', title: job.prompt || undefined, text: promptText }); + + const main = h('div', { className: 'job-main' }, head, prompt); + if (active) { + main.append( + h( + 'p', + { className: 'job-step', role: 'status' }, + h('span', { text: job.step || STATUS_TEXT[job.status] }), + ' · ', + h('span', { dataset: { since: job.startedAt || job.createdAt }, text: `已用 ${fmtElapsed(job.startedAt || job.createdAt)}` }), + ), + ); + } + if (job.status === 'approval' && job.permission) main.append(approvalCard(job)); + if (job.status === 'error' && job.error) main.append(h('p', { className: 'job-error', role: 'alert', text: job.error })); + + const actions = h('div', { className: 'job-actions' }); + const canOpenSession = !!job.sessionId && typeof agentBridge()?.open === 'function'; + if (done) { + actions.append( + actionBtn('view', '查看', () => openDialog(job, 'output')), + actionBtn('copy', '复制图片', (btn) => void copyImage(job, btn)), + h('a', { className: 'btn ghost small-btn', dataset: { action: 'download' }, href: fileUrl(job, 'output', { download: '1' }), download: `cq-render-${job.id}` }, '下载'), + ); + } + if (active) { + if (canOpenSession) actions.append(actionBtn('session', '查看过程', () => void openSession(job))); + actions.append(actionBtn('cancel', '取消', (btn) => void post(job, 'cancel', {}, btn))); + } else { + if (!done) actions.append(actionBtn('retry', '重试', (btn) => void post(job, 'retry', {}, btn))); + if (canOpenSession) actions.append(actionBtn('session', '查看过程', () => void openSession(job))); + actions.append(deleteBtn(job)); + } + main.append(actions); + li.append(thumb, main); + return li; + } + + function actionBtn(action, label, onClick) { + const btn = /** @type {HTMLButtonElement} */ (h('button', { type: 'button', className: 'btn ghost small-btn', dataset: { action }, text: label })); + btn.addEventListener('click', () => onClick(btn)); + return btn; + } + + /** Two-step delete: the first click asks, the second (within 4 s) removes. */ + function deleteBtn(job) { + let armed = null; + const btn = actionBtn('delete', '删除', async () => { + if (!armed) { + btn.textContent = '确认删除'; + btn.classList.add('danger'); + armed = setTimeout(() => { + armed = null; + btn.textContent = '删除'; + btn.classList.remove('danger'); + }, 4000); + return; + } + clearTimeout(armed); + armed = null; + btn.disabled = true; + try { + const res = await fetch(api.apiUrl(`renders/${job.id}`), { method: 'DELETE' }); + if (!res.ok) throw new Error(await api.errorMessage(res)); + data.jobs = data.jobs.filter((j) => j.id !== job.id); + renderJobs(); + } catch (error) { + btn.disabled = false; + api.showToast(`没有删除:${error instanceof Error ? error.message : String(error)}`, 'error', 4000); + } + }); + return btn; + } + + function approvalCard(job) { + const p = job.permission; + const decide = async (decision, btn) => { + for (const b of card.querySelectorAll('button')) /** @type {HTMLButtonElement} */ (b).disabled = true; + const ok = await post(job, 'permission', { requestId: p.requestId, decision }, btn); + if (!ok) for (const b of card.querySelectorAll('button')) /** @type {HTMLButtonElement} */ (b).disabled = false; + }; + const card = h( + 'div', + { className: 'job-approval', role: 'group', 'aria-label': `后台 Agent 请求使用 ${p.toolName}` }, + h('p', { className: 'job-approval-title' }, '后台 Agent 请求使用 ', h('strong', { text: p.toolName || '工具' })), + p.toolInput ? h('pre', { className: 'job-approval-input', text: p.toolInput }) : null, + p.reason ? h('p', { className: 'muted small job-approval-reason', text: p.reason }) : null, + h( + 'div', + { className: 'job-approval-actions' }, + actionBtnPlain('deny', '拒绝', 'btn', (btn) => void decide('deny', btn)), + actionBtnPlain('allow', '允许一次', 'btn btn-primary', (btn) => void decide('allowOnce', btn)), + ), + ); + return card; + } + function actionBtnPlain(action, label, className, onClick) { + const btn = /** @type {HTMLButtonElement} */ (h('button', { type: 'button', className, dataset: { action }, text: label })); + btn.addEventListener('click', () => onClick(btn)); + return btn; + } + + /** @returns {Promise} */ + async function post(job, action, body, btn) { + if (btn) btn.disabled = true; + try { + const res = await fetch(api.apiUrl(`renders/${job.id}/${action}`), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await api.errorMessage(res)); + const result = await res.json(); + if (result?.job) upsert(result.job); + if (action === 'retry') api.showToast('已用同一张白模重新开始渲染', 'info', 2500); + return true; + } catch (error) { + api.showToast(`操作没有完成:${error instanceof Error ? error.message : String(error)}`, 'error', 4000); + void refresh(); + return false; + } finally { + if (btn && btn.isConnected) btn.disabled = false; + } + } + + async function openSession(job) { + const bridge = agentBridge(); + if (typeof bridge?.open !== 'function') return; + try { + await bridge.open(job.sessionId); + } catch (error) { + const code = error instanceof Error ? error.message : String(error); + api.showToast( + code === 'not_permitted' + ? '需要在对话里打开 Viewer,才能在侧栏查看后台过程' + : code === 'session_not_found' + ? '这个后台会话已不存在' + : `没有打开:${code}`, + 'error', + 4000, + ); + } + } + + async function copyImage(job, btn) { + btn.disabled = true; + try { + const blob = await (await fetch(fileUrl(job, 'output'), { cache: 'no-store' })).blob(); + let png = blob; + if (blob.type !== 'image/png') { + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')).drawImage(bitmap, 0, 0); + bitmap.close(); + png = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('png'))), 'image/png')); + } + await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]); + api.showToast('图片已复制', 'info', 1800); + } catch { + api.showToast('这个窗口不能写入剪贴板,可以用「下载」保存图片', 'warning', 4000); + } finally { + btn.disabled = false; + } + } + + // ---------------------------------------------------------------- viewer dialog + function openDialog(job, view) { + dialogState = { job, view: job.status === 'done' ? view : 'input' }; + const canOutput = job.status === 'done'; + for (const b of el.dialogViews) b.disabled = b.dataset.renderView === 'output' && !canOutput; + el.dialogCopy.hidden = !canOutput; + el.dialogDownload.hidden = !canOutput; + el.dialogMeta.textContent = [fmtTime(job.createdAt), styleLabel(job.style), job.aspect, job.prompt].filter(Boolean).join(' · '); + showDialogView(); + if (!el.dialog.open) el.dialog.showModal(); + } + function showDialogView() { + if (!dialogState) return; + const { job, view } = dialogState; + el.dialogImg.src = fileUrl(job, view); + el.dialogImg.alt = view === 'output' ? '渲染成品图' : '发送给渲染的白模截图'; + for (const b of el.dialogViews) b.setAttribute('aria-pressed', String(b.dataset.renderView === view)); + if (job.status === 'done') { + el.dialogDownload.href = fileUrl(job, 'output', { download: '1' }); + el.dialogDownload.setAttribute('download', `cq-render-${job.id}`); + } + } + function closeDialog() { + dialogState = null; + if (el.dialog.open) el.dialog.close(); + } + + // ---------------------------------------------------------------- wiring + function init() { + el.aspect.value = Object.hasOwn(ASPECT_VALUES, api.prefs.renderAspect) ? api.prefs.renderAspect : 'auto'; + el.colors.checked = api.prefs.renderColors; + el.form.addEventListener('submit', submit); + el.aspect.addEventListener('change', () => { + api.prefs.renderAspect = el.aspect.value; + api.savePrefs(); + updatePreview(); + }); + el.colors.addEventListener('change', () => { + api.prefs.renderColors = el.colors.checked; + api.savePrefs(); + updatePreview(); + }); + el.prompt.addEventListener('input', updateForm); + el.prompt.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + el.form.requestSubmit(); + } + }); + el.toggle.addEventListener('click', () => api.open()); + for (const b of el.dialogViews) { + b.addEventListener('click', () => { + if (!dialogState || b.disabled) return; + dialogState.view = /** @type {'output'|'input'} */ (b.dataset.renderView); + showDialogView(); + }); + } + el.dialogClose.addEventListener('click', closeDialog); + el.dialog.addEventListener('close', () => (dialogState = null)); + el.dialog.addEventListener('click', (e) => { + if (e.target === el.dialog) closeDialog(); // backdrop + }); + el.dialogCopy.addEventListener('click', () => { + if (dialogState) void copyImage(dialogState.job, el.dialogCopy); + }); + updateForm(); + } + + return { + init, + refresh, + /** Camera, visibility, loaded models, or window size changed. */ + viewChanged: schedulePreview, + /** @param {boolean} open */ + panelToggled(open) { + el.toggle.setAttribute('aria-pressed', String(open)); + if (open) { + updatePreview(); + void refresh(); + } + }, + isDialogOpen: () => el.dialog.open, + }; +} diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/style.css b/plugins/hanzijie/cq-viewer/miniapp/client/assets/style.css index ceab39ce..6bd58f23 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/style.css +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/style.css @@ -637,6 +637,114 @@ input[type='range'] { color: var(--mcode-danger); } +/* folder groups in the files panel */ +.folders { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; +} + +.folder + .folder { + padding-top: 4px; + border-top: 1px solid var(--mcode-border); +} + +.folder-head { + display: flex; + align-items: center; + gap: 4px; +} + +.folder-toggle { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 6px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background-color 150ms ease-out; +} + +.folder-toggle:hover { + background: var(--mcode-surface-muted); +} + +.folder-toggle svg { + flex: 0 0 auto; + width: 12px; + height: 12px; + color: var(--mcode-text-muted); + transition: transform 150ms ease-out; +} + +.folder-toggle[aria-expanded='true'] svg { + transform: rotate(90deg); +} + +.folder-name { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-toggle .tag { + margin-left: 0; + flex: 0 0 auto; +} + +.folder-count { + margin-left: auto; + flex: 0 0 auto; + color: var(--mcode-text-subtle); + font-size: 12px; +} + +.folder-path { + margin: 0 0 2px 24px; + color: var(--mcode-text-subtle); + font-size: 11px; + line-height: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder .files { + margin: 0 0 4px 12px; +} + +.folder-status { + margin: 2px 0 6px 24px; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} + +.folder-status.error { + color: var(--mcode-danger); +} + +.icon-btn:disabled { + color: var(--mcode-text-subtle); + cursor: default; +} + +.icon-btn:disabled:hover { + background: transparent; +} + /* opened parts */ .parts { list-style: none; @@ -1162,6 +1270,527 @@ input[type='range'] { will-change: transform; } +/* orbit centre while dragging */ +.pivot-dot { + position: absolute; + left: 0; + top: 0; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border-radius: 50%; + border: 2px solid var(--mcode-accent); + box-shadow: 0 0 0 1px var(--cqv-marker-ring); + will-change: transform; +} + +.pivot-dot::after { + content: ''; + position: absolute; + left: 4px; + top: 4px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--mcode-accent); +} + +.pivot-dot[hidden] { + display: none; +} + +/* 操作方式 popover */ +.nav-pop { + width: 260px; +} + +.nav-detected { + margin: 0; +} + +.nav-detected:empty { + display: none; +} + +.nav-keys { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + margin: 0; + font-size: 12px; + line-height: 18px; +} + +.nav-keys dt { + color: var(--mcode-text); + white-space: nowrap; +} + +.nav-keys dd { + margin: 0; + color: var(--mcode-text-muted); +} + +/* ---------------------------------------------------------------- white-model render */ +.float-panel.left.wide { + width: 340px; +} + +.tool-btn.render { + color: var(--mcode-text); + font-weight: 500; +} + +.tool-btn.render[aria-pressed='true'] { + background: var(--mcode-bg-secondary); +} + +.rail-badge[data-kind='warning'] { + background: var(--mcode-warning); +} + +.render-body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.render-body > .warning { + margin: 0; +} + +.render-form { + display: flex; + flex-direction: column; +} + +.render-preview { + position: relative; + margin: 0 0 12px; + width: 100%; + aspect-ratio: 4 / 3; + max-height: 180px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.render-preview img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.render-preview img[hidden] { + display: none; +} + +.render-preview-caption { + position: absolute; + left: 8px; + bottom: 8px; + padding: 2px 8px; + border-radius: 999px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); + font-size: 11px; + line-height: 16px; +} + +.render-preview[data-empty='true'] .render-preview-caption { + position: static; + border: 0; + background: transparent; + font-size: 12px; +} + +.render-field { + margin: 0 0 12px; + padding: 0; + border: 0; + min-width: 0; +} + +.style-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.style-chip { + position: relative; + display: block; + cursor: pointer; +} + +.style-chip input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +.style-chip span { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 8px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + color: var(--mcode-text-muted); + font-size: 13px; + white-space: nowrap; + transition: background-color 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out; +} + +.style-chip:hover span { + color: var(--mcode-text); + background: var(--mcode-surface-muted); +} + +.style-chip input:checked + span { + border-color: var(--mcode-accent); + color: var(--mcode-text); + background: var(--mcode-surface-muted); + font-weight: 500; +} + +.style-chip input:focus-visible + span { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.render-hint { + margin: 6px 0 0; + min-height: 16px; +} + +.render-options { + display: flex; + align-items: flex-end; + gap: 12px; + margin-bottom: 12px; +} + +.render-option:first-child { + flex: 0 0 132px; +} + +.check { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + font-size: 13px; + color: var(--mcode-text); + cursor: pointer; + white-space: nowrap; +} + +.render-count { + margin: 4px 0 0; + text-align: right; +} + +.render-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +} + +.render-jobs { + border-top: 1px solid var(--mcode-border); + padding-top: 12px; +} + +.render-jobs-title { + font-size: 13px; + line-height: 20px; + font-weight: 600; + margin-bottom: 8px; +} + +.render-jobs > p { + margin: 0; +} + +.render-jobs-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.job { + display: flex; + gap: 10px; + padding: 8px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); +} + +.job[data-status='approval'] { + border-color: var(--mcode-warning); +} + +.job-thumb { + flex: 0 0 72px; + width: 72px; + height: 72px; + padding: 0; + border: 1px solid var(--mcode-border); + border-radius: 6px; + background: var(--mcode-surface-muted); + overflow: hidden; + cursor: zoom-in; +} + +.job-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.job[data-status='queued'] .job-thumb img, +.job[data-status='running'] .job-thumb img, +.job[data-status='approval'] .job-thumb img { + opacity: 0.55; +} + +.job-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.job-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.pill { + flex: 0 0 auto; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + line-height: 18px; + font-weight: 500; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); +} + +.pill[data-status='queued'], +.pill[data-status='running'] { + color: var(--mcode-accent); +} + +.pill[data-status='approval'] { + color: var(--mcode-warning); +} + +.pill[data-status='done'] { + color: var(--mcode-success); +} + +.pill[data-status='error'] { + color: var(--mcode-danger); +} + +.job-meta { + min-width: 0; + color: var(--mcode-text-subtle); + font-size: 11px; + line-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-prompt, +.job-step, +.job-error { + margin: 0; + font-size: 12px; + line-height: 17px; +} + +.job-prompt { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.job-step { + color: var(--mcode-text-muted); +} + +.job-error { + color: var(--mcode-danger); + word-break: break-word; +} + +.job-actions { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin: 2px 0 0 -8px; +} + +.btn.small-btn { + height: 26px; + padding: 0 8px; + font-size: 12px; + text-decoration: none; +} + +.btn.ghost.danger { + color: var(--mcode-danger); +} + +.job-approval { + margin-top: 2px; + padding: 8px; + border-radius: 8px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); +} + +.job-approval-title { + margin: 0; + font-size: 12px; +} + +.job-approval-input { + margin: 6px 0 0; + padding: 6px 8px; + max-height: 96px; + overflow: auto; + border-radius: 6px; + background: var(--mcode-bg-secondary); + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 15px; + white-space: pre-wrap; + word-break: break-all; +} + +.job-approval-reason { + margin: 6px 0 0; + word-break: break-word; +} + +.job-approval-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.job-approval-actions .btn { + height: 28px; + font-size: 12px; +} + +/* result viewer */ +.render-dialog { + width: min(1100px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + padding: 0; + border-radius: 12px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--mcode-shadow); +} + +.render-dialog[open] { + display: flex; + flex-direction: column; +} + +.render-dialog::backdrop { + background: rgb(10 10 10 / 45%); +} + +.render-dialog-head, +.render-dialog-foot { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px 10px 16px; + flex: 0 0 auto; +} + +.render-dialog-head h2 { + font-size: 14px; + line-height: 22px; + font-weight: 600; +} + +.render-dialog-foot { + border-top: 1px solid var(--mcode-border); +} + +.render-dialog-foot p { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.render-dialog-foot .btn { + text-decoration: none; +} + +.render-dialog-foot [hidden] { + display: none; +} + +.render-dialog-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + background: var(--mcode-surface-muted); + overflow: hidden; +} + +.render-dialog-body img { + max-width: 100%; + max-height: calc(100vh - 48px - 120px); + object-fit: contain; + display: block; +} + +.segmented button:disabled { + color: var(--mcode-text-subtle); + cursor: default; +} + /* ---------------------------------------------------------------- toast */ .toast { position: fixed; @@ -1221,7 +1850,8 @@ body.picking .float-panel.right { } @media (max-width: 640px) { - .float-panel.left { + .float-panel.left, + .float-panel.left.wide { left: 12px; top: 112px; width: calc(100vw - 24px); diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/index.html b/plugins/hanzijie/cq-viewer/miniapp/client/index.html index 724882a2..f0b12787 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/index.html +++ b/plugins/hanzijie/cq-viewer/miniapp/client/index.html @@ -4,15 +4,16 @@ CadQuery Viewer - +
- +
@@ -27,7 +28,7 @@

正在加载

- +

CadQuery Viewer

@@ -48,6 +49,11 @@

CadQuery Viewer

模型信息 + @@ -60,15 +66,14 @@

模型文件

- -
-

- -
    +

    @@ -120,6 +124,68 @@

    模型信息

    + + +