feat(cad): add neutral geometry preparation
This commit is contained in:
parent
aa8ccec738
commit
6b4c54a1fe
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- 新增中性 CAD 几何准备:可生成或检查 STEP,组合基础体、截面、布尔、圆角、阵列等受控建模步骤,并交付几何清单和预览,结果可继续用于 ANSYS、Abaqus 等分析任务。
|
||||||
|
|
||||||
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
|
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
|
||||||
|
|
||||||
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。
|
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。
|
||||||
|
|
|
||||||
|
|
@ -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 版本、运行类型、入口和契约文件,并可声明 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 只做通用兼容读取。
|
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。
|
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。
|
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。
|
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。
|
||||||
|
|
|
||||||
|
|
@ -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
18
RUN.md
|
|
@ -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/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`。
|
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` 不会自动删除;确需保留时应在部署前从 `<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 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,可不受限制地使用,文件头保留来源说明。
|
验收使用安装包自带的 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` 为每种软件声明 1–1440 分钟的执行上限;旧 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` 为每种软件声明 1–1440 分钟的执行上限;旧 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` 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 卡片已安装的受管解释器运行:`<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% 重传;这些本地结果不会反向覆盖云端终态。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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}" />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 几何"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
cadquery==2.8.0
|
||||||
|
cadquery-ocp==7.9.3.1
|
||||||
|
pillow==11.3.0
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue