feat(freecad): 接入受控建模 Windows Node

This commit is contained in:
caoqianming 2026-09-03 16:39:02 +08:00
parent 4af4816aa2
commit 09cb7d327b
21 changed files with 1886 additions and 9 deletions

View File

@ -10,6 +10,8 @@
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。
- 新增 FreeCAD 受控建模:可通过参数化 Recipe 组合基础体、布尔运算、变换和 STEP 导入,交付可继续调整的 FCStd 工程、STEP、模型清单与无显卡预览并支持版本续作和失败回滚。
- 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;工具异常统一按结构化事件统计,代理主动保护与真正故障分开展示,多人同时查看或自动刷新时不再反复扫描历史消息。
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。

View File

@ -496,6 +496,10 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
首版不输出 GLB当前稳定事实源和跨求解器交换面是 STEP现有 GLB 链路依赖 Blender/装配导出,未在独立 CAD runtime 上形成同等稳定的 roundtrip 与打包验证;后续只有在 CAD runtime 内可独立验证时再作为新增可选产物。
`freecad.model.author@v1` 是面向已安装 FreeCAD 1.1 稳定版的有状态受控建模入口,只接受 `>=1.1.3,<1.2.0`,不接受 RC、weekly 或 experimental 构建。受管 `freecad` Python runtime 只负责发现 `FreeCADCmd.exe`、版本门禁、任务级配置隔离和 Pillow 软件渲染;固定 FreeCAD 侧 Worker 在应用自带 Python 中调用 `FreeCAD/Part`,不把 FreeCAD 安装进 venv也不依赖 FreeCADGui/GPU。每个 Job 固定启用 safe mode、独立 user/system cfg 与 user data/temp 目录,并清除不可信 Python 搜索路径请求不能影响脚本、程序路径、命令行、本机路径、URL、宏、插件或 Workbench。
FreeCAD v1 的 Recipe 是模型唯一事实源,每次 revision 都从空文档按完整目标状态重建,而不是继续编辑上一版 FCStd 中的隐式状态;轻量命名参数只在 adapter 内解析为有限数后写入 Shape不生成 Spreadsheet、alias 或原生 Expression。首版操作限制为 box/cylinder/cone/sphere/torus、STEP import、union/cut/intersect、translate/rotate/combine输入只允许零或一个已登记 STEP/STP外部 FCStd 与更高阶特征留待后续。固定输出 `model.FCStd`、`model.step`、无 GUI 等轴测预览、Recipe、manifest 和 provenance发布前必须完成 recompute、Shape 有效性、FCStd 重开与 STEP 重开并对实体数、包围盒和体积做容差比较。Workspace 继续只管理 current/rollback 两代,续作沿用通用 `software_job_revise`,不新增协议或数据库状态。
`ansys.geometry.inspect@v1` 继续保留原合同和 adapter专门验证 Mechanical 真实导入后的 GeoData、单位与 Named Selection它至少保留一个发布周期但生命周期标为 deprecated/diagnostic-only并指向中性 CAD 入口。普通几何准备不再推荐占用 ANSYS 许可证,显式诊断请求与历史 Job/旧节点协议不受影响。
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。
@ -504,7 +508,7 @@ Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建
专业软件的连续加工以独立 `software_workspaces` 为稳定边界Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据Node 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 user 建上层目录。全新节点的 `data_root` 默认是当前专用账号的 `%LocalAppData%/Zcbot/WindowsNode`,以便无需提权即可从 UI 完成首次配置;升级节点若已有 `%ProgramData%/Zcbot/WindowsNode` 则继续原位使用,不做隐式迁移。专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾在目标同级临时目录复制并逐文件校验后才原子切换旧目录不自动删除。这样用户统计集中在数据库完成节点只管理实际落到本机的 workspace也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node后续 Job 必须以当前 head 为 source、串行回到同一节点节点离线时等待而不复制工程或静默改派。
Node 对每个 Workspace 只保留 `current``rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程物理空间上限约为当前工程加一份回滚工程Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace stateANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。
Node 对每个 Workspace 只保留 `current``rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程物理空间上限约为当前工程加一份回滚工程Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU、Blender 以 BLEND、FreeCAD 以 FCStd 为 workspace stateANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。
科研统计图继续以独立 feature 增量扩展同一契约:`box/histogram/violin` 的系列只绑定原始 Y 列,统计规则由 Origin 固定模板决定;`violin` 把跨输入的观测列复制进受控全 Y 展示表,通过与菜单等价的固定 `worksheet -p 206 Violin` 一次性提交完整选择,使 Origin 正确建立分类轴和每列一组的核密度分布。原始输入表仍保留,默认以分类标签替代图例,并固定预留底部空间显示分类标签与轴标题;首版拒绝逐系列样式,因为原生多 Y 小提琴在图层内是单个统计对象,不能稳定映射普通曲线样式。`bubble` 增加正数 `size` 数据角色,由受控 modifier column 驱动符号尺寸;`band` 要求同一输入的 `x/y/lower/upper`先画上下界并填充到下一曲线再叠加中心线Worker 在打开 Origin 前拒绝非有限尺寸、非正尺寸和倒置边界。`stacked_column/stacked_area/stacked_bar` 统一把两条以上 XY 系列复制进内部连续 XYY 工作表,严格校验横坐标相同后建立 plot group并只执行 Worker 内置的固定累计图层命令;请求不能提供命令、模板或工作表范围。材料谱图使用独立 `stacked_line` feature请求显式给出 `stack_gap_percent`Worker 保留原始输入表,并在内部展示表中把每条曲线的最小值对齐为自身基线,再按全体最大 Y 跨度及间隔百分比逐条纵向偏移;各系列可拥有不同 X 采样点,展示纵轴不支持对数尺度。默认在曲线右端直接标注系列名并隐藏易遮挡谱线的图例,用户显式提供 `legend` 时仍按请求显示;单图标题按实际画布宽度定位。该实现不依赖节点上的 `OFFSETSTACKY.OTP` 或隐式主题,避免模板版本差异,同时 OPJU 中仍可追溯未变换数据。`area/polar/pie` 继续使用固定 Origin 类型 ID。未经过目标 Origin 版本真机验证的统计属性不进入公共 schema避免暴露看似可配但不能稳定复现的参数。

View File

@ -32,6 +32,8 @@
- **09-02 / Unreleased / Admin Provider API 凭据管理与余额提醒**:新增代码内静态可信 Provider 注册表和单行当前凭据控制面0039 以 AES-GCM 字段级 AAD 密文保存Admin 可查看 DB/env 来源与尾号、先测后原子替换、手动测试和删除覆盖LLM/媒体/平台来源/语音均按新请求热解析。DeepSeek 每 30 分钟检查官方 CNY 余额,低于 ¥30 及明确 402/认证失败进入统一状态与邮件提醒,蓝绿实例以 PostgreSQL advisory lock 单轮选主;持续异常每日最多一次、恢复重置。未连接生产数据库、未调用真实第三方 API。
- **09-03 / Unreleased / FreeCAD Windows Node 接入**:新增 `freecad.model.author@v1` / adapter 1.0.0 与独立 `freecad` runtime目标仅为正式版 FreeCAD `>=1.1.3,<1.2.0`。两层 Worker 由受管 Python 负责 `FreeCADCmd.exe` 发现、版本门禁、safe mode/独立配置与 Pillow 等轴测渲染,由 FreeCAD 自带 Python 固定调用 PartRecipe 每次从空文档完整重建,参数引用先解析为数值,不写 Spreadsheet/Expression。首版覆盖五类基础体、STEP import、三类布尔、平移/旋转/组合,固定交付 FCStd、STEP、Recipe、manifest、预览和 provenance并执行 recompute、Shape 有效性、FCStd/STEP 重开及实体数/包围盒/体积容差校验。Windows Node 增加 FreeCAD 软件卡、UI/环境变量/PATH/卸载注册表/标准目录检测与独立/完整包集成;纯 Recipe、假版本 probe、合同、源码和打包自动测试已落地FreeCAD 1.1.3 真机 acceptance 待目标机执行,未伪造通过。
- **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 或运行方式变化。
@ -489,8 +491,8 @@ web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py / admin.py
web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端
software-contracts/ ← 语言无关专业软件合同(含中性 CAD 与生命周期)
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、runtime-only CAD 与发布脚本
software-contracts/ ← 语言无关专业软件合同(含 FreeCAD、中性 CAD 与生命周期)
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD runtime 与发布脚本
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
─────────────────────────────────

12
RUN.md
View File

