diff --git a/CHANGELOG.md b/CHANGELOG.md index df9d25b..cf43428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 新增中性 CAD 几何准备:可生成或检查 STEP,组合基础体、截面、布尔、圆角、阵列等受控建模步骤,并交付几何清单和预览,结果可继续用于 ANSYS、Abaqus 等分析任务。 + - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 - 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。 diff --git a/DESIGN.md b/DESIGN.md index 405e8e4..e58084c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -472,7 +472,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) 云端控制面使用独立的 `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 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtime;UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--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 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,桌面软件允许管理员自动检测或选择预定义应用位置,runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--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 只做通用兼容读取。 @@ -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,不建设跨软件万能 DSL;API/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 预览和 provenance;manifest 只保存单位、几何量、拓扑/表面摘要和已解析区域,不把 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,并统一声明材质、变换、灯光、世界背景和 1–8 个相机视图;`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。 diff --git a/PROGRESS.md b/PROGRESS.md index 55d9684..5a59ae0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 / 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/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector core/software_nodes.py ← Windows Node 注册码、身份认证与运行状态 +core/software_contracts.py ← 专业软件合同热加载、生命周期/可见性与 feature 兼容门 core/sinks.py 101 core/paths.py 50 ← task_dir db form 归一 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/broker.py / sinks.py / pptx_render.py / admin.py 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 tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) ───────────────────────────────── diff --git a/RUN.md b/RUN.md index 7fa791c..862a6bf 100644 --- a/RUN.md +++ b/RUN.md @@ -1114,21 +1114,25 @@ Zcbot.WindowsNode.exe 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`。解释器位于 `\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 哈希校验。解释器位于 `\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 始终跟随当前数据根目录。 Workspace 目录固定为 `\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 -主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 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/`;有对应活动任务时拒绝替换。安装要求本机存在 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/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `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` 不会自动删除;确需保留时应在部署前从 `\workspaces\\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 R2(revision 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 R2(revision 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 和选择全部 solid;manifest 记录的是选择器、数量及几何摘要,不记录 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,可不受限制地使用,文件头保留来源说明。 @@ -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`。 -专业软件契约的源码事实源位于 `software-contracts/*.json`。zcbot 服务端会在文件变化后校验并热加载完整契约快照,正常修改契约不需要重启服务;非法或尚未写完的文件不会替换上一份有效快照。Node 发布时把对应事实源复制进 `adapters//`,运行时只读取 manifest 同目录指向的合同,不存在共享目录 fallback;因此完整 Node 包与独立 adapter 包具有相同结构,更新时必须整体替换 adapter 目录并重启 Node。Node 心跳上报实际合同 SHA-256 和 Workspace 协议版本,服务端不会把 Workspace Job 派给未明确声明支持的节点。manifest 可用 `worker_timeout_minutes` 为每种软件声明 1–1440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。 +专业软件契约的源码事实源位于 `software-contracts/*.json`。zcbot 服务端会在文件变化后校验并热加载完整契约快照,正常修改契约不需要重启服务;非法或尚未写完的文件不会替换上一份有效快照。可选 `lifecycle` 声明状态、普通/诊断可见性及替代 capability;缺失时按 active/standard 解释,兼容既有合同。Node 发布时把对应事实源复制进 `adapters//`,运行时只读取 manifest 同目录指向的合同,不存在共享目录 fallback;因此完整 Node 包与独立 adapter 包具有相同结构,更新时必须整体替换 adapter 目录并重启 Node。Node 心跳上报实际合同 SHA-256 和 Workspace 协议版本,服务端不会把 Workspace Job 派给未明确声明支持的节点。manifest 可用 `worker_timeout_minutes` 为每种软件声明 1–1440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。 两个 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 版本,版本不一致时该能力保持不可用。 +中性 CAD adapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`,再从“中性 CAD”卡安装/更新固定 `cad` runtime;probe 必须显示 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 卡片已安装的受管解释器运行:`\runtimes\origin\Scripts\python.exe \adapters\origin.plot@v2\acceptance.py --work-root \origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band,也可重复传入 `--case ` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait ` 调整释放等待时间。最终报告位于 `\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 数据,不做隐式排序或删行。 @@ -1183,7 +1189,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打 注册配置写入 `\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% 重传;这些本地结果不会反向覆盖云端终态。 diff --git a/core/software_contracts.py b/core/software_contracts.py index bfa94a4..e98e23e 100644 --- a/core/software_contracts.py +++ b/core/software_contracts.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import math import re import threading from dataclasses import dataclass @@ -38,6 +39,14 @@ class WorkspaceSpec: required_adapter_version: str +@dataclass(frozen=True) +class CapabilityLifecycle: + status: str = "active" + visibility: str = "standard" + replacement: str | None = None + detail: str = "" + + @dataclass(frozen=True) class CapabilityContract: capability: str @@ -52,8 +61,20 @@ class CapabilityContract: summary: dict[str, Any] legacy_runtime: dict[str, Any] | 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]: + if not _all_numbers_finite(request): + raise SoftwareContractError( + f"invalid {self.capability} request: non-finite numbers are forbidden" + ) errors = sorted( Draft202012Validator( self.request_schema, format_checker=FormatChecker() @@ -65,7 +86,10 @@ class CapabilityContract: location = ".".join(str(item) for item in first.absolute_path) prefix = f"{location}: " if location else "" 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) if len(encoded.encode("utf-8")) > max_bytes: raise SoftwareContractError(f"{self.capability} request is too large") @@ -145,6 +169,20 @@ def _value_at(value: object, path: list[str]) -> object: 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: if isinstance(value, list): 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", "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}") capability = raw["capability"] namespace = raw["output_namespace"] @@ -234,6 +273,32 @@ def _load_contract(path: Path) -> CapabilityContract: workspace = WorkspaceSpec( 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"]) return CapabilityContract( capability=capability, @@ -248,6 +313,7 @@ def _load_contract(path: Path) -> CapabilityContract: summary=raw["summary"], legacy_runtime=raw["legacy_runtime"], workspace=workspace, + lifecycle=lifecycle, ) @@ -283,6 +349,15 @@ class _ContractRegistry: raise RuntimeError( "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: if initial: raise diff --git a/software-contracts/ansys.geometry.inspect.v1.json b/software-contracts/ansys.geometry.inspect.v1.json index d4f7e99..4062912 100644 --- a/software-contracts/ansys.geometry.inspect.v1.json +++ b/software-contracts/ansys.geometry.inspect.v1.json @@ -2,6 +2,12 @@ "capability": "ansys.geometry.inspect@v1", "display_name": "ANSYS Mechanical 几何检查", "default_enrollment": false, + "lifecycle": { + "status": "deprecated", + "visibility": "diagnostic_only", + "replacement": "cad.geometry.prepare@v1", + "detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通几何准备请使用中性 CAD 能力。" + }, "output_namespace": "ansys", "input_policy": { "suffixes": [".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"], diff --git a/software-contracts/cad.geometry.prepare.v1.json b/software-contracts/cad.geometry.prepare.v1.json new file mode 100644 index 0000000..286e2c2 --- /dev/null +++ b/software-contracts/cad.geometry.prepare.v1.json @@ -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"} + } + } + } + } + ] + } + } + } +} diff --git a/tests/test_cad_adapter.py b/tests/test_cad_adapter.py new file mode 100644 index 0000000..2d4e183 --- /dev/null +++ b/tests/test_cad_adapter.py @@ -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() diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index 603bb80..c3f2ab2 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -42,16 +42,24 @@ class SoftwareJobToolTests(unittest.TestCase): ) self.assertEqual(result["capabilities"][0]["capability"], "origin.plot@v2") self.assertEqual(result["capabilities"][0]["available_nodes"], 1) - self.assertEqual( - result["capabilities"][1]["capability"], - "ansys.geometry.inspect@v1", + capabilities = {item["capability"] for item in result["capabilities"]} + self.assertIn("cad.geometry.prepare@v1", capabilities) + 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 + ) + ) + inspect = next( + item for item in diagnostic["capabilities"] + if item["capability"] == "ansys.geometry.inspect@v1" ) - 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) + 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): created = {"job_id": str(uuid4()), "status": "queued"} diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 2b5ba97..f9f7c60 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -88,6 +88,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase): "ansys.geometry.inspect@v1", "ansys.mechanical.static_structural@v2", "blender.scene.author@v3", + "cad.geometry.prepare@v1", }, ) self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities)) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index dbfb36d..7b75cc9 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -329,8 +329,12 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('IsReadOnly="True"', xaml) self.assertIn("ManagedRuntimeInstaller.InstallAsync", 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("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("RegistryHive.LocalMachine", manager) self.assertIn("RegistryHive.CurrentUser", manager) @@ -491,6 +495,14 @@ class WindowsNodeSourceTests(unittest.TestCase): "adapters\\blender.scene.author@v3\\blender.scene.author.v3.json", 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.assertFalse((ROOT / "install-windows-node.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.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: monitor = ( PROJECT / "Application" / "JobMonitorService.cs" diff --git a/tools/software_jobs.py b/tools/software_jobs.py index db0ca10..6c5b3c3 100644 --- a/tools/software_jobs.py +++ b/tools/software_jobs.py @@ -49,13 +49,25 @@ class SoftwareCapabilityListTool(_SoftwareJobTool): "Use this tool to answer whether software is available; checking availability must " "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() items = [{ "capability": item, "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( 1 for node in nodes @@ -63,7 +75,8 @@ class SoftwareCapabilityListTool(_SoftwareJobTool): and item in (node.get("capabilities") or []) 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"])) return json.dumps({"capabilities": items}, ensure_ascii=False) diff --git a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs index 783e0ea..1258697 100644 --- a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs +++ b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Zcbot.WindowsNode.Tests; public sealed class AdapterArchitectureTests @@ -55,10 +57,26 @@ public sealed class AdapterArchitectureTests 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] public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers() { - Assert.Equal(["origin", "ansys", "blender"], + Assert.Equal(["origin", "ansys", "blender", "cad"], SoftwareCatalog.Definitions.Select(item => item.RuntimeId)); Assert.Equal("ZCBOT_ORIGIN_EXE", SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable); @@ -66,6 +84,10 @@ public sealed class AdapterArchitectureTests SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable); Assert.Equal("ZCBOT_BLENDER_EXE", 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] diff --git a/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs index 4340858..2b16043 100644 --- a/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs +++ b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs @@ -58,6 +58,24 @@ public sealed class SoftwarePageViewModelTests 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] public async Task CancelInstall_CancelsTheInFlightRuntimeOperation() { diff --git a/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml b/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml index 172c775..94157a3 100644 --- a/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml +++ b/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml @@ -229,6 +229,9 @@ + + + @@ -242,6 +245,10 @@ + + + + @@ -255,8 +262,10 @@ Foreground="{StaticResource MutedTextBrush}" TextWrapping="Wrap" /> -