feat(cad): add neutral geometry preparation

This commit is contained in:
caoqianming 2026-09-03 08:21:21 +08:00
parent aa8ccec738
commit 6b4c54a1fe
26 changed files with 1702 additions and 31 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 新增中性 CAD 几何准备:可生成或检查 STEP组合基础体、截面、布尔、圆角、阵列等受控建模步骤并交付几何清单和预览结果可继续用于 ANSYS、Abaqus 等分析任务。
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。 - 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。

View File

@ -472,7 +472,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。 云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtimeUI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 和执行门健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。 专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。契约可附带 `active/deprecated` 生命周期、`standard/diagnostic_only` 可见性和替代 capability普通能力枚举隐藏诊断入口显式诊断查询与历史 Job 读取仍保留。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,桌面软件允许管理员自动检测或选择预定义应用位置,runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 或内核 roundtrip 健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS、Blender 和中性 CAD adapter 使用 Python只是实现选择。
Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`供本机任务列表统一显示标题Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。 Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`供本机任务列表统一显示标题Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。
@ -492,6 +492,12 @@ Origin 科研分析以独立 `origin.analysis@v1` capability 承载,不把数
Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe不建设跨软件万能 DSLAPI/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。 Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe不建设跨软件万能 DSLAPI/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。
`cad.geometry.prepare@v1` 是独立、无状态、runtime-only 的中性几何入口,以固定 CadQuery 2.8.0 / OCP 7.9.3.1 生成 STEP它只负责检查、基础体和受控有序 Recipe不承载材料、网格、接触、边界条件或求解。Recipe 覆盖显式坐标闭合截面、拉伸/旋转/扫掠/放样、布尔、圆角/倒角/抽壳、变换、线性/圆周阵列、STEP import 与多实体组合,所有引用、轮廓平面性、形状有效性、实体上限和选择数量都机械校验。输出固定为 STEP、规范化 Recipe、确定性几何 manifest、无 GUI PNG 预览和 provenancemanifest 只保存单位、几何量、拓扑/表面摘要和已解析区域,不把 OCCT、ANSYS 或 Abaqus 的临时实体 ID 作为跨软件事实。区域选择器以几何语义描述 face/edge/solid其中 `extreme_face``planar_faces` 和 ANSYS 静力 v2 保持同构并由合同测试守护。输入首版只接受显式声明单位的 STEP/STP统一转换到请求必填的 `canonical_unit`,缺失或歧义单位直接失败。
首版不输出 GLB当前稳定事实源和跨求解器交换面是 STEP现有 GLB 链路依赖 Blender/装配导出,未在独立 CAD runtime 上形成同等稳定的 roundtrip 与打包验证;后续只有在 CAD runtime 内可独立验证时再作为新增可选产物。
`ansys.geometry.inspect@v1` 继续保留原合同和 adapter专门验证 Mechanical 真实导入后的 GeoData、单位与 Named Selection它至少保留一个发布周期但生命周期标为 deprecated/diagnostic-only并指向中性 CAD 入口。普通几何准备不再推荐占用 ANSYS 许可证,显式诊断请求与历史 Job/旧节点协议不受影响。
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。 Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。
Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建,不把上一版 `.blend` 中的隐式手工状态当作事实源Workspace 继续保存当前与 rollback 两份工程供结果留存和失败恢复,完整 Recipe 保存在 Job 请求中作为续作事实源。为避免旧的回转窑专用状态与新语义混用,本次按用户明确授权直接移除 v2 合同与调度,旧 v2 Workspace 文件不删除但不能作为 v3 source。目标执行版本仍为 Blender 5.2 LTS受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender实际 `bpy` 在 Blender 自带 Python 内执行请求不能提供脚本、命令行、本机路径、URL、插件、driver 或任意 Geometry Nodes。 Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建,不把上一版 `.blend` 中的隐式手工状态当作事实源Workspace 继续保存当前与 rollback 两份工程供结果留存和失败恢复,完整 Recipe 保存在 Job 请求中作为续作事实源。为避免旧的回转窑专用状态与新语义混用,本次按用户明确授权直接移除 v2 合同与调度,旧 v2 Workspace 文件不删除但不能作为 v3 source。目标执行版本仍为 Blender 5.2 LTS受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender实际 `bpy` 在 Blender 自带 Python 内执行请求不能提供脚本、命令行、本机路径、URL、插件、driver 或任意 Geometry Nodes。

View File

@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **09-02 / Unreleased / 中性 CAD 几何准备**:新增无状态 `cad.geometry.prepare@v1`,以独立 `cad` runtime 和固定 CadQuery 2.8.0/OCP 7.9.3.1 生成供 ANSYS/Abaqus 复用的 STEP首版含 inspect、八类基础体和受控 Recipe覆盖显式二维截面、拉伸/旋转/扫掠/放样、布尔、圆角/倒角/抽壳、变换、阵列、STEP import 与多实体组合。固定交付 STEP、规范化 Recipe、几何 manifest、无 GUI 预览和 provenance加入单位、请求/输入/步骤/点/实体/区域/超时与有限数防线,区域选择与 ANSYS v2 语义一致且支持 `expected_count`。Windows Node 新增 runtime-only 软件卡和基本体/STEP roundtrip probe通用合同生命周期让旧 `ansys.geometry.inspect@v1` 停止普通推荐、保留显式诊断与历史兼容。本地数值 oracle、STEP 重开、确定性 manifest、区域与固定打包已验证ANSYS/Abaqus 真实导入仍待目标机验收,未伪造通过。
- **09-02 / Unreleased / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。 - **09-02 / Unreleased / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
- **09-02 / 0.70.0 / 管理与更新日志界面收敛**Admin 顶部“容器状态”直接承载前后台执行、排队、回收、宿主资源与单用户占用,移除重复的独立容量区块,并统一采用“容器依赖”“专业软件节点”用户文案;用户版更新日志只保留可感知变化,公开接口新增向后兼容的 offset 分页元数据,前端每页加载 5 个版本并按需继续加载。 - **09-02 / 0.70.0 / 管理与更新日志界面收敛**Admin 顶部“容器状态”直接承载前后台执行、排队、回收、宿主资源与单用户占用,移除重复的独立容量区块,并统一采用“容器依赖”“专业软件节点”用户文案;用户版更新日志只保留可感知变化,公开接口新增向后兼容的 offset 分页元数据,前端每页加载 5 个版本并按需继续加载。
@ -446,6 +448,7 @@ core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,facto
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段 core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector
core/software_nodes.py ← Windows Node 注册码、身份认证与运行状态 core/software_nodes.py ← Windows Node 注册码、身份认证与运行状态
core/software_contracts.py ← 专业软件合同热加载、生命周期/可见性与 feature 兼容门
core/sinks.py 101 core/sinks.py 101
core/paths.py 50 ← task_dir db form 归一 core/paths.py 50 ← task_dir db form 归一
core/probe.py 243 core/probe.py 243
@ -473,7 +476,8 @@ web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py / admin.py web/broker.py / sinks.py / pptx_render.py / admin.py
web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端 web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端
windows-node/ ← .NET 10 WinForms 托盘节点、注册与登录后自启动 software-contracts/ ← 语言无关专业软件合同(含中性 CAD 与生命周期)
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、runtime-only CAD 与发布脚本
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
───────────────────────────────── ─────────────────────────────────

18
RUN.md
View File