@ -1119,18 +1119,22 @@ Zcbot.WindowsNode.exe
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service也不要求管理员创建计划任务。
专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。既有桌面软件依赖默认显式使用清华 PyPI 镜像;中性 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 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。
专业软件 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|freecad|cad\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON`、`ZCBOT_ADAPTER_BLENDER_PYTHON`、`ZCBOT_ADAPTER_FREECAD_PYTHON` 或 `ZCBOT_ADAPTER_CAD_PYTHON` 可指定固定解释器,旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
主窗口“专业软件”按 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/心跳会刷新能力状态。
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender、FreeCAD 和中性 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 与 FreeCAD 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE`、`ZCBOT_FREECAD_CMD` 与 `AWP_ROOT242` 继续兼容FreeCAD 位置必须是 `FreeCADCmd.exe`。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过CAD probe 会验证固定依赖、基本体和 STEP roundtrip。配置或安装完成后不必重新注册节点后续 probe/心跳会刷新能力状态。
Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`
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 或任意节点脚本。
FreeCAD 能力为 `freecad.model.author@v1`,只支持正式版 `>=1.1.3,<1.2.0`RC、weekly、experimental、旧版和 1.2+ 都会在 probe 阶段保持不可用。请求固定使用 `model.type=recipe`、`recipe_version=1`、`unit=mm`,可声明最多 64 个有限数参数,并在步骤数值位置用 `{"parameter":"name"}` 引用adapter 会先解析成具体数值FCStd 不绑定 Spreadsheet 或 Expression。首版步骤为 box、cylinder、cone、sphere、torus、import_step、union、cut、intersect、translate、rotate、combine只能绑定零或一个已登记 STEP/STP不能提交 FCStd。固定输出 `model.FCStd`、`model.step`、`model-preview.png`、`model-recipe.json`、`model-manifest.json` 和隐藏 `.meta/provenance.json`;预览自动回传,其余按 Workspace/导出机制处理。续作从 `editable_request` 形成完整下一版 Recipe 后调用 `software_job_revise`,每版重新构建并由通用 current/rollback 管理。
FreeCAD 启动器只接受 UI 配置或 `ZCBOT_FREECAD_CMD` 指向的 `FreeCADCmd.exe`,否则按 PATH、卸载注册表与 `%ProgramFiles%\FreeCAD*` 标准目录探测。每个 Job 固定使用 `--safe-mode`、独立 `--user-cfg/--system-cfg``FREECAD_USER_HOME/FREECAD_USER_DATA/FREECAD_USER_TEMP`,并清理外部 Python 搜索路径请求不能指定脚本、程序、命令行、URL、本机路径、宏、插件或 Workbench。FreeCAD 自带 Python 完成 recompute、Shape 校验、FCStd 保存重开和 STEP 导出重开,受管 Python/Pillow 根据 tessellation 生成无 GUI/GPU 的等轴测预览。
ANSYS 能力为 `ansys.geometry.inspect@v1``ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2revision 242。adapter 0.3.0 使用 PyMechanical 0.11.3:前者保留为真实 Mechanical 导入专项诊断,已标记 deprecated/diagnostic-only普通能力枚举会指向 `cad.geometry.prepare@v1`其外部合同、adapter、历史 Job 和明确诊断调用仍兼容至少一个发布周期。后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区再执行真实静力求解。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`
普通几何先提交 `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` 输入,不新增数据库关系。
@ -1182,6 +1186,8 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
中性 CAD adapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`,再从“中性 CAD”卡安装/更新固定 `cad` runtimeprobe 必须显示 CadQuery 2.8.0 / OCP 7.9.3.1 且基本体与 STEP roundtrip 可用。若默认官方 PyPI 下载失败,可通过 `ZCBOT_PIP_INDEX_URL` 指向已同步并校验 wheel 的院内源;出现 `THESE PACKAGES DO NOT MATCH THE HASHES` 时应修复镜像缓存,不能添加忽略哈希参数。
FreeCAD adapter 可用 `windows-node\package-freecad-adapter.bat` 独立打成 `dist\freecad.model.author@v1-adapter.zip`;退出 Node 后整体替换 `adapters\freecad.model.author@v1\`在“FreeCAD”卡确认应用位置并安装/更新固定 `freecad` runtime。目标机用 `<data_root>\runtimes\freecad\Scripts\python.exe <node_program>\adapters\freecad.model.author@v1\acceptance.py --work-root <new_empty_parent>\freecad-acceptance-YYYYMMDD --repeat 3` 执行固定验收;目录必须不存在。报告 `acceptance-report.json` 覆盖基础体、参数、布尔、变换、STEP import、FCStd/STEP roundtrip、模拟 Workspace revise/rollback、取消、空格/中文路径、连续运行及 FreeCAD 进程释放;报告通过且人工检查预览后才能记录目标机通过。
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 数据,不做隐式排序或删行。
@ -1194,7 +1200,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及四套独立 runtime中性 CAD 无应用位置配置。该页还可运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender、FreeCAD 位置及五套独立 runtime中性 CAD 无应用位置配置。该页还可运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。

View File

@ -0,0 +1,121 @@
{
"capability": "freecad.model.author@v1",
"display_name": "FreeCAD 受控建模",
"default_enrollment": false,
"output_namespace": "freecad",
"input_policy": {
"suffixes": [".step", ".stp"],
"max_count": 1,
"max_bytes": 268435456,
"max_total_bytes": 268435456
},
"outputs": {
"project": {"filename": "model.FCStd", "media_type": "application/vnd.freecad", "relative_path": "model.FCStd", "publish": true, "required": true},
"geometry_step": {"filename": "model.step", "media_type": "model/step", "relative_path": "model.step", "publish": true, "required": true},
"preview": {"filename": "model-preview.png", "media_type": "image/png", "relative_path": "model-preview.png", "publish": true, "required": true},
"model_recipe": {"filename": "model-recipe.json", "media_type": "application/json", "relative_path": "model-recipe.json", "publish": true, "required": true},
"model_manifest": {"filename": "model-manifest.json", "media_type": "application/json", "relative_path": "model-manifest.json", "publish": true, "required": true},
"provenance": {"filename": "provenance.json", "media_type": "application/json", "relative_path": ".meta/provenance.json", "publish": false, "required": true}
},
"feature_path": ["operation", "model", "type"],
"features": {"recipe": "1.0.0"},
"summary": {"title_path": ["operation", "model", "title"]},
"legacy_runtime": null,
"workspace": {
"state_output": "project",
"preview_outputs": ["preview"],
"required_adapter_version": "1.0.0"
},
"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": ["model"], "additionalProperties": false,
"properties": {"model": {"$ref": "#/$defs/model"}}
},
"outputs": {"type": "array", "maxItems": 0}
},
"$defs": {
"identifier": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$"},
"finite": {"type": "number", "minimum": -1000000, "maximum": 1000000},
"positive": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000},
"parameterRef": {
"type": "object", "required": ["parameter"], "additionalProperties": false,
"properties": {"parameter": {"$ref": "#/$defs/identifier"}}
},
"numberValue": {"oneOf": [{"$ref": "#/$defs/finite"}, {"$ref": "#/$defs/parameterRef"}]},
"positiveValue": {"oneOf": [{"$ref": "#/$defs/positive"}, {"$ref": "#/$defs/parameterRef"}]},
"vector3": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"$ref": "#/$defs/numberValue"}},
"resultRef": {"$ref": "#/$defs/identifier"},
"box": {
"type": "object", "required": ["id", "op", "length", "width", "height"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "box"}, "length": {"$ref": "#/$defs/positiveValue"}, "width": {"$ref": "#/$defs/positiveValue"}, "height": {"$ref": "#/$defs/positiveValue"}}
},
"cylinder": {
"type": "object", "required": ["id", "op", "radius", "height"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "cylinder"}, "radius": {"$ref": "#/$defs/positiveValue"}, "height": {"$ref": "#/$defs/positiveValue"}, "angle_degrees": {"$ref": "#/$defs/positiveValue"}}
},
"cone": {
"type": "object", "required": ["id", "op", "radius1", "radius2", "height"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "cone"}, "radius1": {"$ref": "#/$defs/numberValue"}, "radius2": {"$ref": "#/$defs/numberValue"}, "height": {"$ref": "#/$defs/positiveValue"}, "angle_degrees": {"$ref": "#/$defs/positiveValue"}}
},
"sphere": {
"type": "object", "required": ["id", "op", "radius"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "sphere"}, "radius": {"$ref": "#/$defs/positiveValue"}, "angle1_degrees": {"$ref": "#/$defs/numberValue"}, "angle2_degrees": {"$ref": "#/$defs/numberValue"}, "angle3_degrees": {"$ref": "#/$defs/positiveValue"}}
},
"torus": {
"type": "object", "required": ["id", "op", "major_radius", "minor_radius"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "torus"}, "major_radius": {"$ref": "#/$defs/positiveValue"}, "minor_radius": {"$ref": "#/$defs/positiveValue"}, "angle1_degrees": {"$ref": "#/$defs/numberValue"}, "angle2_degrees": {"$ref": "#/$defs/numberValue"}, "angle3_degrees": {"$ref": "#/$defs/positiveValue"}}
},
"importStep": {
"type": "object", "required": ["id", "op", "input"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "import_step"}, "input": {"const": "geometry"}}
},
"boolean": {
"type": "object", "required": ["id", "op", "target", "tools"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"enum": ["union", "cut", "intersect"]}, "target": {"$ref": "#/$defs/resultRef"}, "tools": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"$ref": "#/$defs/resultRef"}}}
},
"translate": {
"type": "object", "required": ["id", "op", "target", "vector"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "translate"}, "target": {"$ref": "#/$defs/resultRef"}, "vector": {"$ref": "#/$defs/vector3"}}
},
"rotate": {
"type": "object", "required": ["id", "op", "target", "axis_point", "axis_direction", "angle_degrees"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "rotate"}, "target": {"$ref": "#/$defs/resultRef"}, "axis_point": {"$ref": "#/$defs/vector3"}, "axis_direction": {"$ref": "#/$defs/vector3"}, "angle_degrees": {"$ref": "#/$defs/numberValue"}}
},
"combine": {
"type": "object", "required": ["id", "op", "targets"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "op": {"const": "combine"}, "targets": {"type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": {"$ref": "#/$defs/resultRef"}}}
},
"step": {"oneOf": [{"$ref": "#/$defs/box"}, {"$ref": "#/$defs/cylinder"}, {"$ref": "#/$defs/cone"}, {"$ref": "#/$defs/sphere"}, {"$ref": "#/$defs/torus"}, {"$ref": "#/$defs/importStep"}, {"$ref": "#/$defs/boolean"}, {"$ref": "#/$defs/translate"}, {"$ref": "#/$defs/rotate"}, {"$ref": "#/$defs/combine"}]},
"preview": {
"type": "object", "additionalProperties": false,
"properties": {"width": {"type": "integer", "minimum": 320, "maximum": 1600}, "height": {"type": "integer", "minimum": 240, "maximum": 1600}, "background": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}}
},
"model": {
"type": "object", "required": ["type", "recipe_version", "title", "unit", "steps", "result"], "additionalProperties": false,
"properties": {
"type": {"const": "recipe"}, "recipe_version": {"const": 1},
"title": {"type": "string", "minLength": 1, "maxLength": 160},
"unit": {"const": "mm"},
"parameters": {"type": "object", "maxProperties": 64, "propertyNames": {"$ref": "#/$defs/identifier"}, "additionalProperties": {"$ref": "#/$defs/finite"}},
"steps": {"type": "array", "minItems": 1, "maxItems": 128, "items": {"$ref": "#/$defs/step"}},
"result": {"$ref": "#/$defs/resultRef"},
"preview": {"$ref": "#/$defs/preview"}
}
}
}
}
}

View File

@ -0,0 +1,234 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from core.software_contracts import SoftwareContractError, get_contract
ROOT = Path(__file__).resolve().parents[1]
ADAPTER = ROOT / "windows-node" / "adapters" / "freecad.model.author@v1"
sys.path.insert(0, str(ADAPTER))
SPEC = importlib.util.spec_from_file_location("freecad_launcher", ADAPTER / "worker.py")
assert SPEC and SPEC.loader
launcher = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(launcher)
from recipe import RecipeError, normalize_model
def model() -> dict[str, object]:
return {
"type": "recipe",
"recipe_version": 1,
"title": "受控模型",
"unit": "mm",
"parameters": {"length": 20},
"steps": [
{
"id": "base",
"op": "box",
"length": {"parameter": "length"},
"width": 10,
"height": 5,
},
{"id": "hole", "op": "cylinder", "radius": 2, "height": 5},
{"id": "result", "op": "cut", "target": "base", "tools": ["hole"]},
],
"result": "result",
}
class FreeCadContractTests(unittest.TestCase):
def test_contract_accepts_parameterized_minimal_recipe(self) -> None:
request = {
"schema_version": 1,
"inputs": [],
"operation": {"model": model()},
"outputs": [],
}
normalized, digest = get_contract("freecad.model.author@v1").normalize_request(
request
)
self.assertEqual(normalized["operation"]["model"]["result"], "result")
self.assertEqual(len(digest), 64)
contract = get_contract("freecad.model.author@v1")
self.assertEqual(contract.workspace.state_output, "project")
self.assertEqual(contract.workspace.preview_outputs, ("preview",))
self.assertEqual(
set(contract.expected_outputs(normalized)),
{
"project",
"geometry_step",
"preview",
"model_recipe",
"model_manifest",
"provenance",
},
)
def test_contract_rejects_raw_expression_strings_and_fcstd_inputs(self) -> None:
request = {
"schema_version": 1,
"inputs": [],
"operation": {"model": model()},
"outputs": [],
}
request["operation"]["model"]["steps"][0]["length"] = "length * 2"
with self.assertRaises(SoftwareContractError):
get_contract("freecad.model.author@v1").normalize_request(request)
self.assertEqual(
get_contract("freecad.model.author@v1").input_policy["suffixes"],
[".step", ".stp"],
)
class FreeCadRecipeTests(unittest.TestCase):
def test_parameter_references_are_resolved_to_numbers(self) -> None:
resolved = normalize_model(model(), input_bound=False)
self.assertEqual(resolved["steps"][0]["length"], 20.0)
self.assertNotIn("Spreadsheet", json.dumps(resolved))
def test_forward_reference_and_unbound_step_are_rejected(self) -> None:
value = model()
value["steps"][0] = {
"id": "moved",
"op": "translate",
"target": "later",
"vector": [1, 0, 0],
}
with self.assertRaisesRegex(RecipeError, "moved"):
normalize_model(value, input_bound=False)
imported = {
"type": "recipe",
"recipe_version": 1,
"title": "import",
"unit": "mm",
"steps": [{"id": "part", "op": "import_step", "input": "geometry"}],
"result": "part",
}
with self.assertRaises(RecipeError) as caught:
normalize_model(imported, input_bound=False)
self.assertEqual(caught.exception.code, "FREECAD_STEP_INPUT_NOT_BOUND")
class FreeCadLauncherTests(unittest.TestCase):
def test_version_gate_accepts_only_stable_113_to_before_120(self) -> None:
executable = Path(r"C:\Program Files\FreeCAD 1.1\bin\FreeCADCmd.exe")
for output in ("FreeCAD 1.1.3", "FreeCAD 1.1.9 Revision: 12345"):
with patch.object(
subprocess,
"run",
return_value=subprocess.CompletedProcess([], 0, output, ""),
):
self.assertTrue(launcher._version(executable).startswith("1.1."))
for output in (
"FreeCAD 1.1.2",
"FreeCAD 1.2.0",
"FreeCAD 1.1.3 RC1",
"FreeCAD 1.1.4 weekly",
"FreeCAD 1.1.4, Libs: 1.1.4devR123",
"FreeCAD 1.1.4, Libs: 1.1.4dev.123",
):
with (
patch.object(
subprocess,
"run",
return_value=subprocess.CompletedProcess([], 0, output, ""),
),
self.assertRaises(launcher.AdapterError),
):
launcher._version(executable)
def test_managed_launcher_uses_fake_freecadcmd_with_fixed_inputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
job_dir = Path(directory) / "含空格 job"
request = {
"schema_version": 1,
"inputs": [],
"operation": {"model": model()},
"outputs": [],
}
record = {
"job_id": "job",
"lease_id": "lease",
"request_digest": "digest",
"request": request,
}
request_path = job_dir / "request" / "request.json"
request_path.parent.mkdir(parents=True)
request_path.write_text(json.dumps(record), encoding="utf-8")
fake = Path(r"C:\Program Files\FreeCAD 1.1\bin\FreeCADCmd.exe")
captured: dict[str, object] = {}
def execute(command, **kwargs):
captured["command"] = command
captured["environment"] = kwargs["env"]
output = job_dir / "output"
metadata = output / ".meta"
for name in (
"model.FCStd",
"model.step",
"model-recipe.json",
"model-manifest.json",
):
(output / name).write_bytes(b"fake-output")
(metadata / "provenance.json").write_bytes(b"{}")
(metadata / "preview-mesh.json").write_text(
json.dumps(
{
"vertices": [[0, 0, 0], [1, 0, 0], [0, 1, 0]],
"triangles": [[0, 1, 2]],
}
),
encoding="utf-8",
)
(metadata / "engine-result.json").write_text(
'{"status":"succeeded"}', encoding="utf-8"
)
return subprocess.CompletedProcess(command, 0)
with (
patch.object(launcher, "_find_freecad", return_value=(fake, "1.1.3")),
patch.object(launcher.subprocess, "run", side_effect=execute),
):
artifacts = launcher.run(job_dir)
command = captured["command"]
environment = captured["environment"]
self.assertEqual(command[0], str(fake))
self.assertIn("--safe-mode", command)
self.assertEqual(Path(command[-1]).name, "freecad_worker.py")
self.assertEqual(
environment["ZCBOT_FREECAD_JOB_DIR"], str(job_dir.resolve())
)
self.assertNotIn("PYTHONPATH", {key.upper() for key in environment})
self.assertEqual(len(artifacts), 6)
def test_launcher_uses_fixed_script_safe_mode_and_isolated_configuration(
self,
) -> None:
source = (ADAPTER / "worker.py").read_text(encoding="utf-8")
for marker in (
"--safe-mode",
"--user-cfg",
"--system-cfg",
"FREECAD_USER_HOME",
"FREECAD_USER_DATA",
"FREECAD_USER_TEMP",
"ZCBOT_FREECAD_JOB_DIR",
):
self.assertIn(marker, source)
for forbidden in ("shell=True", "eval(", "exec(", "requests."):
self.assertNotIn(forbidden, source)
engine = (ADAPTER / "freecad_worker.py").read_text(encoding="utf-8")
self.assertIn("do not rely on __main__", engine)
self.assertNotIn("FreeCADGui", engine)
self.assertNotIn("Spreadsheet", engine)
if __name__ == "__main__":
unittest.main()

View File

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

View File

@ -329,7 +329,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('IsReadOnly="True"', xaml)
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
self.assertIn("item.HasActiveJobs", service)
for runtime_id in ('"origin"', '"ansys"', '"blender"', '"cad"'):
for runtime_id in ('"origin"', '"ansys"', '"blender"', '"freecad"', '"cad"'):
self.assertIn(runtime_id, catalog)
self.assertIn("SoftwarePathKind.None", catalog)
self.assertIn('"https://pypi.org/simple/"', catalog)
@ -339,6 +339,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("RegistryHive.LocalMachine", manager)
self.assertIn("RegistryHive.CurrentUser", manager)
self.assertIn('"ZCBOT_BLENDER_EXE"', catalog)
self.assertIn('"ZCBOT_FREECAD_CMD"', catalog)
self.assertIn('"FreeCADCmd.exe"', catalog)
self.assertIn('"AWP_ROOT242"', catalog)
self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog)
self.assertIn('"Origin*.exe"', catalog)
@ -503,6 +505,14 @@ class WindowsNodeSourceTests(unittest.TestCase):
"adapters\\cad.geometry.prepare@v1\\cad.geometry.prepare.v1.json",
project,
)
self.assertIn(
"..\\..\\software-contracts\\freecad.model.author.v1.json",
project,
)
self.assertIn(
"adapters\\freecad.model.author@v1\\freecad.model.author.v1.json",
project,
)
self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
self.assertFalse((ROOT / "install-windows-node.ps1").exists())
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
@ -805,6 +815,14 @@ class WindowsNodeSourceTests(unittest.TestCase):
for forbidden in ("subprocess", "eval(", "exec(", "os.system", "requests."):
self.assertNotIn(forbidden, worker)
def test_freecad_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-freecad-adapter.bat").read_text(encoding="utf-8")
for filename in ("adapter.json", "worker.py", "freecad_worker.py", "recipe.py", "preview_renderer.py", "requirements.txt", "acceptance.py"):
self.assertIn(filename, script)
self.assertIn("..\\software-contracts\\freecad.model.author.v1.json", script)
self.assertIn("tar.exe -a -c -f", script)
self.assertNotIn("dotnet", script.lower())
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
monitor = (
PROJECT / "Application" / "JobMonitorService.cs"

View File

@ -20,7 +20,7 @@ Origin adapter 1.1.0 起随包提供独立 CLI 真机 QA harness1.3.0 的固
Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
推荐在 Node 主窗口“专业软件”区域完成后续配置Origin、ANSYS、Blender 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements不执行用户输入的命令新环境先在同一数据根目录的临时目录构建成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下Node 启动 Worker 时注入固定环境变量“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准。
推荐在 Node 主窗口“专业软件”区域完成后续配置Origin、ANSYS、Blender、FreeCAD 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”;中性 CAD 使用不带应用位置的 runtime-only 卡片。安装动作只接受内置软件定义和随包发布的固定 requirements不执行用户输入的命令新环境先在同一数据根目录的临时目录构建成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下Node 启动 Worker 时注入固定环境变量“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准FreeCAD 只接受受支持正式版本的 `FreeCADCmd.exe`
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务已有未完成任务时拒绝开始随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。

View File

@ -76,7 +76,7 @@ public sealed class AdapterArchitectureTests
[Fact]
public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers()
{
Assert.Equal(["origin", "ansys", "blender", "cad"],
Assert.Equal(["origin", "ansys", "blender", "freecad", "cad"],
SoftwareCatalog.Definitions.Select(item => item.RuntimeId));
Assert.Equal("ZCBOT_ORIGIN_EXE",
SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable);
@ -84,6 +84,10 @@ public sealed class AdapterArchitectureTests
SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable);
Assert.Equal("ZCBOT_BLENDER_EXE",
SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable);
var freecad = SoftwareCatalog.ByRuntimeId("freecad");
Assert.Equal("ZCBOT_FREECAD_CMD", freecad.EnvironmentVariable);
Assert.Equal("FreeCADCmd.exe", freecad.RequiredRelativePath);
Assert.Equal(SoftwarePathKind.Executable, freecad.PathKind);
var cad = SoftwareCatalog.ByRuntimeId("cad");
Assert.Equal(SoftwarePathKind.None, cad.PathKind);
Assert.Null(cad.EnvironmentVariable);

View File

@ -28,6 +28,14 @@ internal static class SoftwareCatalog
"ZCBOT_BLENDER_EXE",
"blender.exe",
@"adapters\blender.scene.author@v3\requirements.txt"),
new(
"freecad",
"FreeCAD",
"freecad",
SoftwarePathKind.Executable,
"ZCBOT_FREECAD_CMD",
"FreeCADCmd.exe",
@"adapters\freecad.model.author@v1\requirements.txt"),
new(
"cad",
"中性 CAD",

View File

@ -39,6 +39,7 @@ internal static class SoftwareLocationService
"origin" => DetectOrigin(definition),
"ansys" => DetectAnsys(definition),
"blender" => DetectBlender(definition),
"freecad" => DetectFreeCad(definition),
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
};
}
@ -190,6 +191,62 @@ internal static class SoftwareLocationService
return FirstLocation(definition, candidates);
}
private static SoftwareLocation DetectFreeCad(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
var path = FindOnPath("FreeCADCmd.exe");
if (path is not null)
{
candidates.Add((path, "PATH"));
}
foreach (var location in UninstallLocations("FreeCAD"))
{
candidates.Add((Path.Combine(location, "bin", "FreeCADCmd.exe"), "注册表"));
candidates.Add((Path.Combine(location, "FreeCADCmd.exe"), "注册表"));
}
foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" })
{
var root = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) continue;
try
{
candidates.AddRange(Directory.EnumerateDirectories(root, "FreeCAD*")
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
.SelectMany(item => new[]
{
(Path.Combine(item, "bin", "FreeCADCmd.exe"), "标准目录"),
(Path.Combine(item, "FreeCADCmd.exe"), "标准目录"),
}));
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
// Continue with candidates found through environment, PATH, and registry.
}
}
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var localPrograms = Path.Combine(localAppData, "Programs");
if (Directory.Exists(localPrograms))
{
try
{
candidates.AddRange(Directory.EnumerateDirectories(localPrograms, "FreeCAD*")
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
.SelectMany(item => new[]
{
(Path.Combine(item, "bin", "FreeCADCmd.exe"), "用户标准目录"),
(Path.Combine(item, "FreeCADCmd.exe"), "用户标准目录"),
}));
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
// Continue with machine-wide standard directories.
}
}
return FirstLocation(definition, candidates);
}
private static SoftwareLocation FirstLocation(
SoftwareDefinition definition,
IEnumerable<(string Path, string Source)> candidates)

View File

@ -53,5 +53,10 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\freecad.model.author.v1.json">
<Link>adapters\freecad.model.author@v1\freecad.model.author.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,366 @@
"""Fixed target-machine acceptance suite for FreeCAD 1.1.3."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
ADAPTER_ROOT = Path(__file__).resolve().parent
WORKER = ADAPTER_ROOT / "worker.py"
CASES = ("basic", "parameters", "boolean", "transform", "step_import", "workspace")
def _json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def _digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _model(case: str, revision: int = 1) -> dict[str, object]:
common: dict[str, object] = {
"type": "recipe",
"recipe_version": 1,
"title": f"FreeCAD acceptance {case}",
"unit": "mm",
}
if case == "basic":
common.update(
{
"steps": [
{"id": "box", "op": "box", "length": 20, "width": 14, "height": 8},
{"id": "cylinder", "op": "cylinder", "radius": 4, "height": 12},
{
"id": "cone",
"op": "cone",
"radius1": 5,
"radius2": 2,
"height": 9,
},
{"id": "sphere", "op": "sphere", "radius": 6},
{
"id": "torus",
"op": "torus",
"major_radius": 8,
"minor_radius": 2,
},
{
"id": "all",
"op": "combine",
"targets": ["box", "cylinder", "cone", "sphere", "torus"],
},
],
"result": "all",
}
)
elif case == "parameters":
common.update(
{
"parameters": {"length": 20 + revision, "radius": 4},
"steps": [
{
"id": "body",
"op": "box",
"length": {"parameter": "length"},
"width": 12,
"height": 8,
},
{
"id": "hole",
"op": "cylinder",
"radius": {"parameter": "radius"},
"height": 8,
},
{"id": "result", "op": "cut", "target": "body", "tools": ["hole"]},
],
"result": "result",
}
)
elif case == "boolean":
common.update(
{
"steps": [
{"id": "a", "op": "box", "length": 20, "width": 20, "height": 10},
{"id": "b0", "op": "box", "length": 12, "width": 12, "height": 12},
{"id": "b", "op": "translate", "target": "b0", "vector": [8, 8, 0]},
{"id": "union", "op": "union", "target": "a", "tools": ["b"]},
{
"id": "intersection",
"op": "intersect",
"target": "a",
"tools": ["b"],
},
{
"id": "result",
"op": "cut",
"target": "union",
"tools": ["intersection"],
},
],
"result": "result",
}
)
elif case == "transform":
common.update(
{
"steps": [
{"id": "base", "op": "box", "length": 20, "width": 8, "height": 5},
{
"id": "moved",
"op": "translate",
"target": "base",
"vector": [5, 2, 3],
},
{
"id": "result",
"op": "rotate",
"target": "moved",
"axis_point": [0, 0, 0],
"axis_direction": [0, 0, 1],
"angle_degrees": 30,
},
],
"result": "result",
}
)
elif case == "step_import":
common.update(
{
"steps": [
{"id": "imported", "op": "import_step", "input": "geometry"},
{
"id": "result",
"op": "translate",
"target": "imported",
"vector": [3, 0, 0],
},
],
"result": "result",
}
)
else:
return _model("parameters", revision)
common["preview"] = {"width": 800, "height": 600, "background": "#F7F9FC"}
return common
def _job(
root: Path, name: str, model: dict[str, object], step_input: Path | None = None
) -> dict[str, object]:
directory = root / name
directory.mkdir(parents=True)
artifact_id = str(uuid.uuid4())
inputs = []
if step_input is not None:
target = directory / "input" / "geometry" / step_input.name
target.parent.mkdir(parents=True)
shutil.copy2(step_input, target)
inputs = [{"key": "geometry", "artifact_id": artifact_id}]
request = {
"schema_version": 1,
"inputs": inputs,
"operation": {"model": model},
"outputs": [],
}
record = {
"job_id": str(uuid.uuid4()),
"lease_id": str(uuid.uuid4()),
"request_digest": hashlib.sha256(
json.dumps(request, sort_keys=True).encode()
).hexdigest(),
"request": request,
}
_json(directory / "request" / "request.json", record)
result = subprocess.run(
[sys.executable, str(WORKER), str(directory)],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=300,
)
terminal = (
json.loads((directory / "terminal.json").read_text(encoding="utf-8"))
if (directory / "terminal.json").exists()
else {}
)
passed = result.returncode == 0 and terminal.get("status") == "succeeded"
required = [
"model.FCStd",
"model.step",
"model-preview.png",
"model-recipe.json",
"model-manifest.json",
".meta/provenance.json",
]
passed = passed and all(
(directory / "output" / item).is_file() for item in required
)
return {
"name": name,
"passed": passed,
"directory": str(directory),
"returncode": result.returncode,
"terminal": terminal,
"stderr": result.stderr[-1000:],
}
def _freecad_pids() -> set[int]:
query = subprocess.run(
["tasklist.exe", "/FO", "CSV", "/NH", "/FI", "IMAGENAME eq FreeCAD*.exe"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
result: set[int] = set()
for line in query.stdout.splitlines():
parts = [item.strip('"') for item in line.split('","')]
if len(parts) > 1 and parts[1].isdigit():
result.add(int(parts[1]))
return result
def _cancel_job(root: Path, baseline: set[int]) -> dict[str, object]:
directory = root / "cancel-dispatched-job"
request = {
"schema_version": 1,
"inputs": [],
"operation": {"model": _model("basic")},
"outputs": [],
}
record = {
"job_id": str(uuid.uuid4()),
"lease_id": str(uuid.uuid4()),
"request_digest": "cancel-test",
"request": request,
}
_json(directory / "request" / "request.json", record)
process = subprocess.Popen(
[sys.executable, str(WORKER), str(directory)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + 15
saw_child = False
while time.monotonic() < deadline and process.poll() is None:
if _freecad_pids() - baseline:
saw_child = True
break
time.sleep(0.1)
subprocess.run(
["taskkill.exe", "/PID", str(process.pid), "/T", "/F"],
check=False,
capture_output=True,
timeout=30,
)
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
return {"name": "cancel", "passed": False, "detail": "launcher did not exit"}
time.sleep(1)
return {"name": "cancel", "passed": saw_child and not (_freecad_pids() - baseline)}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--repeat", type=int, default=3)
args = parser.parse_args()
root = args.work_root.resolve()
if root.exists() or not root.parent.is_dir():
print(
"[ERR] --work-root must be a new directory under an existing parent.",
file=sys.stderr,
)
return 2
root.mkdir()
baseline_pids = _freecad_pids()
results: list[dict[str, object]] = []
source = _job(root, "00-step-source", _model("basic"))
results.append(source)
source_step = Path(source["directory"]) / "output" / "model.step"
for case in CASES[:-1]:
step = source_step if case == "step_import" else None
results.append(_job(root, f"case-{case}-空格-中文", _model(case), step))
first = _job(root, "workspace-revision-1", _model("workspace", 1))
second = _job(root, "workspace-revision-2", _model("workspace", 2))
workspace = root / "workspace"
current = workspace / "current"
rollback = workspace / "rollback"
shutil.copytree(Path(first["directory"]) / "output", current)
current.rename(rollback)
shutil.copytree(Path(second["directory"]) / "output", current)
rollback_hash = _digest(rollback / "model.FCStd")
shutil.rmtree(current)
rollback.rename(current)
workspace_passed = (
first["passed"]
and second["passed"]
and _digest(current / "model.FCStd") == rollback_hash
)
results.extend(
[
first,
second,
{"name": "workspace-revise-rollback", "passed": workspace_passed},
]
)
for index in range(max(1, min(args.repeat, 20))):
results.append(
_job(root, f"repeat-{index + 1:02d}", _model("parameters", index + 1))
)
results.append(_cancel_job(root, baseline_pids))
time.sleep(2)
residual = sorted(_freecad_pids() - baseline_pids)
report = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"passed": all(bool(item.get("passed")) for item in results) and not residual,
"cases": results,
"baseline_freecad_pids": sorted(baseline_pids),
"residual_freecad_pids": residual,
"coverage": [
"primitives",
"parameters",
"boolean",
"transform",
"STEP",
"STEP import",
"FCStd roundtrip",
"STEP roundtrip",
"workspace revise/rollback",
"cancellation",
"spaces/unicode paths",
"consecutive runs",
"process cleanup",
],
"note": "The cancellation case uses the same Windows process-tree termination semantics as the node supervisor.",
}
_json(root / "acceptance-report.json", report)
print(
"[OK] FreeCAD acceptance passed."
if report["passed"]
else "[ERR] FreeCAD acceptance failed."
)
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,10 @@
{
"capability": "freecad.model.author@v1",
"adapter_version": "1.0.0",
"runtime": "python",
"runtime_id": "freecad",
"entrypoint": "worker.py",
"contract": "freecad.model.author.v1.json",
"worker_timeout_minutes": 30,
"running_detail": "FreeCAD 正在重建并验证受控模型"
}

View File

@ -0,0 +1,353 @@
"""Fixed FreeCAD-side model compiler. FreeCADCmd may import this file directly."""
from __future__ import annotations
import hashlib
import json
import os
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import FreeCAD as App
import Import
import Part
MAX_SOLIDS = 256
MAX_TRIANGLES = 500_000
JOB_ENV = "ZCBOT_FREECAD_JOB_DIR"
class EngineError(RuntimeError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(detail or code)
self.code = code
def _atomic_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
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 _checked(shape: object, role: str):
if shape is None or shape.isNull() or not shape.isValid():
raise EngineError("FREECAD_SHAPE_INVALID", role)
solids = list(shape.Solids)
if not solids:
raise EngineError("FREECAD_SHAPE_HAS_NO_SOLIDS", role)
if len(solids) > MAX_SOLIDS:
raise EngineError("FREECAD_ENTITY_LIMIT_EXCEEDED", role)
return shape
def _step_shape(path: Path):
document = App.newDocument("ZcbotStepImport")
try:
Import.insert(str(path), document.Name)
if document.recompute() is False:
raise EngineError("FREECAD_STEP_RECOMPUTE_FAILED")
shapes = [
obj.Shape.copy()
for obj in document.Objects
if hasattr(obj, "Shape") and not obj.Shape.isNull()
]
if not shapes:
raise EngineError("FREECAD_STEP_IMPORT_EMPTY")
shape = shapes[0] if len(shapes) == 1 else Part.makeCompound(shapes)
except Exception as exc:
if isinstance(exc, EngineError):
raise
raise EngineError("FREECAD_STEP_IMPORT_FAILED", str(exc)) from exc
finally:
App.closeDocument(document.Name)
return _checked(shape, "STEP input")
def _make(step: dict[str, object], values: dict[str, object], input_path: Path | None):
op = step["op"]
if op == "box":
shape = Part.makeBox(step["length"], step["width"], step["height"])
elif op == "cylinder":
shape = Part.makeCylinder(
step["radius"],
step["height"],
App.Vector(0, 0, 1),
step.get("angle_degrees", 360.0),
)
elif op == "cone":
shape = Part.makeCone(
step["radius1"],
step["radius2"],
step["height"],
App.Vector(0, 0, 1),
step.get("angle_degrees", 360.0),
)
elif op == "sphere":
shape = Part.makeSphere(
step["radius"],
App.Vector(),
App.Vector(0, 0, 1),
step.get("angle1_degrees", -90.0),
step.get("angle2_degrees", 90.0),
step.get("angle3_degrees", 360.0),
)
elif op == "torus":
shape = Part.makeTorus(
step["major_radius"],
step["minor_radius"],
App.Vector(),
App.Vector(0, 0, 1),
step.get("angle1_degrees", -180.0),
step.get("angle2_degrees", 180.0),
step.get("angle3_degrees", 360.0),
)
elif op == "import_step":
if input_path is None:
raise EngineError("FREECAD_STEP_INPUT_NOT_BOUND")
shape = _step_shape(input_path)
elif op in {"union", "cut", "intersect"}:
shape = values[step["target"]]
for name in step["tools"]:
tool = values[name]
if op == "union":
shape = shape.fuse(tool)
elif op == "cut":
shape = shape.cut(tool)
else:
shape = shape.common(tool)
elif op == "translate":
shape = values[step["target"]].copy()
shape.translate(App.Vector(*step["vector"]))
elif op == "rotate":
shape = values[step["target"]].copy()
shape.rotate(
App.Vector(*step["axis_point"]),
App.Vector(*step["axis_direction"]),
step["angle_degrees"],
)
elif op == "combine":
shape = Part.makeCompound([values[name] for name in step["targets"]])
else:
raise EngineError("FREECAD_OPERATION_UNSUPPORTED", str(op))
return _checked(shape, str(step["id"]))
def _metrics(shape: object) -> dict[str, object]:
box = shape.BoundBox
return {
"solids": len(shape.Solids),
"bounding_box": {
"min": [box.XMin, box.YMin, box.ZMin],
"max": [box.XMax, box.YMax, box.ZMax],
"size": [box.XLength, box.YLength, box.ZLength],
},
"volume": float(shape.Volume),
"area": float(shape.Area),
"center_of_mass": [
shape.CenterOfMass.x,
shape.CenterOfMass.y,
shape.CenterOfMass.z,
],
}
def _compare(expected: dict[str, object], actual: dict[str, object], role: str) -> None:
if expected["solids"] != actual["solids"]:
raise EngineError(f"FREECAD_{role}_SOLID_COUNT_MISMATCH")
expected_box = expected["bounding_box"]
actual_box = actual["bounding_box"]
scale = max([1.0, *expected_box["size"]])
box_tolerance = scale * 1e-7
for key in ("min", "max", "size"):
if any(
abs(a - b) > box_tolerance
for a, b in zip(expected_box[key], actual_box[key])
):
raise EngineError(f"FREECAD_{role}_BOUNDING_BOX_MISMATCH")
volume_tolerance = max(1e-8, abs(expected["volume"]) * 1e-7)
if abs(expected["volume"] - actual["volume"]) > volume_tolerance:
raise EngineError(f"FREECAD_{role}_VOLUME_MISMATCH")
def _mesh(shape: object) -> dict[str, object]:
box = shape.BoundBox
deflection = max(box.DiagonalLength * 0.002, 0.01)
vertices, triangles = shape.tessellate(deflection)
if not vertices or not triangles or len(triangles) > MAX_TRIANGLES:
raise EngineError("FREECAD_PREVIEW_LIMIT_EXCEEDED")
return {
"vertices": [
[float(vertex.x), float(vertex.y), float(vertex.z)] for vertex in vertices
],
"triangles": [[int(index) for index in triangle] for triangle in triangles],
}
def _surface_types(shape: object) -> dict[str, int]:
names = []
for face in shape.Faces:
names.append(type(face.Surface).__name__.removeprefix("Surface").casefold())
return dict(sorted(Counter(names).items()))
def run(job_dir: Path) -> None:
output = (job_dir / "output").resolve(strict=True)
metadata = (output / ".meta").resolve(strict=True)
if not output.is_relative_to(job_dir) or not metadata.is_relative_to(output):
raise EngineError("FREECAD_OUTPUT_PATH_ESCAPES_JOB")
payload = json.loads((metadata / "resolved-job.json").read_text(encoding="utf-8"))
model = payload["model"]
input_path = (
Path(payload["input_path"]).resolve(strict=True)
if payload.get("input_path")
else None
)
if input_path is not None and not input_path.is_relative_to(
(job_dir / "input").resolve(strict=True)
):
raise EngineError("FREECAD_INPUT_PATH_ESCAPES_JOB")
document = App.newDocument("ZcbotModel")
values: dict[str, object] = {}
objects: dict[str, object] = {}
try:
for step in model["steps"]:
shape = _make(step, values, input_path)
values[step["id"]] = shape
obj = document.addObject("Part::Feature", step["id"])
obj.Label = step["id"]
obj.Shape = shape
objects[step["id"]] = obj
result_shape = _checked(values[model["result"]], "result")
result_object_name = objects[model["result"]].Name
if document.recompute() is False:
raise EngineError("FREECAD_DOCUMENT_RECOMPUTE_FAILED")
for obj in document.Objects:
if (
hasattr(obj, "Shape")
and not obj.Shape.isNull()
and not obj.Shape.isValid()
):
raise EngineError("FREECAD_DOCUMENT_SHAPE_INVALID", obj.Name)
project_path = output / "model.FCStd"
document.saveAs(str(project_path))
baseline = _metrics(result_shape)
finally:
App.closeDocument(document.Name)
reopened = App.openDocument(str(project_path))
try:
if reopened.recompute() is False:
raise EngineError("FREECAD_FCSTD_RECOMPUTE_FAILED")
result_object = reopened.getObject(result_object_name)
if result_object is None:
raise EngineError("FREECAD_FCSTD_RESULT_MISSING")
fcstd_shape = _checked(result_object.Shape, "FCStd roundtrip")
_compare(baseline, _metrics(fcstd_shape), "FCSTD_ROUNDTRIP")
step_path = output / "model.step"
fcstd_shape.exportStep(str(step_path))
if not step_path.is_file() or step_path.stat().st_size < 256:
raise EngineError("FREECAD_STEP_EXPORT_EMPTY")
finally:
App.closeDocument(reopened.Name)
step_shape = _step_shape(step_path)
step_metrics = _metrics(step_shape)
_compare(baseline, step_metrics, "STEP_ROUNDTRIP")
_atomic_json(
output / "model-recipe.json",
{
"schema_version": 1,
"recipe": payload["recipe"],
"resolved_model": model,
},
)
_atomic_json(
output / "model-manifest.json",
{
"schema_version": 1,
"title": model["title"],
"unit": "mm",
"valid": True,
"result": model["result"],
"step_count": len(model["steps"]),
"operation_counts": dict(
sorted(Counter(step["op"] for step in model["steps"]).items())
),
"geometry": step_metrics,
"surface_types": _surface_types(step_shape),
"roundtrip": {"fcstd": "verified", "step": "verified"},
"input": None
if input_path is None
else {
"filename": input_path.name,
"size_bytes": input_path.stat().st_size,
"sha256": _sha256(input_path),
},
},
)
_atomic_json(metadata / "preview-mesh.json", _mesh(step_shape))
_atomic_json(
metadata / "provenance.json",
{
"adapter_version": payload["adapter_version"],
"freecad_version": payload["software_version"],
"request_digest": payload.get("request_digest"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"network_access": False,
"gui_started": False,
"safe_mode": True,
"fcstd_sha256": _sha256(project_path),
"step_sha256": _sha256(step_path),
},
)
def _entry() -> None:
raw_job_dir = os.environ.get(JOB_ENV)
if not raw_job_dir:
return
job_dir = Path(raw_job_dir).resolve(strict=True)
result_path = job_dir / "output" / ".meta" / "engine-result.json"
try:
run(job_dir)
_atomic_json(result_path, {"status": "succeeded"})
except Exception as exc: # noqa: BLE001 - fixed engine boundary must report all failures
code = exc.code if isinstance(exc, EngineError) else "FREECAD_ENGINE_FAILED"
_atomic_json(
result_path,
{
"status": "failed",
"code": code,
"detail": f"{type(exc).__name__}: {exc}"[:500],
},
)
print(f"[ERR] {code}: {exc}", file=sys.stderr)
# FreeCADCmd can execute Python files by importing them, so do not rely on __main__.
_entry()

View File

@ -0,0 +1,83 @@
"""Deterministic GPU-free isometric renderer for FreeCAD tessellation output."""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
MAX_TRIANGLES = 500_000
def render(
mesh_path: Path, output_path: Path, options: dict[str, object] | None = None
) -> None:
from PIL import Image, ImageColor, ImageDraw
payload = json.loads(mesh_path.read_text(encoding="utf-8"))
vertices = payload.get("vertices")
triangles = payload.get("triangles")
if not isinstance(vertices, list) or not isinstance(triangles, list):
raise TypeError("FREECAD_PREVIEW_MESH_INVALID")
if not vertices or not triangles or len(triangles) > MAX_TRIANGLES:
raise RuntimeError("FREECAD_PREVIEW_LIMIT_EXCEEDED")
settings = options or {}
width = int(settings.get("width", 1200))
height = int(settings.get("height", 900))
if not 320 <= width <= 1600 or not 240 <= height <= 1600:
raise RuntimeError("FREECAD_PREVIEW_SIZE_INVALID")
background = ImageColor.getrgb(str(settings.get("background", "#F7F9FC")))
projected: list[tuple[float, float, float]] = []
for item in vertices:
if not isinstance(item, list) or len(item) != 3:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID")
x, y, z = map(float, item)
projected.append(
(0.8660254 * (x - y), 0.5 * (x + y) - z, 0.4082483 * (x + y + z))
)
xs = [item[0] for item in projected]
ys = [item[1] for item in projected]
span_x = max(xs) - min(xs)
span_y = max(ys) - min(ys)
scale = min(width * 0.82 / max(span_x, 1e-12), height * 0.82 / max(span_y, 1e-12))
center_x = (min(xs) + max(xs)) / 2
center_y = (min(ys) + max(ys)) / 2
points = [
((x - center_x) * scale + width / 2, (y - center_y) * scale + height / 2, depth)
for x, y, depth in projected
]
image = Image.new("RGB", (width, height), background)
draw = ImageDraw.Draw(image)
light = (0.3, -0.45, 0.84)
ordered = sorted(
triangles, key=lambda tri: sum(points[int(index)][2] for index in tri) / 3
)
for triangle in ordered:
if not isinstance(triangle, list) or len(triangle) != 3:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID")
indices = [int(item) for item in triangle]
try:
a, b, c = (vertices[index] for index in indices)
except (IndexError, TypeError) as exc:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID") from exc
ab = tuple(float(b[i]) - float(a[i]) for i in range(3))
ac = tuple(float(c[i]) - float(a[i]) for i in range(3))
normal = (
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
)
magnitude = max(math.sqrt(sum(item * item for item in normal)), 1e-12)
shade = 0.42 + 0.48 * abs(
sum(normal[i] * light[i] for i in range(3)) / magnitude
)
color = tuple(int(channel * shade) for channel in (96, 158, 210))
draw.polygon(
[(points[index][0], points[index][1]) for index in indices],
fill=color,
outline=(45, 65, 82),
)
temporary = output_path.with_name(output_path.name + ".tmp.png")
image.save(temporary, format="PNG", optimize=True)
os.replace(temporary, output_path)

View File

@ -0,0 +1,172 @@
"""Pure validation and parameter resolution for the FreeCAD recipe contract."""
from __future__ import annotations
import copy
import math
from typing import Any
MAX_STEPS = 128
MAX_PARAMETERS = 64
MAX_ENTITIES = 256
SUPPORTED_OPS = {
"box",
"cylinder",
"cone",
"sphere",
"torus",
"import_step",
"union",
"cut",
"intersect",
"translate",
"rotate",
"combine",
}
class RecipeError(ValueError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(detail or code)
self.code = code
def _number(value: Any, parameters: dict[str, float], role: str) -> float:
if isinstance(value, bool):
raise RecipeError("FREECAD_VALUE_INVALID", role)
if isinstance(value, (int, float)):
result = float(value)
elif isinstance(value, dict) and set(value) == {"parameter"}:
name = value["parameter"]
if not isinstance(name, str) or name not in parameters:
raise RecipeError("FREECAD_PARAMETER_UNKNOWN", str(name))
result = parameters[name]
else:
raise RecipeError("FREECAD_VALUE_INVALID", role)
if not math.isfinite(result) or abs(result) > 1_000_000:
raise RecipeError("FREECAD_VALUE_OUT_OF_RANGE", role)
return result
def _resolve(value: Any, parameters: dict[str, float], role: str) -> Any:
if isinstance(value, dict):
if set(value) == {"parameter"}:
return _number(value, parameters, role)
return {
key: _resolve(item, parameters, f"{role}.{key}")
for key, item in value.items()
}
if isinstance(value, list):
return [_resolve(item, parameters, f"{role}[]") for item in value]
return value
def normalize_model(model: dict[str, Any], input_bound: bool) -> dict[str, Any]:
if model.get("type") != "recipe" or model.get("recipe_version") != 1:
raise RecipeError("FREECAD_RECIPE_VERSION_UNSUPPORTED")
raw_parameters = model.get("parameters") or {}
if not isinstance(raw_parameters, dict) or len(raw_parameters) > MAX_PARAMETERS:
raise RecipeError("FREECAD_PARAMETER_LIMIT_EXCEEDED")
parameters = {
name: _number(value, {}, f"parameters.{name}")
for name, value in raw_parameters.items()
}
steps = model.get("steps")
if not isinstance(steps, list) or not 1 <= len(steps) <= MAX_STEPS:
raise RecipeError("FREECAD_STEP_LIMIT_EXCEEDED")
resolved = copy.deepcopy(model)
resolved["parameters"] = parameters
resolved["steps"] = [
_resolve(step, parameters, f"steps[{index}]")
for index, step in enumerate(steps)
]
known: set[str] = set()
imports = 0
estimated_entities: dict[str, int] = {}
for step in resolved["steps"]:
name = step.get("id")
op = step.get("op")
if not isinstance(name, str) or name in known:
raise RecipeError("FREECAD_STEP_ID_DUPLICATE", str(name))
if op not in SUPPORTED_OPS:
raise RecipeError("FREECAD_OPERATION_UNSUPPORTED", str(op))
references: list[str] = []
if op in {"union", "cut", "intersect"}:
references = [step.get("target"), *(step.get("tools") or [])]
elif op in {"translate", "rotate"}:
references = [step.get("target")]
elif op == "combine":
references = list(step.get("targets") or [])
if any(not isinstance(item, str) or item not in known for item in references):
raise RecipeError("FREECAD_STEP_REFERENCE_UNKNOWN", name)
if op == "import_step":
imports += 1
if step.get("input") != "geometry" or not input_bound:
raise RecipeError("FREECAD_STEP_INPUT_NOT_BOUND")
if op == "box":
_positive(step, ("length", "width", "height"))
elif op == "cylinder":
_positive(step, ("radius", "height"))
_angle(step, "angle_degrees", 360.0, positive=True)
elif op == "cone":
_positive(step, ("height",))
_nonnegative(step, ("radius1", "radius2"))
if step["radius1"] == 0 and step["radius2"] == 0:
raise RecipeError("FREECAD_CONE_RADII_INVALID", name)
_angle(step, "angle_degrees", 360.0, positive=True)
elif op == "sphere":
_positive(step, ("radius",))
_angle(step, "angle1_degrees", -90.0)
_angle(step, "angle2_degrees", 90.0)
_angle(step, "angle3_degrees", 360.0, positive=True)
if step.get("angle1_degrees", -90.0) >= step.get("angle2_degrees", 90.0):
raise RecipeError("FREECAD_SPHERE_ANGLES_INVALID", name)
elif op == "torus":
_positive(step, ("major_radius", "minor_radius"))
if step["minor_radius"] >= step["major_radius"]:
raise RecipeError("FREECAD_TORUS_RADII_INVALID", name)
_angle(step, "angle1_degrees", -180.0)
_angle(step, "angle2_degrees", 180.0)
_angle(step, "angle3_degrees", 360.0, positive=True)
elif op == "rotate":
direction = step.get("axis_direction") or []
if (
len(direction) != 3
or math.sqrt(sum(float(item) ** 2 for item in direction)) <= 1e-12
):
raise RecipeError("FREECAD_ROTATION_AXIS_INVALID", name)
if op == "combine":
estimated_entities[name] = sum(
estimated_entities[item] for item in references
)
elif op in {"translate", "rotate"}:
estimated_entities[name] = estimated_entities[references[0]]
else:
estimated_entities[name] = 1
if estimated_entities[name] > MAX_ENTITIES:
raise RecipeError("FREECAD_ENTITY_LIMIT_EXCEEDED", name)
known.add(name)
if input_bound != (imports == 1):
raise RecipeError("FREECAD_INPUT_USAGE_INVALID")
if resolved.get("result") not in known:
raise RecipeError("FREECAD_RESULT_UNKNOWN")
return resolved
def _positive(step: dict[str, Any], keys: tuple[str, ...]) -> None:
if any(float(step[key]) <= 0 for key in keys):
raise RecipeError("FREECAD_POSITIVE_VALUE_REQUIRED", str(step.get("id")))
def _nonnegative(step: dict[str, Any], keys: tuple[str, ...]) -> None:
if any(float(step[key]) < 0 for key in keys):
raise RecipeError("FREECAD_NONNEGATIVE_VALUE_REQUIRED", str(step.get("id")))
def _angle(
step: dict[str, Any], key: str, default: float, positive: bool = False
) -> None:
value = float(step.get(key, default))
if (positive and value <= 0) or abs(value) > 360:
raise RecipeError("FREECAD_ANGLE_INVALID", str(step.get("id")))

View File

@ -0,0 +1 @@
pillow==11.3.0

View File

@ -0,0 +1,383 @@
"""Managed-Python launcher for the fixed FreeCAD authoring worker."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from preview_renderer import render
from recipe import RecipeError, normalize_model
ADAPTER_VERSION = "1.0.0"
FREECAD_ENV = "ZCBOT_FREECAD_CMD"
JOB_ENV = "ZCBOT_FREECAD_JOB_DIR"
MIN_VERSION = (1, 1, 3)
MAX_VERSION = (1, 2, 0)
MAX_INPUT_BYTES = 256 * 1024 * 1024
OUTPUT_MEDIA = {
"model.FCStd": ("project", "application/vnd.freecad"),
"model.step": ("geometry_step", "model/step"),
"model-preview.png": ("preview", "image/png"),
"model-recipe.json": ("model_recipe", "application/json"),
"model-manifest.json": ("model_manifest", "application/json"),
"provenance.json": ("provenance", "application/json"),
}
class AdapterError(RuntimeError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(detail or code)
self.code = code
def _atomic_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
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 _candidate_paths() -> list[Path]:
candidates: list[Path] = []
configured = os.environ.get(FREECAD_ENV)
if configured:
candidates.append(Path(configured))
discovered = shutil.which("FreeCADCmd.exe")
if discovered:
candidates.append(Path(discovered))
for variable in ("ProgramFiles", "ProgramW6432"):
root = os.environ.get(variable)
if not root:
continue
base = Path(root)
candidates.extend(
sorted(base.glob("FreeCAD 1.1*/bin/FreeCADCmd.exe"), reverse=True)
)
candidates.extend(
sorted(base.glob("FreeCAD 1.1*/FreeCADCmd.exe"), reverse=True)
)
candidates.extend(
sorted(base.glob("FreeCAD*/bin/FreeCADCmd.exe"), reverse=True)
)
result: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
if candidate.name.casefold() != "freecadcmd.exe":
continue
try:
resolved = candidate.resolve(strict=True)
except (OSError, RuntimeError):
continue
key = str(resolved).casefold()
if resolved.is_file() and key not in seen:
seen.add(key)
result.append(resolved)
return result
def _version(executable: Path) -> str:
result = subprocess.run(
[str(executable), "--version"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=7,
env=_clean_environment(),
)
output = (result.stdout + "\n" + result.stderr).strip()
match = re.search(
r"FreeCAD(?:Cmd)?\s+([0-9]+\.[0-9]+\.[0-9]+)", output, re.IGNORECASE
)
if result.returncode != 0 or match is None:
raise AdapterError("FREECAD_VERSION_PROBE_FAILED", output[:300])
lowered = output.casefold()
if any(
token in lowered
for token in (
"rc",
"weekly",
"experimental",
"development",
"preview",
"devr",
".dev",
)
) or re.search(r"\ddev(?:[.\dr]|$)", lowered):
raise AdapterError("FREECAD_PRERELEASE_UNSUPPORTED", output[:300])
version = match.group(1)
parts = tuple(int(item) for item in version.split("."))
if parts < MIN_VERSION or parts >= MAX_VERSION:
raise AdapterError("FREECAD_VERSION_UNSUPPORTED", version)
return version
def _find_freecad() -> tuple[Path, str]:
failures: list[str] = []
for candidate in _candidate_paths():
try:
return candidate, _version(candidate)
except (AdapterError, OSError, subprocess.SubprocessError) as exc:
failures.append(f"{candidate}: {exc}")
detail = "; ".join(failures) if failures else "FreeCADCmd.exe was not found"
raise AdapterError("FREECAD_NOT_AVAILABLE", detail[:500])
def _clean_environment() -> dict[str, str]:
environment = dict(os.environ)
for name in tuple(environment):
if name.casefold() in {"pythonpath", "pythonhome"}:
environment.pop(name, None)
environment["PYTHONNOUSERSITE"] = "1"
environment["PYTHONUTF8"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
return environment
def _record(job_dir: Path) -> dict[str, Any]:
value = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
if not isinstance(value, dict) or not isinstance(value.get("request"), dict):
raise AdapterError("FREECAD_JOB_REQUEST_INVALID")
return value
def _input(job_dir: Path, bindings: list[dict[str, Any]]) -> Path | None:
if not bindings:
return None
if len(bindings) != 1 or bindings[0].get("key") != "geometry":
raise AdapterError("FREECAD_INPUT_BINDING_INVALID")
input_root = (job_dir / "input").resolve(strict=True)
directory = (input_root / "geometry").resolve(strict=True)
if not directory.is_relative_to(input_root):
raise AdapterError("FREECAD_INPUT_PATH_ESCAPES_JOB")
files = [
item
for item in directory.iterdir()
if item.is_file() and not item.name.startswith(".")
]
if len(files) != 1:
raise AdapterError("FREECAD_INPUT_FILE_COUNT_INVALID")
path = files[0].resolve(strict=True)
if not path.is_relative_to(input_root) or path.suffix.casefold() not in {
".step",
".stp",
}:
raise AdapterError("FREECAD_STEP_INPUT_REQUIRED")
if path.stat().st_size > MAX_INPUT_BYTES:
raise AdapterError("FREECAD_STEP_INPUT_TOO_LARGE")
return path
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]],
code: str = "",
detail: str = "",
) -> 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": {} if not code else {"code": code, "detail": detail[:500]},
"finished_at": datetime.now(timezone.utc).isoformat(),
}
def probe() -> int:
payload: dict[str, Any] = {
"adapter_version": ADAPTER_VERSION,
"software": "FreeCAD",
"software_version": None,
"health": "unavailable",
"detail": "",
}
try:
executable, version = _find_freecad()
payload.update(
{
"software_version": version,
"health": "ready",
"detail": f"FreeCADCmd.exe {version} detected: {executable}",
}
)
except (AdapterError, OSError, subprocess.SubprocessError) as exc:
payload["detail"] = str(exc)[:500]
print(json.dumps(payload, ensure_ascii=False))
return 0
def run(job_dir: Path) -> list[dict[str, Any]]:
job_dir = job_dir.resolve(strict=True)
record = _record(job_dir)
request = record["request"]
if request.get("outputs"):
raise AdapterError("FREECAD_OUTPUTS_MUST_BE_EMPTY")
bindings = request.get("inputs") or []
input_path = _input(job_dir, bindings)
try:
normalized = normalize_model(
request["operation"]["model"], input_path is not None
)
except RecipeError as exc:
raise AdapterError(exc.code, str(exc)) from exc
executable, software_version = _find_freecad()
output = (job_dir / "output").resolve()
output.mkdir(exist_ok=True)
if not output.is_relative_to(job_dir):
raise AdapterError("FREECAD_OUTPUT_PATH_ESCAPES_JOB")
metadata = output / ".meta"
metadata.mkdir(exist_ok=True)
_atomic_json(
metadata / "resolved-job.json",
{
"recipe": request["operation"]["model"],
"model": normalized,
"input_path": str(input_path) if input_path else None,
"software_version": software_version,
"adapter_version": ADAPTER_VERSION,
"request_digest": record.get("request_digest"),
},
)
runtime = job_dir / ".freecad-runtime"
runtime.mkdir(exist_ok=False)
user_cfg = runtime / "user.cfg"
system_cfg = runtime / "system.cfg"
user_cfg.write_text("", encoding="utf-8")
system_cfg.write_text("", encoding="utf-8")
script = Path(__file__).with_name("freecad_worker.py").resolve(strict=True)
environment = _clean_environment()
environment.update(
{
JOB_ENV: str(job_dir),
"FREECAD_USER_HOME": str(runtime / "home"),
"FREECAD_USER_DATA": str(runtime / "data"),
"FREECAD_USER_TEMP": str(runtime / "temp"),
}
)
for name in ("home", "data", "temp"):
(runtime / name).mkdir()
try:
result = subprocess.run(
[
str(executable),
"--safe-mode",
"--user-cfg",
str(user_cfg),
"--system-cfg",
str(system_cfg),
str(script),
],
check=False,
env=environment,
)
finally:
shutil.rmtree(runtime, ignore_errors=True)
engine_result_path = metadata / "engine-result.json"
if not engine_result_path.is_file():
raise AdapterError(
"FREECAD_NO_ENGINE_RESULT",
f"FreeCADCmd.exe exited with code {result.returncode}",
)
engine_result = json.loads(engine_result_path.read_text(encoding="utf-8"))
if engine_result.get("status") != "succeeded":
raise AdapterError(
str(engine_result.get("code") or "FREECAD_ENGINE_FAILED"),
str(engine_result.get("detail") or "FreeCAD engine failed"),
)
mesh_path = metadata / "preview-mesh.json"
render(mesh_path, output / "model-preview.png", normalized.get("preview"))
mesh_path.unlink(missing_ok=True)
(metadata / "resolved-job.json").unlink(missing_ok=True)
engine_result_path.unlink(missing_ok=True)
paths = [
output / "model.FCStd",
output / "model.step",
output / "model-preview.png",
output / "model-recipe.json",
output / "model-manifest.json",
metadata / "provenance.json",
]
if any(not path.is_file() or path.stat().st_size == 0 for path in paths):
raise AdapterError("FREECAD_OUTPUT_MISSING")
return [_artifact(path) for path in paths]
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 = _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] FreeCAD model authored and verified.")
return 0
except Exception as exc: # noqa: BLE001 - terminal must capture every launcher/engine failure
code = exc.code if isinstance(exc, AdapterError) else "FREECAD_ADAPTER_FAILED"
try:
_atomic_json(
job_dir / "terminal.json",
_terminal(record, "failed", [], code, str(exc)),
)
except OSError:
pass
print(f"[ERR] {code}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,47 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
set "OUTPUT_ROOT=%~f1"
if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist"
set "ADAPTER_DIR_NAME=freecad.model.author@v1"
set "ARCHIVE_NAME=freecad.model.author@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!"
for %%F in (adapter.json worker.py freecad_worker.py recipe.py preview_renderer.py requirements.txt acceptance.py) do (
copy /y "adapters\freecad.model.author@v1\%%F" "!PACKAGE_DIR!\%%F" >nul
if errorlevel 1 goto :failed
)
copy /y "..\software-contracts\freecad.model.author.v1.json" "!PACKAGE_DIR!\freecad.model.author.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] FreeCAD adapter packaging failed.
exit /b 1