@ -1114,21 +1114,25 @@ Zcbot.WindowsNode.exe
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service也不要求管理员创建计划任务。 Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service也不要求管理员创建计划任务。
专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。依赖默认显式使用清华 PyPI 镜像;如需切换官方源或院内镜像,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。解释器位于 `<data_root>\runtimes\origin|ansys|blender\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON``ZCBOT_ADAPTER_BLENDER_PYTHON` 继续兼容,旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。 专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。既有桌面软件依赖默认显式使用清华 PyPI 镜像;中性 CAD 因二进制 wheel 完整性要求默认使用官方 PyPI。统一覆盖源或切换院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`,但不得绕过 pip 哈希校验。解释器位于 `<data_root>\runtimes\origin|ansys|blender|cad\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON`、`ZCBOT_ADAPTER_BLENDER_PYTHON` 或 `ZCBOT_ADAPTER_CAD_PYTHON` 可指定固定解释器,旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。 数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。三种软件均可用“选择位置”保存当前专用账号的配置,点“自动检测”会清除手工值并重新探测;Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`ANSYS 与 Blender 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。 主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender 和中性 CAD 分别管理隔离 runtime前三者同时管理桌面应用位置中性 CAD 是 runtime-only 软件卡,不显示也不接受“选择位置/自动检测”。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`ANSYS 与 Blender 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过CAD probe 会验证固定依赖、基本体和 STEP roundtrip。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。
Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb` Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`
Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status``editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `<data_root>\workspaces\<workspace-id>\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。 Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status``editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `<data_root>\workspaces\<workspace-id>\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。
ANSYS 能力为 `ansys.geometry.inspect@v1``ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2revision 242。adapter 0.3.0 使用 PyMechanical 0.11.3:前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区,再执行真实静力求解。v2 直接替换旧 v1旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1` ANSYS 能力为 `ansys.geometry.inspect@v1``ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2revision 242。adapter 0.3.0 使用 PyMechanical 0.11.3:前者保留为真实 Mechanical 导入专项诊断,已标记 deprecated/diagnostic-only普通能力枚举会指向 `cad.geometry.prepare@v1`其外部合同、adapter、历史 Job 和明确诊断调用仍兼容至少一个发布周期。后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区再执行真实静力求解。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`
普通几何先提交 `ansys.geometry.inspect@v1``outputs` 传空数组;成功后以 `geometry-manifest.json` 的单位、包围盒、面质心/法向/面积和 `geometry-preview.png` 判断边界区域。规则板件可在静力 v2 中使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`,其中偏置和容差沿用 `analysis.unit_system` 的长度单位;已有 CAD Named Selection 则使用 `{"type":"named_selection","name":"fixed_mount"}`。每个 boundary/load 还必须提供唯一的 ASCII `name`,用于 Mechanical 树、反力结果和溯源。`expected_count` 可省略,但自动化生产请求应尽量提供;数量不符、没有平面、查询为空或名称冲突都会在网格和求解前失败。复杂装配或歧义区域不得只凭预览猜测,应回到 CAD 建立 Named Selection 后再提交。 普通几何先提交 `cad.geometry.prepare@v1``outputs` 传空数组;`inspect` 必须绑定一个已登记 STEP/STP`primitive` 不带输入,`recipe` 可选绑定一个供 `import_step` 引用的输入。三种 feature 都必须显式给 `canonical_unit=mm|m`,不会猜测缺失或歧义 STEP 单位;固定输出为 `geometry.step`、`geometry-recipe.json`、`geometry-manifest.json`、`geometry-preview.png` 和 `.meta/provenance.json`。成功后的 `geometry.step` 会登记为普通 Artifact可直接作为后续 `ansys.mechanical.static_structural@v2` 或 Abaqus capability 的 `geometry` 输入,不新增数据库关系。
CAD primitive 覆盖 box、cylinder、tube、cone/frustum、sphere、wedge、torus。Recipe 只接受合同列出的有序步骤和稳定 ASCII ID基础体、显式三维坐标且共面的二维闭合轮廓、extrude/revolve/sweep/loft、union/cut/intersect、fillet/chamfer/shell、translate/rotate/mirror、linear/circular pattern、STEP import 和 combine。请求上限为 256 KiB、一个 256 MiB STEP、128 步、64 轮廓、每轮廓 512 点、64 区域和 256 个结果实体adapter 超时 15 分钟;数值必须有限且坐标/尺寸受合同范围约束。任何未知引用、退化轮廓、空选区、`expected_count` 不符、无效布尔结果或 STEP roundtrip 数值偏差都会让 Job 清晰失败,不静默跳过。
规则板件可在 CAD 区域和静力 v2 中一致使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`。CAD 还支持按 surface type 选 face、按 curve type 选 edge 和选择全部 solidmanifest 记录的是选择器、数量及几何摘要,不记录 OCCT/Mechanical 内部实体 ID。静力请求中的偏置和容差仍沿用 `analysis.unit_system` 长度单位;已有 CAD Named Selection 可直接使用 `{"type":"named_selection","name":"fixed_mount"}`。复杂装配或歧义区域不得只凭预览猜测,应在明确几何语义或 Named Selection 后再提交。
验收使用安装包自带的 NIST FTC-11 AP203 几何adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录并启动在 ANSYS 软件卡中安装/更新运行环境确认能力仍显示待验收。随后在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”只需选择报告父目录UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project可不受限制地使用文件头保留来源说明。 验收使用安装包自带的 NIST FTC-11 AP203 几何adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录并启动在 ANSYS 软件卡中安装/更新运行环境确认能力仍显示待验收。随后在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”只需选择报告父目录UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project可不受限制地使用文件头保留来源说明。
@ -1165,12 +1169,14 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度、Y 是半径pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind` 常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度、Y 是半径pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind`
专业软件契约的源码事实源位于 `software-contracts/*.json`。zcbot 服务端会在文件变化后校验并热加载完整契约快照正常修改契约不需要重启服务非法或尚未写完的文件不会替换上一份有效快照。Node 发布时把对应事实源复制进 `adapters/<capability>/`,运行时只读取 manifest 同目录指向的合同,不存在共享目录 fallback因此完整 Node 包与独立 adapter 包具有相同结构,更新时必须整体替换 adapter 目录并重启 Node。Node 心跳上报实际合同 SHA-256 和 Workspace 协议版本,服务端不会把 Workspace Job 派给未明确声明支持的节点。manifest 可用 `worker_timeout_minutes` 为每种软件声明 11440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。 专业软件契约的源码事实源位于 `software-contracts/*.json`。zcbot 服务端会在文件变化后校验并热加载完整契约快照,正常修改契约不需要重启服务;非法或尚未写完的文件不会替换上一份有效快照。可选 `lifecycle` 声明状态、普通/诊断可见性及替代 capability缺失时按 active/standard 解释,兼容既有合同。Node 发布时把对应事实源复制进 `adapters/<capability>/`,运行时只读取 manifest 同目录指向的合同,不存在共享目录 fallback因此完整 Node 包与独立 adapter 包具有相同结构,更新时必须整体替换 adapter 目录并重启 Node。Node 心跳上报实际合同 SHA-256 和 Workspace 协议版本,服务端不会把 Workspace Job 派给未明确声明支持的节点。manifest 可用 `worker_timeout_minutes` 为每种软件声明 11440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。 两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。 只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
中性 CAD adapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`,再从“中性 CAD”卡安装/更新固定 `cad` runtimeprobe 必须显示 CadQuery 2.8.0 / OCP 7.9.3.1 且基本体与 STEP roundtrip 可用。若默认官方 PyPI 下载失败,可通过 `ZCBOT_PIP_INDEX_URL` 指向已同步并校验 wheel 的院内源;出现 `THESE PACKAGES DO NOT MATCH THE HASHES` 时应修复镜像缓存,不能添加忽略哈希参数。
Origin adapter 1.1.0 起随包提供固定真机 QA harness但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。 Origin adapter 1.1.0 起随包提供固定真机 QA harness但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。
`origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact`data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X积分输出累计值及可选 `from/to` 区间值线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。 `origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact`data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X积分输出累计值及可选 `from/to` 区间值线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。
@ -1183,7 +1189,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及各自 runtime运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless` 直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及四套独立 runtime中性 CAD 无应用位置配置。该页还可运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。 若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
import math
import re import re
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@ -38,6 +39,14 @@ class WorkspaceSpec:
required_adapter_version: str required_adapter_version: str
@dataclass(frozen=True)
class CapabilityLifecycle:
status: str = "active"
visibility: str = "standard"
replacement: str | None = None
detail: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class CapabilityContract: class CapabilityContract:
capability: str capability: str
@ -52,8 +61,20 @@ class CapabilityContract:
summary: dict[str, Any] summary: dict[str, Any]
legacy_runtime: dict[str, Any] | None legacy_runtime: dict[str, Any] | None
workspace: WorkspaceSpec | None workspace: WorkspaceSpec | None
lifecycle: CapabilityLifecycle
@property
def ordinarily_visible(self) -> bool:
return (
self.lifecycle.status == "active"
and self.lifecycle.visibility == "standard"
)
def normalize_request(self, request: object) -> tuple[dict[str, Any], str]: def normalize_request(self, request: object) -> tuple[dict[str, Any], str]:
if not _all_numbers_finite(request):
raise SoftwareContractError(
f"invalid {self.capability} request: non-finite numbers are forbidden"
)
errors = sorted( errors = sorted(
Draft202012Validator( Draft202012Validator(
self.request_schema, format_checker=FormatChecker() self.request_schema, format_checker=FormatChecker()
@ -65,7 +86,10 @@ class CapabilityContract:
location = ".".join(str(item) for item in first.absolute_path) location = ".".join(str(item) for item in first.absolute_path)
prefix = f"{location}: " if location else "" prefix = f"{location}: " if location else ""
raise SoftwareContractError(f"invalid {self.capability} request: {prefix}{first.message}") raise SoftwareContractError(f"invalid {self.capability} request: {prefix}{first.message}")
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) encoded = json.dumps(
request, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
allow_nan=False,
)
max_bytes = int(self.request_schema.get("x-maxBytes") or 256 * 1024) max_bytes = int(self.request_schema.get("x-maxBytes") or 256 * 1024)
if len(encoded.encode("utf-8")) > max_bytes: if len(encoded.encode("utf-8")) > max_bytes:
raise SoftwareContractError(f"{self.capability} request is too large") raise SoftwareContractError(f"{self.capability} request is too large")
@ -145,6 +169,20 @@ def _value_at(value: object, path: list[str]) -> object:
return current return current
def _all_numbers_finite(value: object) -> bool:
if isinstance(value, bool) or value is None or isinstance(value, str):
return True
if isinstance(value, float):
return math.isfinite(value)
if isinstance(value, int):
return True
if isinstance(value, dict):
return all(_all_numbers_finite(item) for item in value.values())
if isinstance(value, (list, tuple)):
return all(_all_numbers_finite(item) for item in value)
return True
def _resolve_local_refs(value: Any, definitions: dict[str, Any]) -> Any: def _resolve_local_refs(value: Any, definitions: dict[str, Any]) -> Any:
if isinstance(value, list): if isinstance(value, list):
return [_resolve_local_refs(item, definitions) for item in value] return [_resolve_local_refs(item, definitions) for item in value]
@ -171,7 +209,8 @@ def _load_contract(path: Path) -> CapabilityContract:
"input_policy", "outputs", "feature_path", "features", "summary", "legacy_runtime", "input_policy", "outputs", "feature_path", "features", "summary", "legacy_runtime",
"workspace", "workspace",
} }
if not isinstance(raw, dict) or set(raw) != required: optional = {"lifecycle"}
if not isinstance(raw, dict) or not required.issubset(raw) or set(raw) - required - optional:
raise RuntimeError(f"invalid software contract fields: {path.name}") raise RuntimeError(f"invalid software contract fields: {path.name}")
capability = raw["capability"] capability = raw["capability"]
namespace = raw["output_namespace"] namespace = raw["output_namespace"]
@ -234,6 +273,32 @@ def _load_contract(path: Path) -> CapabilityContract:
workspace = WorkspaceSpec( workspace = WorkspaceSpec(
state_output, tuple(preview_outputs), required_adapter_version state_output, tuple(preview_outputs), required_adapter_version
) )
lifecycle_raw = raw.get("lifecycle") or {}
if not isinstance(lifecycle_raw, dict) or set(lifecycle_raw) - {
"status", "visibility", "replacement", "detail"
}:
raise RuntimeError(f"invalid lifecycle spec: {path.name}")
lifecycle = CapabilityLifecycle(
status=lifecycle_raw.get("status", "active"),
visibility=lifecycle_raw.get("visibility", "standard"),
replacement=lifecycle_raw.get("replacement"),
detail=lifecycle_raw.get("detail", ""),
)
if (
lifecycle.status not in {"active", "deprecated"}
or lifecycle.visibility not in {"standard", "diagnostic_only"}
or (
lifecycle.replacement is not None
and (
not isinstance(lifecycle.replacement, str)
or not re.fullmatch(
r"[a-z][a-z0-9_.-]+@v[1-9][0-9]*", lifecycle.replacement
)
)
)
or not isinstance(lifecycle.detail, str)
):
raise RuntimeError(f"unsafe lifecycle spec: {path.name}")
Draft202012Validator.check_schema(raw["request_schema"]) Draft202012Validator.check_schema(raw["request_schema"])
return CapabilityContract( return CapabilityContract(
capability=capability, capability=capability,
@ -248,6 +313,7 @@ def _load_contract(path: Path) -> CapabilityContract:
summary=raw["summary"], summary=raw["summary"],
legacy_runtime=raw["legacy_runtime"], legacy_runtime=raw["legacy_runtime"],
workspace=workspace, workspace=workspace,
lifecycle=lifecycle,
) )
@ -283,6 +349,15 @@ class _ContractRegistry:
raise RuntimeError( raise RuntimeError(
"software contracts are missing or contain duplicate capabilities" "software contracts are missing or contain duplicate capabilities"
) )
for item in loaded:
replacement = item.lifecycle.replacement
if replacement is not None and (
replacement == item.capability or replacement not in contracts
):
raise RuntimeError(
f"software contract has invalid lifecycle replacement: "
f"{item.capability}"
)
except Exception: except Exception:
if initial: if initial:
raise raise

View File

@ -2,6 +2,12 @@
"capability": "ansys.geometry.inspect@v1", "capability": "ansys.geometry.inspect@v1",
"display_name": "ANSYS Mechanical 几何检查", "display_name": "ANSYS Mechanical 几何检查",
"default_enrollment": false, "default_enrollment": false,
"lifecycle": {
"status": "deprecated",
"visibility": "diagnostic_only",
"replacement": "cad.geometry.prepare@v1",
"detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通几何准备请使用中性 CAD 能力。"
},
"output_namespace": "ansys", "output_namespace": "ansys",
"input_policy": { "input_policy": {
"suffixes": [".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"], "suffixes": [".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"],

View File

@ -0,0 +1,345 @@
{
"capability": "cad.geometry.prepare@v1",
"display_name": "中性 CAD 几何准备",
"default_enrollment": false,
"lifecycle": {
"status": "active",
"visibility": "standard",
"replacement": null,
"detail": "生成供 ANSYS、Abaqus 等后续任务复用的中性 STEP。"
},
"output_namespace": "cad",
"input_policy": {
"suffixes": [".step", ".stp"],
"max_count": 1,
"max_bytes": 268435456,
"max_total_bytes": 268435456
},
"outputs": {
"geometry": {
"filename": "geometry.step",
"media_type": "model/step",
"relative_path": "geometry.step",
"publish": true,
"required": true
},
"geometry_recipe": {
"filename": "geometry-recipe.json",
"media_type": "application/json",
"relative_path": "geometry-recipe.json",
"publish": true,
"required": true
},
"geometry_manifest": {
"filename": "geometry-manifest.json",
"media_type": "application/json",
"relative_path": "geometry-manifest.json",
"publish": true,
"required": true
},
"geometry_preview": {
"filename": "geometry-preview.png",
"media_type": "image/png",
"relative_path": "geometry-preview.png",
"publish": true,
"required": true
},
"provenance": {
"filename": "provenance.json",
"media_type": "application/json",
"relative_path": ".meta/provenance.json",
"publish": false,
"required": true
}
},
"feature_path": ["operation", "prepare", "type"],
"features": {
"inspect": "1.0.0",
"primitive": "1.0.0",
"recipe": "1.0.0"
},
"summary": {
"title_path": ["operation", "prepare", "title"]
},
"legacy_runtime": null,
"workspace": null,
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"x-maxBytes": 262144,
"required": ["schema_version", "inputs", "operation", "outputs"],
"additionalProperties": false,
"properties": {
"schema_version": {"const": 1},
"inputs": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"required": ["key", "artifact_id"],
"additionalProperties": false,
"properties": {
"key": {"const": "geometry"},
"artifact_id": {"type": "string", "format": "uuid"}
}
}
},
"operation": {
"type": "object",
"required": ["prepare"],
"additionalProperties": false,
"properties": {
"prepare": {
"oneOf": [
{"$ref": "#/$defs/inspect"},
{"$ref": "#/$defs/primitive_prepare"},
{"$ref": "#/$defs/recipe_prepare"}
]
}
}
},
"outputs": {"type": "array", "maxItems": 0}
},
"$defs": {
"finite": {"type": "number", "minimum": -1000000, "maximum": 1000000},
"positive": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000},
"nonnegative": {"type": "number", "minimum": 0, "maximum": 1000000},
"vector3": {
"type": "array", "minItems": 3, "maxItems": 3,
"items": {"$ref": "#/$defs/finite"}
},
"direction3": {
"type": "array", "minItems": 3, "maxItems": 3,
"items": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
},
"point3": {
"type": "array", "minItems": 3, "maxItems": 3,
"items": {"$ref": "#/$defs/finite"}
},
"expected_count": {
"type": "integer", "minimum": 1, "maximum": 128,
"description": "Exact selected topology count assertion."
},
"extreme_face": {
"type": "object",
"required": ["type", "axis", "side"],
"additionalProperties": false,
"properties": {
"type": {"const": "extreme_face"},
"axis": {"enum": ["x", "y", "z"]},
"side": {"enum": ["min", "max"]},
"tolerance": {"$ref": "#/$defs/nonnegative"},
"angle_tolerance_degrees": {"type": "number", "minimum": 0, "maximum": 15, "default": 5},
"expected_count": {"$ref": "#/$defs/expected_count"}
}
},
"planar_faces": {
"type": "object",
"required": ["type", "normal", "offset", "tolerance"],
"additionalProperties": false,
"properties": {
"type": {"const": "planar_faces"},
"normal": {"$ref": "#/$defs/direction3"},
"offset": {"$ref": "#/$defs/finite"},
"tolerance": {"$ref": "#/$defs/positive"},
"angle_tolerance_degrees": {"type": "number", "minimum": 0, "maximum": 15, "default": 5},
"expected_count": {"$ref": "#/$defs/expected_count"}
}
},
"surface_faces": {
"type": "object",
"required": ["type", "surface"],
"additionalProperties": false,
"properties": {
"type": {"const": "surface_faces"},
"surface": {"enum": ["plane", "cylinder", "cone", "sphere", "torus", "other"]},
"expected_count": {"$ref": "#/$defs/expected_count"}
}
},
"face_selector": {
"oneOf": [
{"$ref": "#/$defs/extreme_face"},
{"$ref": "#/$defs/planar_faces"},
{"$ref": "#/$defs/surface_faces"}
]
},
"edge_selector": {
"type": "object",
"required": ["type", "curve"],
"additionalProperties": false,
"properties": {
"type": {"const": "curve_edges"},
"curve": {"enum": ["line", "circle", "ellipse", "spline", "other"]},
"expected_count": {"$ref": "#/$defs/expected_count"}
}
},
"solid_selector": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {"const": "all_solids"},
"expected_count": {"$ref": "#/$defs/expected_count"}
}
},
"region": {
"type": "object",
"required": ["name", "topology", "selector"],
"additionalProperties": false,
"allOf": [
{"if": {"properties": {"topology": {"const": "face"}}, "required": ["topology"]}, "then": {"properties": {"selector": {"$ref": "#/$defs/face_selector"}}}},
{"if": {"properties": {"topology": {"const": "edge"}}, "required": ["topology"]}, "then": {"properties": {"selector": {"$ref": "#/$defs/edge_selector"}}}},
{"if": {"properties": {"topology": {"const": "solid"}}, "required": ["topology"]}, "then": {"properties": {"selector": {"$ref": "#/$defs/solid_selector"}}}}
],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"},
"topology": {"enum": ["face", "edge", "solid"]},
"selector": {
"oneOf": [
{"$ref": "#/$defs/face_selector"},
{"$ref": "#/$defs/edge_selector"},
{"$ref": "#/$defs/solid_selector"}
]
}
}
},
"primitive": {
"oneOf": [
{
"type": "object", "required": ["shape", "length", "width", "height"], "additionalProperties": false,
"properties": {"shape": {"const": "box"}, "length": {"$ref": "#/$defs/positive"}, "width": {"$ref": "#/$defs/positive"}, "height": {"$ref": "#/$defs/positive"}}
},
{
"type": "object", "required": ["shape", "radius", "height"], "additionalProperties": false,
"properties": {"shape": {"const": "cylinder"}, "radius": {"$ref": "#/$defs/positive"}, "height": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
},
{
"type": "object", "required": ["shape", "radius", "inner_radius", "height"], "additionalProperties": false,
"properties": {"shape": {"const": "tube"}, "radius": {"$ref": "#/$defs/positive"}, "inner_radius": {"$ref": "#/$defs/positive"}, "height": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
},
{
"type": "object", "required": ["shape", "radius", "height"], "additionalProperties": false,
"properties": {"shape": {"const": "cone"}, "radius": {"$ref": "#/$defs/positive"}, "height": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
},
{
"type": "object", "required": ["shape", "radius1", "radius2", "height"], "additionalProperties": false,
"properties": {"shape": {"const": "frustum"}, "radius1": {"$ref": "#/$defs/nonnegative"}, "radius2": {"$ref": "#/$defs/nonnegative"}, "height": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
},
{
"type": "object", "required": ["shape", "radius"], "additionalProperties": false,
"properties": {"shape": {"const": "sphere"}, "radius": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
},
{
"type": "object", "required": ["shape", "x_min", "x_max", "y_min", "y_max", "z_min", "z_max"], "additionalProperties": false,
"properties": {"shape": {"const": "wedge"}, "x_min": {"$ref": "#/$defs/finite"}, "x_max": {"$ref": "#/$defs/finite"}, "y_min": {"$ref": "#/$defs/finite"}, "y_max": {"$ref": "#/$defs/finite"}, "z_min": {"$ref": "#/$defs/finite"}, "z_max": {"$ref": "#/$defs/finite"}}
},
{
"type": "object", "required": ["shape", "major_radius", "minor_radius"], "additionalProperties": false,
"properties": {"shape": {"const": "torus"}, "major_radius": {"$ref": "#/$defs/positive"}, "minor_radius": {"$ref": "#/$defs/positive"}, "angle_degrees": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}}
}
]
},
"profile": {
"type": "object",
"required": ["id", "points"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/name"},
"points": {
"type": "array", "minItems": 3, "maxItems": 512,
"items": {"$ref": "#/$defs/point3"}
}
}
},
"name": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"},
"step": {
"type": "object",
"required": ["id", "op"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/name"},
"op": {"enum": ["box", "cylinder", "tube", "cone", "frustum", "sphere", "wedge", "torus", "import_step", "extrude", "revolve", "sweep", "loft", "union", "cut", "intersect", "fillet", "chamfer", "shell", "translate", "rotate", "mirror", "linear_pattern", "circular_pattern", "combine"]},
"primitive": {"$ref": "#/$defs/primitive"},
"input": {"const": "geometry"},
"profile": {"$ref": "#/$defs/name"},
"profiles": {"type": "array", "minItems": 2, "maxItems": 32, "uniqueItems": true, "items": {"$ref": "#/$defs/name"}},
"target": {"$ref": "#/$defs/name"},
"targets": {"type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": {"$ref": "#/$defs/name"}},
"tools": {"type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": {"$ref": "#/$defs/name"}},
"vector": {"$ref": "#/$defs/vector3"},
"axis_start": {"$ref": "#/$defs/point3"},
"axis_end": {"$ref": "#/$defs/point3"},
"angle_degrees": {"type": "number", "exclusiveMinimum": -360, "maximum": 360},
"path": {"type": "array", "minItems": 2, "maxItems": 512, "items": {"$ref": "#/$defs/point3"}},
"selector": {"oneOf": [{"$ref": "#/$defs/face_selector"}, {"$ref": "#/$defs/edge_selector"}]},
"radius": {"$ref": "#/$defs/positive"},
"distance": {"$ref": "#/$defs/positive"},
"thickness": {"$ref": "#/$defs/positive"},
"plane": {"enum": ["XY", "XZ", "YZ"]},
"count": {"type": "integer", "minimum": 2, "maximum": 128},
"spacing": {"$ref": "#/$defs/positive"},
"center": {"$ref": "#/$defs/point3"}
}
},
"base_prepare": {
"type": "object",
"required": ["type", "title", "canonical_unit", "regions"],
"properties": {
"type": {"enum": ["inspect", "primitive", "recipe"]},
"title": {"type": "string", "minLength": 1, "maxLength": 160},
"canonical_unit": {"enum": ["mm", "m"]},
"regions": {"type": "array", "maxItems": 64, "items": {"$ref": "#/$defs/region"}}
}
},
"inspect": {
"allOf": [
{"$ref": "#/$defs/base_prepare"},
{
"type": "object",
"required": ["type", "title", "canonical_unit", "regions", "geometry"],
"additionalProperties": false,
"properties": {
"type": {"const": "inspect"},
"title": true, "canonical_unit": true, "regions": true,
"geometry": {"type": "object", "required": ["input"], "additionalProperties": false, "properties": {"input": {"const": "geometry"}}}
}
}
]
},
"primitive_prepare": {
"allOf": [
{"$ref": "#/$defs/base_prepare"},
{
"type": "object",
"required": ["type", "title", "canonical_unit", "regions", "primitive"],
"additionalProperties": false,
"properties": {"type": {"const": "primitive"}, "title": true, "canonical_unit": true, "regions": true, "primitive": {"$ref": "#/$defs/primitive"}}
}
]
},
"recipe_prepare": {
"allOf": [
{"$ref": "#/$defs/base_prepare"},
{
"type": "object",
"required": ["type", "title", "canonical_unit", "regions", "recipe"],
"additionalProperties": false,
"properties": {
"type": {"const": "recipe"}, "title": true, "canonical_unit": true, "regions": true,
"recipe": {
"type": "object",
"required": ["profiles", "steps", "result"],
"additionalProperties": false,
"properties": {
"profiles": {"type": "array", "maxItems": 64, "items": {"$ref": "#/$defs/profile"}},
"steps": {"type": "array", "minItems": 1, "maxItems": 128, "items": {"$ref": "#/$defs/step"}},
"result": {"$ref": "#/$defs/name"}
}
}
}
}
]
}
}
}
}

389
tests/test_cad_adapter.py Normal file
View File

@ -0,0 +1,389 @@
from __future__ import annotations
import importlib.util
import json
import math
import tempfile
import unittest
from pathlib import Path
from uuid import uuid4
from core.software_contracts import SoftwareContractError, get_contract
ROOT = Path(__file__).resolve().parents[1]
WORKER_PATH = (
ROOT / "windows-node" / "adapters" / "cad.geometry.prepare@v1" / "worker.py"
)
SPEC = importlib.util.spec_from_file_location("zcbot_cad_worker", WORKER_PATH)
assert SPEC and SPEC.loader
worker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(worker)
HAS_CADQUERY = importlib.util.find_spec("cadquery") is not None
def _request(feature: str, definition: dict, *, regions: list[dict] | None = None) -> dict:
prepare = {
"type": feature,
"title": f"CAD {feature}",
"canonical_unit": "mm",
"regions": regions or [],
}
prepare.update(definition)
return {
"schema_version": 1,
"inputs": [],
"operation": {"prepare": prepare},
"outputs": [],
}
def _primitive(shape: str, **parameters) -> dict:
return _request("primitive", {"primitive": {"shape": shape, **parameters}})
class CadContractTests(unittest.TestCase):
def test_contract_exposes_fixed_features_outputs_and_quotas(self) -> None:
contract = get_contract("cad.geometry.prepare@v1")
self.assertTrue(contract.ordinarily_visible)
self.assertEqual(set(contract.features), {"inspect", "primitive", "recipe"})
self.assertEqual(contract.input_policy["suffixes"], [".step", ".stp"])
request = _primitive("box", length=10, width=20, height=30)
normalized, digest = contract.normalize_request(request)
self.assertEqual(contract.feature(normalized), "primitive")
self.assertEqual(len(digest), 64)
self.assertEqual(
set(contract.expected_outputs(normalized)),
{"geometry", "geometry_recipe", "geometry_manifest", "geometry_preview", "provenance"},
)
geometry_output = contract.outputs["geometry"]
self.assertEqual(geometry_output.filename, "geometry.step")
ansys = get_contract("ansys.mechanical.static_structural@v2")
self.assertIn(Path(geometry_output.filename).suffix, ansys.input_policy["suffixes"])
def test_contract_rejects_scripts_paths_nonfinite_and_missing_units(self) -> None:
contract = get_contract("cad.geometry.prepare@v1")
for key, value in (("script", "anything"), ("path", "C:/secret.step")):
request = _primitive("box", length=10, width=20, height=30)
request["operation"]["prepare"][key] = value
with self.assertRaises(SoftwareContractError):
contract.normalize_request(request)
request = _primitive("box", length=math.inf, width=20, height=30)
with self.assertRaises(SoftwareContractError):
contract.normalize_request(request)
request = _primitive("box", length=math.nan, width=20, height=30)
with self.assertRaisesRegex(SoftwareContractError, "non-finite"):
contract.normalize_request(request)
request = _primitive("box", length=10, width=20, height=30)
del request["operation"]["prepare"]["canonical_unit"]
with self.assertRaises(SoftwareContractError):
contract.normalize_request(request)
for primitive in (
{"shape": "box", "length": 10, "width": 20},
{"shape": "box", "length": 10, "width": 20, "height": 30, "radius": 2},
):
with self.assertRaises(SoftwareContractError):
contract.normalize_request(_request("primitive", {"primitive": primitive}))
def test_ansys_and_cad_planar_selector_contracts_stay_consistent(self) -> None:
cad = get_contract("cad.geometry.prepare@v1").request_schema["$defs"]
ansys = get_contract("ansys.mechanical.static_structural@v2").request_schema["$defs"]
def resolved(value: object, definitions: dict) -> object:
if isinstance(value, list):
return [resolved(item, definitions) for item in value]
if not isinstance(value, dict):
return value
if set(value) == {"$ref"}:
return resolved(definitions[value["$ref"].split("/")[-1]], definitions)
return {
key: resolved(item, definitions)
for key, item in value.items()
if key != "description"
}
for name in ("extreme_face", "planar_faces"):
ansys_name = f"{name}_scope"
self.assertEqual(
resolved(cad[name], cad),
resolved(ansys[ansys_name], ansys),
)
def test_ansys_inspect_is_hidden_diagnostic_with_replacement(self) -> None:
contract = get_contract("ansys.geometry.inspect@v1")
self.assertFalse(contract.ordinarily_visible)
self.assertEqual(contract.lifecycle.status, "deprecated")
self.assertEqual(contract.lifecycle.visibility, "diagnostic_only")
self.assertEqual(contract.lifecycle.replacement, "cad.geometry.prepare@v1")
@unittest.skipUnless(HAS_CADQUERY, "fixed CAD runtime is not installed in project test environment")
class CadKernelTests(unittest.TestCase):
def test_all_required_primitives_have_numeric_oracles(self) -> None:
cases = [
({"shape": "box", "length": 2, "width": 3, "height": 5}, 30.0),
({"shape": "cylinder", "radius": 2, "height": 5}, 20 * math.pi),
({"shape": "tube", "radius": 3, "inner_radius": 2, "height": 5}, 25 * math.pi),
({"shape": "cone", "radius": 3, "height": 4}, 12 * math.pi),
({"shape": "frustum", "radius1": 3, "radius2": 1, "height": 4}, 52 * math.pi / 3),
({"shape": "sphere", "radius": 3}, 36 * math.pi),
({"shape": "wedge", "x_min": 0, "x_max": 4, "y_min": 0, "y_max": 3, "z_min": 0, "z_max": 2}, 12.0),
({"shape": "torus", "major_radius": 5, "minor_radius": 2}, 40 * math.pi**2),
]
for spec, expected in cases:
with self.subTest(spec["shape"]):
shape = worker._primitive(spec)
self.assertAlmostEqual(shape.Volume(), expected, delta=max(1e-8, expected * 1e-8))
def test_recipe_builds_plate_hole_notch_i_beam_and_multi_body(self) -> None:
plate = {
"profiles": [],
"steps": [
{"id": "plate", "op": "box", "primitive": {"shape": "box", "length": 100, "width": 60, "height": 10}},
{"id": "hole", "op": "cylinder", "primitive": {"shape": "cylinder", "radius": 10, "height": 10}},
{"id": "centered_hole", "op": "translate", "target": "hole", "vector": [50, 30, 0]},
{"id": "perforated", "op": "cut", "target": "plate", "tools": ["centered_hole"]},
{"id": "notch", "op": "box", "primitive": {"shape": "box", "length": 20, "width": 20, "height": 10}},
{"id": "placed_notch", "op": "translate", "target": "notch", "vector": [80, 40, 0]},
{"id": "result", "op": "cut", "target": "perforated", "tools": ["placed_notch"]},
],
"result": "result",
}
shape = worker._recipe(plate, None, "mm")
self.assertAlmostEqual(
shape.Volume(),
100 * 60 * 10 - math.pi * 10**2 * 10 - 20 * 20 * 10,
delta=1e-6,
)
i_beam = {
"profiles": [{"id": "i", "points": [[0, 0, 0], [60, 0, 0], [60, 10, 0], [35, 10, 0], [35, 90, 0], [60, 90, 0], [60, 100, 0], [0, 100, 0], [0, 90, 0], [25, 90, 0], [25, 10, 0], [0, 10, 0]]}],
"steps": [{"id": "beam", "op": "extrude", "profile": "i", "vector": [0, 0, 200]}],
"result": "beam",
}
beam = worker._recipe(i_beam, None, "mm")
self.assertAlmostEqual(beam.Volume(), 2000 * 200, delta=1e-6)
multi = {
"profiles": [],
"steps": [
{"id": "coupon", "op": "box", "primitive": {"shape": "box", "length": 10, "width": 10, "height": 20}},
{"id": "top", "op": "translate", "target": "coupon", "vector": [0, 0, 30]},
{"id": "assembly", "op": "combine", "targets": ["coupon", "top"]},
],
"result": "assembly",
}
assembly = worker._recipe(multi, None, "mm")
self.assertEqual(len(assembly.Solids()), 2)
def test_regions_manifest_and_step_roundtrip_are_deterministic(self) -> None:
shape = worker._primitive({"shape": "box", "length": 10, "width": 20, "height": 30})
regions = [{"name": "fixed", "topology": "face", "selector": {"type": "extreme_face", "axis": "z", "side": "min", "expected_count": 1}}]
resolved = worker._regions(shape, regions)
prepare = _primitive("box", length=10, width=20, height=30)["operation"]["prepare"]
first = worker._manifest(shape, prepare, resolved, None)
second = worker._manifest(shape, prepare, resolved, None)
self.assertEqual(first, second)
self.assertEqual(first["topology"]["solids"], 1)
self.assertEqual(first["regions"][0]["count"], 1)
self.assertEqual(first["bounding_box"]["size"], [10.0, 20.0, 30.0])
self.assertAlmostEqual(first["area"], 2200.0, delta=1e-8)
self.assertEqual(first["center_of_mass"], [5.0, 10.0, 15.0])
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "geometry.step"
worker._export_step(shape, path, "mm")
worker._require_step_unit(path)
import cadquery as cq
reopened = cq.importers.importStep(str(path), unit="MM").val()
self.assertAlmostEqual(reopened.Volume(), 6000.0, delta=1e-6)
def test_advanced_recipe_operations_fail_loudly_or_produce_valid_solids(self) -> None:
cases = {
"revolve": {
"profiles": [{"id": "section", "points": [[2, 0, 0], [3, 0, 0], [3, 0, 5], [2, 0, 5]]}],
"steps": [{"id": "result", "op": "revolve", "profile": "section", "axis_start": [0, 0, 0], "axis_end": [0, 0, 1], "angle_degrees": 360}],
"result": "result",
},
"sweep": {
"profiles": [{"id": "section", "points": [[0, -1, -1], [0, 1, -1], [0, 1, 1], [0, -1, 1]]}],
"steps": [{"id": "result", "op": "sweep", "profile": "section", "path": [[0, 0, 0], [10, 0, 0]]}],
"result": "result",
},
"loft": {
"profiles": [
{"id": "a", "points": [[-2, -2, 0], [2, -2, 0], [2, 2, 0], [-2, 2, 0]]},
{"id": "b", "points": [[-1, -1, 10], [1, -1, 10], [1, 1, 10], [-1, 1, 10]]},
],
"steps": [{"id": "result", "op": "loft", "profiles": ["a", "b"]}],
"result": "result",
},
"fillet": {
"profiles": [],
"steps": [
{"id": "base", "op": "box", "primitive": {"shape": "box", "length": 10, "width": 10, "height": 10}},
{"id": "result", "op": "fillet", "target": "base", "selector": {"type": "curve_edges", "curve": "line", "expected_count": 12}, "radius": 1},
],
"result": "result",
},
"chamfer": {
"profiles": [],
"steps": [
{"id": "base", "op": "box", "primitive": {"shape": "box", "length": 10, "width": 10, "height": 10}},
{"id": "result", "op": "chamfer", "target": "base", "selector": {"type": "curve_edges", "curve": "line", "expected_count": 12}, "distance": 0.5},
],
"result": "result",
},
"shell": {
"profiles": [],
"steps": [
{"id": "base", "op": "box", "primitive": {"shape": "box", "length": 10, "width": 10, "height": 10}},
{"id": "result", "op": "shell", "target": "base", "selector": {"type": "extreme_face", "axis": "z", "side": "max", "expected_count": 1}, "thickness": 1},
],
"result": "result",
},
"mirror": {
"profiles": [],
"steps": [
{"id": "base", "op": "box", "primitive": {"shape": "box", "length": 2, "width": 3, "height": 4}},
{"id": "result", "op": "mirror", "target": "base", "plane": "YZ"},
],
"result": "result",
},
"linear_pattern": {
"profiles": [],
"steps": [
{"id": "base", "op": "box", "primitive": {"shape": "box", "length": 2, "width": 2, "height": 2}},
{"id": "result", "op": "linear_pattern", "target": "base", "vector": [1, 0, 0], "count": 3, "spacing": 4},
],
"result": "result",
},
}
for name, recipe in cases.items():
with self.subTest(name):
shape = worker._recipe(recipe, None, "mm")
self.assertTrue(shape.isValid())
self.assertGreater(shape.Volume(), 0)
invalid = cases["fillet"]
invalid = json.loads(json.dumps(invalid))
invalid["steps"][1]["selector"]["expected_count"] = 11
with self.assertRaisesRegex(RuntimeError, "GEOMETRY_SCOPE_COUNT_MISMATCH"):
worker._recipe(invalid, None, "mm")
def test_flange_circular_holes_and_circular_pattern(self) -> None:
recipe = {
"profiles": [],
"steps": [
{"id": "flange", "op": "cylinder", "primitive": {"shape": "cylinder", "radius": 20, "height": 5}},
{"id": "hole", "op": "cylinder", "primitive": {"shape": "cylinder", "radius": 2, "height": 5}},
{"id": "offset_hole", "op": "translate", "target": "hole", "vector": [12, 0, 0]},
{"id": "holes", "op": "circular_pattern", "target": "offset_hole", "axis_start": [0, 0, 0], "axis_end": [0, 0, 1], "count": 6, "angle_degrees": 360},
{"id": "result", "op": "cut", "target": "flange", "tools": ["holes"]},
],
"result": "result",
}
shape = worker._recipe(recipe, None, "mm")
expected = math.pi * 20**2 * 5 - 6 * math.pi * 2**2 * 5
self.assertAlmostEqual(shape.Volume(), expected, delta=expected * 1e-8)
def test_full_job_emits_fixed_package_and_preview(self) -> None:
request = _request(
"primitive",
{"primitive": {"shape": "cylinder", "radius": 4, "height": 12}},
regions=[{"name": "ends", "topology": "face", "selector": {"type": "surface_faces", "surface": "plane", "expected_count": 2}}],
)
get_contract("cad.geometry.prepare@v1").normalize_request(request)
def execute() -> dict:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "request").mkdir()
(root / "request" / "request.json").write_text(
json.dumps(
{
"job_id": str(uuid4()),
"lease_id": str(uuid4()),
"request_digest": "a" * 64,
"request": request,
}
),
encoding="utf-8",
)
artifacts = worker.run(root)
self.assertEqual(
{item["filename"] for item in artifacts}, set(worker.OUTPUT_MEDIA)
)
self.assertGreater(
(root / "output" / "geometry-preview.png").stat().st_size,
1000,
)
return json.loads(
(root / "output" / "geometry-manifest.json").read_text(
encoding="utf-8"
)
)
first = execute()
second = execute()
self.assertEqual(first, second)
self.assertAlmostEqual(first["volume"], math.pi * 4**2 * 12, delta=1e-6)
def test_external_step_inspect_and_recipe_import(self) -> None:
import cadquery as cq
source = cq.Solid.makeBox(7, 11, 13)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_dir = root / "input" / "geometry"
request_dir = root / "request"
input_dir.mkdir(parents=True)
request_dir.mkdir()
source_path = input_dir / "external.step"
worker._export_step(source, source_path, "mm")
for feature, definition in (
("inspect", {"geometry": {"input": "geometry"}}),
(
"recipe",
{
"recipe": {
"profiles": [],
"steps": [
{"id": "imported", "op": "import_step", "input": "geometry"},
{"id": "moved", "op": "translate", "target": "imported", "vector": [20, 0, 0]},
{"id": "result", "op": "combine", "targets": ["imported", "moved"]},
],
"result": "result",
}
},
),
):
request = _request(feature, definition)
request["inputs"] = [
{"key": "geometry", "artifact_id": str(uuid4())}
]
(request_dir / "request.json").write_text(
json.dumps(
{
"job_id": str(uuid4()),
"lease_id": str(uuid4()),
"request_digest": "b" * 64,
"request": request,
}
),
encoding="utf-8",
)
artifacts = worker.run(root)
self.assertEqual(len(artifacts), 5)
manifest = json.loads(
(root / "output" / "geometry-manifest.json").read_text(
encoding="utf-8"
)
)
expected_volume = 7 * 11 * 13 * (2 if feature == "recipe" else 1)
self.assertAlmostEqual(
manifest["volume"], expected_volume, delta=1e-6
)
if __name__ == "__main__":
unittest.main()

View File

@ -42,16 +42,24 @@ class SoftwareJobToolTests(unittest.TestCase):
) )
self.assertEqual(result["capabilities"][0]["capability"], "origin.plot@v2") self.assertEqual(result["capabilities"][0]["capability"], "origin.plot@v2")
self.assertEqual(result["capabilities"][0]["available_nodes"], 1) self.assertEqual(result["capabilities"][0]["available_nodes"], 1)
self.assertEqual( capabilities = {item["capability"] for item in result["capabilities"]}
result["capabilities"][1]["capability"], self.assertIn("cad.geometry.prepare@v1", capabilities)
"ansys.geometry.inspect@v1", self.assertIn("ansys.mechanical.static_structural@v2", capabilities)
self.assertNotIn("ansys.geometry.inspect@v1", capabilities)
with patch("tools.software_jobs.list_nodes", return_value=nodes):
diagnostic = json.loads(
SoftwareCapabilityListTool(self.user_id, self.task_id).execute(
include_diagnostic=True
) )
self.assertEqual(result["capabilities"][1]["available_nodes"], 0)
self.assertEqual(
result["capabilities"][2]["capability"],
"ansys.mechanical.static_structural@v2",
) )
self.assertEqual(result["capabilities"][2]["available_nodes"], 0) inspect = next(
item for item in diagnostic["capabilities"]
if item["capability"] == "ansys.geometry.inspect@v1"
)
self.assertEqual(inspect["lifecycle"], "deprecated")
self.assertEqual(inspect["visibility"], "diagnostic_only")
self.assertEqual(inspect["replacement"], "cad.geometry.prepare@v1")
def test_submit_injects_current_user_and_task(self): def test_submit_injects_current_user_and_task(self):
created = {"job_id": str(uuid4()), "status": "queued"} created = {"job_id": str(uuid4()), "status": "queued"}

View File

@ -88,6 +88,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
"ansys.geometry.inspect@v1", "ansys.geometry.inspect@v1",
"ansys.mechanical.static_structural@v2", "ansys.mechanical.static_structural@v2",
"blender.scene.author@v3", "blender.scene.author@v3",
"cad.geometry.prepare@v1",
}, },
) )
self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities)) self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities))

View File

@ -329,8 +329,12 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('IsReadOnly="True"', xaml) self.assertIn('IsReadOnly="True"', xaml)
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service) self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
self.assertIn("item.HasActiveJobs", service) self.assertIn("item.HasActiveJobs", service)
for runtime_id in ('"origin"', '"ansys"', '"blender"'): for runtime_id in ('"origin"', '"ansys"', '"blender"', '"cad"'):
self.assertIn(runtime_id, catalog) self.assertIn(runtime_id, catalog)
self.assertIn("SoftwarePathKind.None", catalog)
self.assertIn('"https://pypi.org/simple/"', catalog)
self.assertIn("definition.DefaultIndexUrl ?? DefaultIndexUrl", installer)
self.assertIn("runtime-only 软件不接受应用路径", manager)
self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager) self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
self.assertIn("RegistryHive.LocalMachine", manager) self.assertIn("RegistryHive.LocalMachine", manager)
self.assertIn("RegistryHive.CurrentUser", manager) self.assertIn("RegistryHive.CurrentUser", manager)
@ -491,6 +495,14 @@ class WindowsNodeSourceTests(unittest.TestCase):
"adapters\\blender.scene.author@v3\\blender.scene.author.v3.json", "adapters\\blender.scene.author@v3\\blender.scene.author.v3.json",
project, project,
) )
self.assertIn(
"..\\..\\software-contracts\\cad.geometry.prepare.v1.json",
project,
)
self.assertIn(
"adapters\\cad.geometry.prepare@v1\\cad.geometry.prepare.v1.json",
project,
)
self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project) self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
self.assertFalse((ROOT / "install-windows-node.ps1").exists()) self.assertFalse((ROOT / "install-windows-node.ps1").exists())
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists()) self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
@ -776,6 +788,23 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("tar.exe -a -c -f", script) self.assertIn("tar.exe -a -c -f", script)
self.assertNotIn("dotnet", script.lower()) self.assertNotIn("dotnet", script.lower())
def test_cad_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-cad-adapter.bat").read_text(encoding="utf-8")
worker = (
ROOT / "adapters" / "cad.geometry.prepare@v1" / "worker.py"
).read_text(encoding="utf-8")
self.assertIn("adapters\\cad.geometry.prepare@v1\\adapter.json", script)
self.assertIn("adapters\\cad.geometry.prepare@v1\\worker.py", script)
self.assertIn("adapters\\cad.geometry.prepare@v1\\requirements.txt", script)
self.assertIn("..\\software-contracts\\cad.geometry.prepare.v1.json", script)
self.assertIn("tar.exe -a -c -f", script)
self.assertNotIn("dotnet", script.lower())
self.assertIn("INPUT_PATH_ESCAPES_JOB", worker)
self.assertIn("OUTPUT_PATH_ESCAPES_JOB", worker)
self.assertIn("STEP_ROUNDTRIP_VOLUME_MISMATCH", worker)
for forbidden in ("subprocess", "eval(", "exec(", "os.system", "requests."):
self.assertNotIn(forbidden, worker)
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None: def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
monitor = ( monitor = (
PROJECT / "Application" / "JobMonitorService.cs" PROJECT / "Application" / "JobMonitorService.cs"

View File

@ -49,13 +49,25 @@ class SoftwareCapabilityListTool(_SoftwareJobTool):
"Use this tool to answer whether software is available; checking availability must " "Use this tool to answer whether software is available; checking availability must "
"not submit a software job." "not submit a software job."
) )
parameters = {"type": "object", "properties": {}, "additionalProperties": False} parameters = {
"type": "object",
"properties": {
"include_diagnostic": {
"type": "boolean",
"description": "Include deprecated diagnostic-only capabilities when explicitly needed.",
}
},
"additionalProperties": False,
}
def execute(self) -> str: def execute(self, include_diagnostic: bool = False) -> str:
nodes = list_nodes() nodes = list_nodes()
items = [{ items = [{
"capability": item, "capability": item,
"display_name": get_contract(item).display_name, "display_name": get_contract(item).display_name,
"lifecycle": get_contract(item).lifecycle.status,
"visibility": get_contract(item).lifecycle.visibility,
"replacement": get_contract(item).lifecycle.replacement,
"available_nodes": sum( "available_nodes": sum(
1 1
for node in nodes for node in nodes
@ -63,7 +75,8 @@ class SoftwareCapabilityListTool(_SoftwareJobTool):
and item in (node.get("capabilities") or []) and item in (node.get("capabilities") or [])
and node_available_slots(item, node.get("runtime") or {}) > 0 and node_available_slots(item, node.get("runtime") or {}) > 0
), ),
} for item in supported_capabilities()] } for item in supported_capabilities()
if include_diagnostic or get_contract(item).ordinarily_visible]
items.sort(key=lambda item: (-item["available_nodes"], item["capability"])) items.sort(key=lambda item: (-item["available_nodes"], item["capability"]))
return json.dumps({"capabilities": items}, ensure_ascii=False) return json.dumps({"capabilities": items}, ensure_ascii=False)

View File

@ -1,3 +1,5 @@
using System.Diagnostics;
namespace Zcbot.WindowsNode.Tests; namespace Zcbot.WindowsNode.Tests;
public sealed class AdapterArchitectureTests public sealed class AdapterArchitectureTests
@ -55,10 +57,26 @@ public sealed class AdapterArchitectureTests
async () => await gate.EnterAsync(stop.Token)); async () => await gate.EnterAsync(stop.Token));
} }
[Fact]
public async Task ProcessSupervisorTerminatesAWorkerProcessTree()
{
var supervisor = new ProcessSupervisor();
using var process = supervisor.Start(new ProcessStartInfo(
"cmd.exe", "/d /c ping 127.0.0.1 -t")
{
UseShellExecute = false,
CreateNoWindow = true,
});
await supervisor.TerminateTreeAsync(process);
Assert.True(process.HasExited);
}
[Fact] [Fact]
public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers() public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers()
{ {
Assert.Equal(["origin", "ansys", "blender"], Assert.Equal(["origin", "ansys", "blender", "cad"],
SoftwareCatalog.Definitions.Select(item => item.RuntimeId)); SoftwareCatalog.Definitions.Select(item => item.RuntimeId));
Assert.Equal("ZCBOT_ORIGIN_EXE", Assert.Equal("ZCBOT_ORIGIN_EXE",
SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable); SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable);
@ -66,6 +84,10 @@ public sealed class AdapterArchitectureTests
SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable); SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable);
Assert.Equal("ZCBOT_BLENDER_EXE", Assert.Equal("ZCBOT_BLENDER_EXE",
SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable); SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable);
var cad = SoftwareCatalog.ByRuntimeId("cad");
Assert.Equal(SoftwarePathKind.None, cad.PathKind);
Assert.Null(cad.EnvironmentVariable);
Assert.Equal("https://pypi.org/simple/", cad.DefaultIndexUrl);
} }
[Fact] [Fact]

View File

@ -58,6 +58,24 @@ public sealed class SoftwarePageViewModelTests
Assert.Equal("blender", service.InstalledId); Assert.Equal("blender", service.InstalledId);
} }
[Fact]
public async Task RuntimeOnlyCardInstallsWithoutOfferingApplicationLocation()
{
var service = new FakeSoftwareManagementService(
new SoftwareManagementSnapshot(
"cad", "中性 CAD", SoftwarePathKind.None, "", true,
"无需桌面应用位置", "运行环境尚未安装", false, false));
using var viewModel = new SoftwarePageViewModel(service);
viewModel.ConfirmInstallRequested += _ => true;
await viewModel.RefreshAsync();
var card = Assert.Single(viewModel.Cards);
Assert.False(card.HasApplicationLocation);
Assert.False(card.ChooseLocationCommand.CanExecute(null));
Assert.False(card.ClearLocationCommand.CanExecute(null));
Assert.True(card.InstallRuntimeCommand.CanExecute(null));
}
[Fact] [Fact]
public async Task CancelInstall_CancelsTheInFlightRuntimeOperation() public async Task CancelInstall_CancelsTheInFlightRuntimeOperation()
{ {

View File

@ -229,6 +229,9 @@
<DataTrigger Binding="{Binding LocationAvailable}" Value="True"> <DataTrigger Binding="{Binding LocationAvailable}" Value="True">
<Setter Property="Background" Value="{StaticResource SuccessSurfaceBrush}" /> <Setter Property="Background" Value="{StaticResource SuccessSurfaceBrush}" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding HasApplicationLocation}" Value="False">
<Setter Property="Background" Value="{StaticResource SuccessSurfaceBrush}" />
</DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
@ -242,6 +245,10 @@
<Setter Property="Text" Value="已检测" /> <Setter Property="Text" Value="已检测" />
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}" /> <Setter Property="Foreground" Value="{StaticResource SuccessBrush}" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding HasApplicationLocation}" Value="False">
<Setter Property="Text" Value="无需配置" />
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</TextBlock.Style> </TextBlock.Style>
@ -255,8 +262,10 @@
Foreground="{StaticResource MutedTextBrush}" Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<WrapPanel Margin="0,16,0,0"> <WrapPanel Margin="0,16,0,0">
<Button Content="选择位置" Command="{Binding ChooseLocationCommand}" /> <Button Content="选择位置" Command="{Binding ChooseLocationCommand}"
<Button Content="自动检测" Command="{Binding ClearLocationCommand}" /> Visibility="{Binding HasApplicationLocation, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Content="自动检测" Command="{Binding ClearLocationCommand}"
Visibility="{Binding HasApplicationLocation, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Content="安装 / 更新环境" <Button Content="安装 / 更新环境"
Command="{Binding InstallRuntimeCommand}" Command="{Binding InstallRuntimeCommand}"
Style="{StaticResource PrimaryButtonStyle}" /> Style="{StaticResource PrimaryButtonStyle}" />

View File

@ -98,7 +98,9 @@ internal sealed class SoftwareManagementService : ISoftwareManagementService
definition.PathKind, definition.PathKind,
definition.RequiredRelativePath ?? string.Empty, definition.RequiredRelativePath ?? string.Empty,
location.Available, location.Available,
location.Path ?? "未检测到应用位置", definition.PathKind == SoftwarePathKind.None
? "无需桌面应用位置"
: location.Path ?? "未检测到应用位置",
status, status,
runtimeInstalled, runtimeInstalled,
hasActiveJobs); hasActiveJobs);

View File

@ -43,6 +43,7 @@ internal sealed class SoftwareCardViewModel : ObservableObject
} }
public bool LocationAvailable => snapshot.LocationAvailable; public bool LocationAvailable => snapshot.LocationAvailable;
public bool HasApplicationLocation => snapshot.PathKind != SoftwarePathKind.None;
public bool RuntimeInstalled => snapshot.RuntimeInstalled; public bool RuntimeInstalled => snapshot.RuntimeInstalled;
public bool HasActiveJobs => snapshot.HasActiveJobs; public bool HasActiveJobs => snapshot.HasActiveJobs;
internal bool IsPageBusy { get; set; } internal bool IsPageBusy { get; set; }
@ -69,7 +70,7 @@ internal sealed class SoftwareCardViewModel : ObservableObject
((AsyncCommand)InstallRuntimeCommand).RaiseCanExecuteChanged(); ((AsyncCommand)InstallRuntimeCommand).RaiseCanExecuteChanged();
} }
private bool CanChange() => !IsPageBusy; private bool CanChange() => !IsPageBusy && HasApplicationLocation;
private bool CanInstall() => !IsPageBusy && LocationAvailable && !HasActiveJobs; private bool CanInstall() => !IsPageBusy && LocationAvailable && !HasActiveJobs;
} }

View File

@ -62,7 +62,7 @@ internal static class ManagedRuntimeInstaller
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL"); var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl)) if (string.IsNullOrWhiteSpace(indexUrl))
{ {
indexUrl = DefaultIndexUrl; indexUrl = definition.DefaultIndexUrl ?? DefaultIndexUrl;
} }
await RunAsync( await RunAsync(
stagingPython, stagingPython,

View File

@ -28,6 +28,15 @@ internal static class SoftwareCatalog
"ZCBOT_BLENDER_EXE", "ZCBOT_BLENDER_EXE",
"blender.exe", "blender.exe",
@"adapters\blender.scene.author@v3\requirements.txt"), @"adapters\blender.scene.author@v3\requirements.txt"),
new(
"cad",
"中性 CAD",
"cad",
SoftwarePathKind.None,
null,
null,
@"adapters\cad.geometry.prepare@v1\requirements.txt",
"https://pypi.org/simple/"),
]; ];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) => internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>

View File

@ -11,6 +11,10 @@ internal static class SoftwareLocationService
internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition) internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition)
{ {
if (definition.PathKind == SoftwarePathKind.None)
{
return new SoftwareLocation(true, null, "固定运行环境", "无需桌面应用位置。");
}
var configured = ReadConfiguredPath(definition.Id); var configured = ReadConfiguredPath(definition.Id);
if (!string.IsNullOrWhiteSpace(configured)) if (!string.IsNullOrWhiteSpace(configured))
{ {
@ -41,6 +45,10 @@ internal static class SoftwareLocationService
internal static void SaveConfiguredPath(SoftwareDefinition definition, string path) internal static void SaveConfiguredPath(SoftwareDefinition definition, string path)
{ {
if (definition.PathKind == SoftwarePathKind.None)
{
throw new InvalidOperationException("runtime-only 软件不接受应用路径配置。");
}
var location = Location(definition, path, "界面配置", strict: true); var location = Location(definition, path, "界面配置", strict: true);
if (!location.Available || location.Path is null) if (!location.Available || location.Path is null)
{ {
@ -53,6 +61,7 @@ internal static class SoftwareLocationService
internal static void ClearConfiguredPath(SoftwareDefinition definition) internal static void ClearConfiguredPath(SoftwareDefinition definition)
{ {
if (definition.PathKind == SoftwarePathKind.None) return;
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey, writable: true); using var key = Registry.CurrentUser.OpenSubKey(SettingsKey, writable: true);
key?.DeleteValue(definition.Id, throwOnMissingValue: false); key?.DeleteValue(definition.Id, throwOnMissingValue: false);
} }
@ -209,7 +218,9 @@ internal static class SoftwareLocationService
{ {
SoftwarePathKind.Executable => path, SoftwarePathKind.Executable => path,
SoftwarePathKind.Directory => Path.Combine(path, definition.RequiredRelativePath!), SoftwarePathKind.Directory => Path.Combine(path, definition.RequiredRelativePath!),
_ => path, SoftwarePathKind.None => throw new InvalidOperationException(
"runtime-only 软件不接受应用路径。"),
_ => throw new ArgumentOutOfRangeException(nameof(definition.PathKind)),
}; };
var valid = File.Exists(required) var valid = File.Exists(required)
&& (definition.PathKind != SoftwarePathKind.Executable && (definition.PathKind != SoftwarePathKind.Executable

View File

@ -14,7 +14,8 @@ internal sealed record SoftwareDefinition(
SoftwarePathKind PathKind, SoftwarePathKind PathKind,
string? EnvironmentVariable, string? EnvironmentVariable,
string? RequiredRelativePath, string? RequiredRelativePath,
string RequirementsRelativePath); string RequirementsRelativePath,
string? DefaultIndexUrl = null);
internal sealed record SoftwareLocation( internal sealed record SoftwareLocation(
bool Available, bool Available,

View File

@ -48,5 +48,10 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>
<Content Include="..\..\software-contracts\cad.geometry.prepare.v1.json">
<Link>adapters\cad.geometry.prepare@v1\cad.geometry.prepare.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,10 @@
{
"capability": "cad.geometry.prepare@v1",
"adapter_version": "1.0.0",
"runtime": "python",
"runtime_id": "cad",
"entrypoint": "worker.py",
"contract": "cad.geometry.prepare.v1.json",
"worker_timeout_minutes": 15,
"running_detail": "中性 CAD 正在准备 STEP 几何"
}

View File

@ -0,0 +1,3 @@
cadquery==2.8.0
cadquery-ocp==7.9.3.1
pillow==11.3.0

View File

@ -0,0 +1,650 @@
"""Declarative, headless neutral-CAD adapter backed by pinned CadQuery/OCP."""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
import tempfile
from collections import Counter
from datetime import datetime, timezone
from importlib.metadata import version
from pathlib import Path
from typing import Any, Iterable
ADAPTER_VERSION = "1.0.0"
RECIPE_VERSION = 1
MAX_SOLIDS = 256
OUTPUT_MEDIA = {
"geometry.step": ("geometry", "model/step"),
"geometry-recipe.json": ("geometry_recipe", "application/json"),
"geometry-manifest.json": ("geometry_manifest", "application/json"),
"geometry-preview.png": ("geometry_preview", "image/png"),
"provenance.json": ("provenance", "application/json"),
}
def _atomic_json(path: Path, value: Any) -> None:
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _artifact(path: Path) -> dict[str, Any]:
artifact_id, media_type = OUTPUT_MEDIA[path.name]
return {
"artifact_id": artifact_id,
"filename": path.name,
"media_type": media_type,
"size_bytes": path.stat().st_size,
"sha256": _sha256(path),
}
def _terminal(record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], error: dict[str, Any]) -> dict[str, Any]:
return {
"job_id": record.get("job_id"),
"lease_id": record.get("lease_id"),
"request_digest": record.get("request_digest"),
"status": status,
"artifacts": artifacts,
"error": error,
"finished_at": datetime.now(timezone.utc).isoformat(),
}
def _input_file(job_dir: Path, key: str) -> Path:
input_root = (job_dir / "input").resolve(strict=True)
if not input_root.is_relative_to(job_dir):
raise ValueError("INPUT_ROOT_ESCAPES_JOB")
directory = (input_root / key).resolve(strict=True)
if not directory.is_relative_to(input_root):
raise ValueError("INPUT_DIRECTORY_ESCAPES_JOB")
files = [item for item in directory.iterdir() if item.is_file() and not item.name.startswith(".")]
if len(files) != 1:
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
path = files[0].resolve(strict=True)
if not path.is_relative_to(input_root):
raise ValueError("INPUT_PATH_ESCAPES_JOB")
if path.suffix.lower() not in {".step", ".stp"}:
raise ValueError("STEP_INPUT_REQUIRED")
if path.stat().st_size > 256 * 1024 * 1024:
raise ValueError("STEP_INPUT_TOO_LARGE")
return path
def _require_step_unit(path: Path) -> None:
from OCP.IFSelect import IFSelect_RetDone
from OCP.STEPControl import STEPControl_Reader
from OCP.TColStd import TColStd_SequenceOfAsciiString
reader = STEPControl_Reader()
if reader.ReadFile(str(path)) != IFSelect_RetDone:
raise ValueError("STEP_INPUT_INVALID")
lengths = TColStd_SequenceOfAsciiString()
angles = TColStd_SequenceOfAsciiString()
solid_angles = TColStd_SequenceOfAsciiString()
reader.FileUnits(lengths, angles, solid_angles)
units = {
lengths.Value(index).ToCString().strip().casefold()
for index in range(1, lengths.Length() + 1)
if lengths.Value(index).ToCString().strip()
}
if len(units) != 1:
raise ValueError("STEP_UNIT_MISSING_OR_AMBIGUOUS")
def _vector(value: Iterable[float]):
import cadquery as cq
return cq.Vector(*(float(item) for item in value))
def _primitive(spec: dict[str, Any]):
import cadquery as cq
shape = spec["shape"]
required = {
"box": {"length", "width", "height"},
"cylinder": {"radius", "height"},
"tube": {"radius", "inner_radius", "height"},
"cone": {"radius", "height"},
"frustum": {"radius1", "radius2", "height"},
"sphere": {"radius"},
"wedge": {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"},
"torus": {"major_radius", "minor_radius"},
}[shape]
allowed = required | {"shape"}
if shape in {"cylinder", "tube", "cone", "frustum", "sphere", "torus"}:
allowed.add("angle_degrees")
if set(spec) - allowed or not required.issubset(spec):
raise ValueError(f"PRIMITIVE_PARAMETERS_INVALID:{shape}")
angle = float(spec.get("angle_degrees", 360.0))
if shape == "box":
result = cq.Solid.makeBox(spec["length"], spec["width"], spec["height"])
elif shape == "cylinder":
result = cq.Solid.makeCylinder(spec["radius"], spec["height"], angleDegrees=angle)
elif shape == "tube":
if spec["inner_radius"] >= spec["radius"]:
raise ValueError("TUBE_INNER_RADIUS_MUST_BE_SMALLER")
outer = cq.Solid.makeCylinder(spec["radius"], spec["height"], angleDegrees=angle)
inner = cq.Solid.makeCylinder(spec["inner_radius"], spec["height"], angleDegrees=angle)
result = outer.cut(inner)
elif shape in {"cone", "frustum"}:
radius1 = spec["radius"] if shape == "cone" else spec["radius1"]
radius2 = 0.0 if shape == "cone" else spec["radius2"]
if radius1 == 0 and radius2 == 0:
raise ValueError("FRUSTUM_REQUIRES_NONZERO_RADIUS")
result = cq.Solid.makeCone(radius1, radius2, spec["height"], angleDegrees=angle)
elif shape == "sphere":
result = cq.Solid.makeSphere(spec["radius"], angleDegrees1=-90, angleDegrees2=90, angleDegrees3=angle)
elif shape == "wedge":
if not (spec["x_min"] < spec["x_max"] and spec["y_min"] < spec["y_max"] and spec["z_min"] < spec["z_max"]):
raise ValueError("WEDGE_BOUNDS_INVALID")
dx = spec["x_max"] - spec["x_min"]
dy = spec["y_max"] - spec["y_min"]
dz = spec["z_max"] - spec["z_min"]
result = cq.Solid.makeWedge(dx, dy, dz, 0, 0, 0, dz)
result = result.translate((spec["x_min"], spec["y_min"], spec["z_min"]))
else:
if spec["minor_radius"] >= spec["major_radius"]:
raise ValueError("TORUS_MINOR_RADIUS_MUST_BE_SMALLER")
result = cq.Solid.makeTorus(
spec["major_radius"], spec["minor_radius"], angleDegrees2=angle
)
return _checked(result, f"primitive:{shape}")
def _checked(shape: Any, role: str):
if shape is None or not shape.isValid():
raise RuntimeError(f"CAD_SHAPE_INVALID:{role}")
solids = list(shape.Solids())
if not solids:
raise RuntimeError(f"CAD_SHAPE_HAS_NO_SOLIDS:{role}")
if len(solids) > MAX_SOLIDS:
raise RuntimeError(f"CAD_SOLID_LIMIT_EXCEEDED:{len(solids)}")
return shape
def _profiles(items: list[dict[str, Any]]) -> dict[str, Any]:
import cadquery as cq
result: dict[str, Any] = {}
for item in items:
name = item["id"]
if name in result:
raise ValueError(f"DUPLICATE_PROFILE_ID:{name}")
points = [cq.Vector(*map(float, point)) for point in item["points"]]
if points[0].sub(points[-1]).Length < 1e-12:
points.pop()
if len(points) < 3:
raise ValueError(f"PROFILE_TOO_SHORT:{name}")
normal = None
for index in range(1, len(points) - 1):
candidate = points[index].sub(points[0]).cross(points[index + 1].sub(points[0]))
if candidate.Length > 1e-10:
normal = candidate.normalized()
break
if normal is None:
raise ValueError(f"PROFILE_COLLINEAR:{name}")
scale = max(1.0, max(point.sub(points[0]).Length for point in points))
if any(abs(point.sub(points[0]).dot(normal)) > scale * 1e-9 for point in points):
raise ValueError(f"PROFILE_NOT_PLANAR:{name}")
wire = cq.Wire.makePolygon(points + [points[0]])
if not wire.IsClosed():
raise ValueError(f"PROFILE_NOT_CLOSED:{name}")
face = cq.Face.makeFromWires(wire)
if not face.isValid() or face.Area() <= scale * scale * 1e-12:
raise ValueError(f"PROFILE_INVALID:{name}")
result[name] = face
return result
def _lookup(values: dict[str, Any], name: str, role: str):
try:
return values[name]
except KeyError as exc:
raise ValueError(f"UNKNOWN_{role.upper()}:{name}") from exc
def _require_keys(step: dict[str, Any], required: set[str], optional: set[str] = set()) -> None:
allowed = {"id", "op"} | required | optional
if not required.issubset(step) or set(step) - allowed:
raise ValueError(f"STEP_PARAMETERS_INVALID:{step['id']}:{step['op']}")
def _combine(shapes: list[Any]):
import cadquery as cq
flattened = [solid for shape in shapes for solid in shape.Solids()]
return cq.Compound.makeCompound(flattened)
def _recipe(spec: dict[str, Any], input_path: Path | None, unit: str):
import cadquery as cq
profiles = _profiles(spec["profiles"])
values: dict[str, Any] = {}
for step in spec["steps"]:
name, op = step["id"], step["op"]
if name in values or name in profiles:
raise ValueError(f"DUPLICATE_RECIPE_ID:{name}")
if op in {"box", "cylinder", "tube", "cone", "frustum", "sphere", "wedge", "torus"}:
_require_keys(step, {"primitive"})
if step["primitive"]["shape"] != op:
raise ValueError(f"STEP_PRIMITIVE_MISMATCH:{name}")
shape = _primitive(step["primitive"])
elif op == "import_step":
_require_keys(step, {"input"})
if step["input"] != "geometry":
raise ValueError("IMPORT_STEP_INPUT_MUST_BE_GEOMETRY")
if input_path is None:
raise ValueError("IMPORT_STEP_INPUT_NOT_BOUND")
shape = cq.importers.importStep(str(input_path), unit=unit.upper()).val()
elif op == "extrude":
_require_keys(step, {"profile", "vector"})
face = _lookup(profiles, step["profile"], "profile")
if _vector(step["vector"]).Length <= 1e-12:
raise ValueError("EXTRUDE_VECTOR_MUST_BE_NONZERO")
shape = cq.Solid.extrudeLinear(
face.outerWire(), list(face.innerWires()), _vector(step["vector"])
)
elif op == "revolve":
_require_keys(step, {"profile", "axis_start", "axis_end", "angle_degrees"})
start, end = _vector(step["axis_start"]), _vector(step["axis_end"])
if end.sub(start).Length <= 1e-12:
raise ValueError("REVOLVE_AXIS_MUST_BE_NONZERO")
if abs(float(step["angle_degrees"])) <= 1e-12:
raise ValueError("REVOLVE_ANGLE_MUST_BE_NONZERO")
face = _lookup(profiles, step["profile"], "profile")
shape = cq.Solid.revolve(
face.outerWire(), list(face.innerWires()),
step["angle_degrees"], start, end
)
elif op == "sweep":
_require_keys(step, {"profile", "path"})
path = cq.Wire.makePolygon([_vector(point) for point in step["path"]])
face = _lookup(profiles, step["profile"], "profile")
shape = cq.Solid.sweep(
face.outerWire(), list(face.innerWires()), path
)
elif op == "loft":
_require_keys(step, {"profiles"})
wires = [_lookup(profiles, item, "profile").outerWire() for item in step["profiles"]]
shape = cq.Solid.makeLoft(wires)
elif op in {"union", "cut", "intersect"}:
_require_keys(step, {"target", "tools"})
shape = _lookup(values, step["target"], "step")
tools = [_lookup(values, item, "step") for item in step["tools"]]
if op == "union":
for tool in tools:
shape = shape.fuse(tool)
elif op == "cut":
for tool in tools:
shape = shape.cut(tool)
else:
for tool in tools:
shape = shape.intersect(tool)
elif op in {"fillet", "chamfer", "shell"}:
amount_key = "thickness" if op == "shell" else "radius" if op == "fillet" else "distance"
_require_keys(step, {"target", "selector", amount_key})
base = _lookup(values, step["target"], "step")
selected = _select(base, "face" if op == "shell" else "edge", step["selector"])
if op == "fillet":
shape = base.fillet(step[amount_key], selected)
elif op == "chamfer":
shape = base.chamfer(step[amount_key], None, selected)
else:
shape = cq.Workplane(obj=base).newObject(selected).shell(
-step[amount_key]
).val()
elif op == "translate":
_require_keys(step, {"target", "vector"})
shape = _lookup(values, step["target"], "step").translate(step["vector"])
elif op == "rotate":
_require_keys(step, {"target", "axis_start", "axis_end", "angle_degrees"})
shape = _lookup(values, step["target"], "step").rotate(step["axis_start"], step["axis_end"], step["angle_degrees"])
elif op == "mirror":
_require_keys(step, {"target", "plane"})
shape = _lookup(values, step["target"], "step").mirror(step["plane"])
elif op == "linear_pattern":
_require_keys(step, {"target", "vector", "count", "spacing"})
direction = _vector(step["vector"])
if direction.Length <= 1e-12:
raise ValueError("LINEAR_PATTERN_VECTOR_MUST_BE_NONZERO")
direction = direction.normalized().multiply(step["spacing"])
base = _lookup(values, step["target"], "step")
shape = _combine([base.translate(direction.multiply(index).toTuple()) for index in range(step["count"])])
elif op == "circular_pattern":
_require_keys(step, {"target", "axis_start", "axis_end", "count", "angle_degrees"})
if _vector(step["axis_end"]).sub(_vector(step["axis_start"])).Length <= 1e-12:
raise ValueError("CIRCULAR_PATTERN_AXIS_MUST_BE_NONZERO")
base = _lookup(values, step["target"], "step")
shape = _combine([base.rotate(step["axis_start"], step["axis_end"], index * step["angle_degrees"] / step["count"]) for index in range(step["count"])])
elif op == "combine":
_require_keys(step, {"targets"})
shape = _combine([_lookup(values, item, "step") for item in step["targets"]])
else:
raise ValueError(f"STEP_OPERATION_UNSUPPORTED:{op}")
values[name] = _checked(shape, f"step:{name}")
return _lookup(values, spec["result"], "result")
def _surface_type(face: Any) -> str:
value = str(face.geomType()).casefold()
return next((item for item in ("plane", "cylinder", "cone", "sphere", "torus") if item in value), "other")
def _curve_type(edge: Any) -> str:
value = str(edge.geomType()).casefold()
return next((item for item in ("line", "circle", "ellipse", "spline") if item in value), "other")
def _face_data(face: Any) -> dict[str, Any]:
center = face.Center()
normal = None
if _surface_type(face) == "plane":
try:
normal = list(face.normalAt(center).toTuple())
except Exception: # noqa: BLE001 - OCCT surface query varies by face kind
normal = None
return {
"surface": _surface_type(face),
"centroid": list(center.toTuple()),
"normal": normal,
"area": float(face.Area()),
}
def _assert_count(selector: dict[str, Any], selected: list[Any]) -> None:
expected = selector.get("expected_count")
if expected is not None and len(selected) != expected:
raise RuntimeError(f"GEOMETRY_SCOPE_COUNT_MISMATCH:expected={expected},actual={len(selected)}")
def _select(shape: Any, topology: str, selector: dict[str, Any]) -> list[Any]:
if topology == "solid":
if selector["type"] != "all_solids":
raise ValueError("SOLID_SELECTOR_TYPE_UNSUPPORTED")
selected = list(shape.Solids())
elif topology == "edge":
if selector["type"] != "curve_edges":
raise ValueError("EDGE_SELECTOR_TYPE_UNSUPPORTED")
selected = [edge for edge in shape.Edges() if _curve_type(edge) == selector["curve"]]
elif topology == "face":
faces = list(shape.Faces())
if selector["type"] == "surface_faces":
selected = [face for face in faces if _surface_type(face) == selector["surface"]]
else:
planar = [(face, _face_data(face)) for face in faces if _surface_type(face) == "plane"]
if not planar:
raise RuntimeError("GEOMETRY_SCOPE_NO_PLANAR_FACES")
angle = math.radians(float(selector.get("angle_tolerance_degrees", 5.0)))
cosine = math.cos(angle)
if selector["type"] == "extreme_face":
axis = {"x": 0, "y": 1, "z": 2}[selector["axis"]]
aligned = [(face, data) for face, data in planar if abs(data["normal"][axis]) >= cosine]
if not aligned:
raise RuntimeError("GEOMETRY_SCOPE_NO_AXIS_ALIGNED_FACES")
coordinates = [data["centroid"][axis] for _, data in aligned]
extreme = min(coordinates) if selector["side"] == "min" else max(coordinates)
box = shape.BoundingBox()
span = (box.xlen, box.ylen, box.zlen)[axis]
tolerance = float(selector.get("tolerance", max(abs(span) * 1e-7, 1e-12)))
selected = [face for face, data in aligned if abs(data["centroid"][axis] - extreme) <= tolerance]
elif selector["type"] == "planar_faces":
normal = [float(item) for item in selector["normal"]]
magnitude = math.sqrt(sum(item * item for item in normal))
if magnitude <= 1e-12:
raise ValueError("PLANE_NORMAL_MUST_BE_NONZERO")
normal = [item / magnitude for item in normal]
selected = []
for face, data in planar:
alignment = abs(sum(a * b for a, b in zip(normal, data["normal"], strict=True)))
position = sum(a * b for a, b in zip(normal, data["centroid"], strict=True))
if alignment >= cosine and abs(position - selector["offset"]) <= selector["tolerance"]:
selected.append(face)
else:
raise ValueError("FACE_SELECTOR_TYPE_UNSUPPORTED")
else:
raise ValueError("REGION_TOPOLOGY_UNSUPPORTED")
if not selected:
raise RuntimeError(f"GEOMETRY_SCOPE_EMPTY:{selector['type']}")
_assert_count(selector, selected)
return selected
def _regions(shape: Any, specs: list[dict[str, Any]]) -> list[dict[str, Any]]:
names: set[str] = set()
result = []
for spec in specs:
if spec["name"] in names:
raise ValueError(f"DUPLICATE_REGION_NAME:{spec['name']}")
names.add(spec["name"])
selected = _select(shape, spec["topology"], spec["selector"])
summaries = []
for item in selected:
if spec["topology"] == "face":
summaries.append(_face_data(item))
elif spec["topology"] == "edge":
summaries.append({"curve": _curve_type(item), "length": float(item.Length()), "centroid": list(item.Center().toTuple())})
else:
summaries.append({"volume": float(item.Volume()), "centroid": list(item.Center().toTuple())})
result.append({"name": spec["name"], "topology": spec["topology"], "selector": spec["selector"], "count": len(selected), "entities": summaries})
return result
def _manifest(shape: Any, prepare: dict[str, Any], resolved_regions: list[dict[str, Any]], source: dict[str, Any] | None) -> dict[str, Any]:
box = shape.BoundingBox()
center = shape.Center()
surfaces = Counter(_surface_type(face) for face in shape.Faces())
curves = Counter(_curve_type(edge) for edge in shape.Edges())
return {
"schema_version": 1,
"title": prepare["title"],
"canonical_unit": prepare["canonical_unit"],
"valid": bool(shape.isValid()),
"topology": {
"solids": len(shape.Solids()), "shells": len(shape.Shells()),
"faces": len(shape.Faces()), "wires": len(shape.Wires()),
"edges": len(shape.Edges()), "vertices": len(shape.Vertices()),
},
"bounding_box": {"min": [box.xmin, box.ymin, box.zmin], "max": [box.xmax, box.ymax, box.zmax], "size": [box.xlen, box.ylen, box.zlen]},
"area": float(shape.Area()),
"volume": float(shape.Volume()),
"center_of_mass": list(center.toTuple()),
"shape_description": {"kind": "multi_solid" if len(shape.Solids()) > 1 else "solid", "surface_types": dict(sorted(surfaces.items())), "curve_types": dict(sorted(curves.items()))},
"regions": resolved_regions,
"input_summary": source,
"output_summary": {"format": "STEP", "filename": "geometry.step", "solid_count": len(shape.Solids())},
}
def _preview(shape: Any, path: Path) -> None:
from PIL import Image, ImageDraw
box = shape.BoundingBox()
tolerance = max(max(box.xlen, box.ylen, box.zlen) * 1e-3, 1e-3)
vertices, triangles = shape.tessellate(tolerance, 0.15)
if not vertices or not triangles or len(triangles) > 500_000:
raise RuntimeError("PREVIEW_MESH_INVALID_OR_TOO_LARGE")
projected = []
for vertex in vertices:
x, y, z = vertex.toTuple()
projected.append((0.866 * (x - y), 0.5 * (x + y) - z, 0.408 * (x + y + z)))
xs, ys = [p[0] for p in projected], [p[1] for p in projected]
span = max(max(xs) - min(xs), max(ys) - min(ys), 1e-12)
scale = 1000.0 / span
points = [((x - (min(xs) + max(xs)) / 2) * scale + 600, (y - (min(ys) + max(ys)) / 2) * scale + 600, depth) for x, y, depth in projected]
image = Image.new("RGB", (1200, 1200), "white")
draw = ImageDraw.Draw(image)
ordered = sorted(triangles, key=lambda tri: sum(points[index][2] for index in tri) / 3)
light = (0.25, -0.5, 0.83)
for triangle in ordered:
a, b, c = (vertices[index] for index in triangle)
normal = b.sub(a).cross(c.sub(a))
magnitude = max(normal.Length, 1e-12)
shade = 0.35 + 0.55 * abs(normal.dot(_vector(light)) / magnitude)
color = tuple(int(channel * shade) for channel in (112, 160, 210))
draw.polygon([(points[index][0], points[index][1]) for index in triangle], fill=color, outline=(55, 75, 95))
temporary = path.with_name(path.name + ".tmp.png")
image.save(temporary, format="PNG", optimize=True)
os.replace(temporary, path)
def _export_step(shape: Any, path: Path, unit: str) -> None:
temporary = path.with_name(path.name + ".tmp.step")
try:
shape.exportStep(str(temporary), unit=unit.upper())
if not temporary.exists() or temporary.stat().st_size < 256:
raise RuntimeError("STEP_EXPORT_EMPTY")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _load_record(job_dir: Path) -> dict[str, Any]:
record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
request = record.get("request")
if not isinstance(request, dict):
raise TypeError("JOB_REQUEST_MISSING")
return record
def run(job_dir: Path) -> list[dict[str, Any]]:
import cadquery as cq
job_dir = job_dir.resolve(strict=True)
record = _load_record(job_dir)
request = record["request"]
prepare = request["operation"]["prepare"]
bindings = {item["key"] for item in request["inputs"]}
feature = prepare["type"]
if request["outputs"]:
raise ValueError("CAD_OUTPUTS_MUST_BE_EMPTY")
input_path = _input_file(job_dir, "geometry") if "geometry" in bindings else None
if input_path is not None:
_require_step_unit(input_path)
if feature == "inspect":
if input_path is None or prepare["geometry"]["input"] != "geometry" or len(bindings) != 1:
raise ValueError("INSPECT_INPUT_NOT_BOUND")
shape = cq.importers.importStep(str(input_path), unit=prepare["canonical_unit"].upper()).val()
elif feature == "primitive":
if bindings:
raise ValueError("PRIMITIVE_INPUTS_MUST_BE_EMPTY")
shape = _primitive(prepare["primitive"])
elif feature == "recipe":
shape = _recipe(prepare["recipe"], input_path, prepare["canonical_unit"])
else:
raise ValueError("CAD_FEATURE_UNSUPPORTED")
shape = _checked(shape, "result")
source = None if input_path is None else {"key": "geometry", "filename": input_path.name, "size_bytes": input_path.stat().st_size, "sha256": _sha256(input_path)}
normalized_recipe = {
"schema_version": RECIPE_VERSION,
"feature": feature,
"title": prepare["title"],
"canonical_unit": prepare["canonical_unit"],
"source": source,
"definition": prepare.get("primitive") or prepare.get("recipe") or {"input": "geometry"},
"regions": prepare["regions"],
}
output = job_dir / "output"
output.mkdir(exist_ok=True)
output = output.resolve(strict=True)
if not output.is_relative_to(job_dir):
raise ValueError("OUTPUT_PATH_ESCAPES_JOB")
paths = {
"step": output / "geometry.step", "recipe": output / "geometry-recipe.json",
"manifest": output / "geometry-manifest.json", "preview": output / "geometry-preview.png",
"provenance": output / "provenance.json",
}
_export_step(shape, paths["step"], prepare["canonical_unit"])
reopened = cq.importers.importStep(str(paths["step"]), unit=prepare["canonical_unit"].upper()).val()
reopened = _checked(reopened, "step_roundtrip")
tolerance = max(1e-9, abs(float(shape.Volume())) * 1e-8)
if abs(float(reopened.Volume()) - float(shape.Volume())) > tolerance:
raise RuntimeError("STEP_ROUNDTRIP_VOLUME_MISMATCH")
resolved_regions = _regions(reopened, prepare["regions"])
_atomic_json(paths["recipe"], normalized_recipe)
_atomic_json(paths["manifest"], _manifest(reopened, prepare, resolved_regions, source))
_preview(reopened, paths["preview"])
_atomic_json(paths["provenance"], {
"adapter_version": ADAPTER_VERSION,
"cadquery_version": version("cadquery"),
"ocp_version": version("cadquery-ocp"),
"request_digest": record.get("request_digest"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"network_access": False,
"gui_started": False,
"step_sha256": _sha256(paths["step"]),
})
result_paths = [paths[key] for key in ("step", "recipe", "manifest", "preview", "provenance")]
return [_artifact(path) for path in result_paths]
def _probe() -> int:
payload: dict[str, Any] = {"adapter_version": ADAPTER_VERSION, "software": "CadQuery/OCP", "software_version": None, "health": "unavailable", "detail": ""}
try:
import cadquery as cq
with tempfile.TemporaryDirectory(prefix="zcbot-cad-probe-") as directory:
path = Path(directory) / "probe.step"
box = cq.Solid.makeBox(2, 3, 5)
box.exportStep(str(path), unit="MM")
reopened = cq.importers.importStep(str(path), unit="MM").val()
if not reopened.isValid() or abs(reopened.Volume() - 30.0) > 1e-8:
raise RuntimeError("CAD_PROBE_STEP_ROUNDTRIP_FAILED")
payload.update({"software_version": f"CadQuery {version('cadquery')} / OCP {version('cadquery-ocp')}", "health": "ready", "detail": "依赖、基本体与 STEP roundtrip 可用"})
except Exception as exc: # noqa: BLE001 - probe must always return structured health
payload["detail"] = f"{type(exc).__name__}: {exc}"[:500]
print(json.dumps(payload, ensure_ascii=False))
return 0
def main() -> int:
if sys.argv[1:] == ["--probe"]:
return _probe()
if len(sys.argv) != 2:
print("[ERR] Usage: worker.py <job-directory>", file=sys.stderr)
return 2
job_dir = Path(sys.argv[1])
record: dict[str, Any] = {}
try:
record = _load_record(job_dir)
artifacts = run(job_dir)
_atomic_json(job_dir / "artifacts.json", artifacts)
_atomic_json(job_dir / "terminal.json", _terminal(record, "succeeded", artifacts, {}))
print("[OK] Neutral CAD geometry prepared.")
return 0
except Exception as exc: # noqa: BLE001 - terminal must capture kernel failures
_atomic_json(job_dir / "terminal.json", _terminal(record, "failed", [], {"code": type(exc).__name__, "detail": str(exc)[:500]}))
print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
exit_code = main()
sys.stdout.flush()
sys.stderr.flush()
if sys.platform == "win32":
# OCP/VTK extension finalizers can fault during CPython shutdown on
# Windows after all requested artifacts have already been persisted.
os._exit(exit_code)
raise SystemExit(exit_code)

View File

@ -0,0 +1,46 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
set "OUTPUT_ROOT=%~f1"
if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist"
set "ADAPTER_DIR_NAME=cad.geometry.prepare@v1"
set "ARCHIVE_NAME=cad.geometry.prepare@v1-adapter.zip"
set "PACKAGE_DIR=!OUTPUT_ROOT!\!ADAPTER_DIR_NAME!"
set "ARCHIVE_PATH=!OUTPUT_ROOT!\!ARCHIVE_NAME!"
set "HASH_PATH=!ARCHIVE_PATH!.sha256"
where.exe tar.exe >nul 2>&1
if errorlevel 1 goto :missing_tool
where.exe certutil.exe >nul 2>&1
if errorlevel 1 goto :missing_tool
if not exist "!OUTPUT_ROOT!" mkdir "!OUTPUT_ROOT!"
if exist "!PACKAGE_DIR!" rmdir /s /q "!PACKAGE_DIR!"
if exist "!ARCHIVE_PATH!" del /f /q "!ARCHIVE_PATH!"
if exist "!HASH_PATH!" del /f /q "!HASH_PATH!"
mkdir "!PACKAGE_DIR!"
copy /y "adapters\cad.geometry.prepare@v1\adapter.json" "!PACKAGE_DIR!\adapter.json" >nul
copy /y "adapters\cad.geometry.prepare@v1\worker.py" "!PACKAGE_DIR!\worker.py" >nul
copy /y "adapters\cad.geometry.prepare@v1\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul
copy /y "..\software-contracts\cad.geometry.prepare.v1.json" "!PACKAGE_DIR!\cad.geometry.prepare.v1.json" >nul
if errorlevel 1 goto :failed
tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "!ADAPTER_DIR_NAME!"
if errorlevel 1 goto :failed
set "SHA256="
for /f "tokens=*" %%H in ('certutil.exe -hashfile "!ARCHIVE_PATH!" SHA256 ^| findstr.exe /R /C:"^[0-9A-Fa-f][0-9A-Fa-f ]*[0-9A-Fa-f]$"') do set "SHA256=%%H"
set "SHA256=!SHA256: =!"
if not defined SHA256 goto :failed
>"!HASH_PATH!" echo !SHA256! !ARCHIVE_NAME!
echo [OK] Adapter package: !ARCHIVE_PATH!
echo [OK] SHA256: !SHA256!
exit /b 0
:missing_tool
echo [ERR] Windows tar.exe and certutil.exe are required.
exit /b 1
:failed
echo [ERR] Neutral CAD adapter packaging failed.
exit /b 1