From 09cb7d327b8b3f74a6bdf6aa4bbe6d6f0cd6796e Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 3 Sep 2026 16:39:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(freecad):=20=E6=8E=A5=E5=85=A5=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E5=BB=BA=E6=A8=A1=20Windows=20Node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + DESIGN.md | 6 +- PROGRESS.md | 6 +- RUN.md | 12 +- .../freecad.model.author.v1.json | 121 ++++++ tests/test_freecad_adapter.py | 234 +++++++++++ tests/test_software_nodes.py | 1 + tests/test_windows_node_source.py | 20 +- windows-node/README.md | 2 +- .../AdapterArchitectureTests.cs | 6 +- .../Runtime/SoftwareCatalog.cs | 8 + .../Runtime/SoftwareLocationService.cs | 57 +++ .../Zcbot.WindowsNode.csproj | 5 + .../freecad.model.author@v1/acceptance.py | 366 +++++++++++++++++ .../freecad.model.author@v1/adapter.json | 10 + .../freecad.model.author@v1/freecad_worker.py | 353 ++++++++++++++++ .../preview_renderer.py | 83 ++++ .../freecad.model.author@v1/recipe.py | 172 ++++++++ .../freecad.model.author@v1/requirements.txt | 1 + .../freecad.model.author@v1/worker.py | 383 ++++++++++++++++++ windows-node/package-freecad-adapter.bat | 47 +++ 21 files changed, 1886 insertions(+), 9 deletions(-) create mode 100644 software-contracts/freecad.model.author.v1.json create mode 100644 tests/test_freecad_adapter.py create mode 100644 windows-node/adapters/freecad.model.author@v1/acceptance.py create mode 100644 windows-node/adapters/freecad.model.author@v1/adapter.json create mode 100644 windows-node/adapters/freecad.model.author@v1/freecad_worker.py create mode 100644 windows-node/adapters/freecad.model.author@v1/preview_renderer.py create mode 100644 windows-node/adapters/freecad.model.author@v1/recipe.py create mode 100644 windows-node/adapters/freecad.model.author@v1/requirements.txt create mode 100644 windows-node/adapters/freecad.model.author@v1/worker.py create mode 100644 windows-node/package-freecad-adapter.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index 2701698..632a3b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。 +- 新增 FreeCAD 受控建模:可通过参数化 Recipe 组合基础体、布尔运算、变换和 STEP 导入,交付可继续调整的 FCStd 工程、STEP、模型清单与无显卡预览,并支持版本续作和失败回滚。 + - 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;工具异常统一按结构化事件统计,代理主动保护与真正故障分开展示,多人同时查看或自动刷新时不再反复扫描历史消息。 - 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。 diff --git a/DESIGN.md b/DESIGN.md index bbef21c..ab46ef1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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,并统一声明材质、变换、灯光、世界背景和 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。 @@ -504,7 +508,7 @@ Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建 专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `/workspaces//`,不再按 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 state;ANSYS 静力 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 state;ANSYS 静力 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,避免暴露看似可配但不能稳定复现的参数。 diff --git a/PROGRESS.md b/PROGRESS.md index 28ce6b1..86acd6c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 固定调用 Part;Recipe 每次从空文档完整重建,参数引用先解析为数值,不写 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 门控) ───────────────────────────────── diff --git a/RUN.md b/RUN.md index 6acda9c..ba7fdb1 100644 --- a/RUN.md +++ b/RUN.md @@ -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 哈希校验。解释器位于 `\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 哈希校验。解释器位于 `\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 目录固定为 `\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 -主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender 和中性 CAD 分别管理隔离 runtime;前三者同时管理桌面应用位置,中性 CAD 是 runtime-only 软件卡,不显示也不接受“选择位置/自动检测”。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`,ANSYS 与 Blender 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过;CAD probe 会验证固定依赖、基本体和 STEP roundtrip。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。 +主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender、FreeCAD 和中性 CAD 分别管理隔离 runtime;前四者同时管理桌面应用位置,中性 CAD 是 runtime-only 软件卡,不显示也不接受“选择位置/自动检测”。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`,ANSYS、Blender 与 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/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`。 Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status` 的 `editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source,节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `\workspaces\\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。 +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 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`。 普通几何先提交 `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` 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` 时应修复镜像缓存,不能添加忽略哈希参数。 +FreeCAD adapter 可用 `windows-node\package-freecad-adapter.bat` 独立打成 `dist\freecad.model.author@v1-adapter.zip`;退出 Node 后整体替换 `adapters\freecad.model.author@v1\`,在“FreeCAD”卡确认应用位置并安装/更新固定 `freecad` runtime。目标机用 `\runtimes\freecad\Scripts\python.exe \adapters\freecad.model.author@v1\acceptance.py --work-root \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 卡片已安装的受管解释器运行:`\runtimes\origin\Scripts\python.exe \adapters\origin.plot@v2\acceptance.py --work-root \origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band,也可重复传入 `--case ` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait ` 调整释放等待时间。最终报告位于 `\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。 `origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact;除 `data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X,积分输出累计值及可选 `from/to` 区间值,线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差,并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。 @@ -1194,7 +1200,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打 注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 -直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及四套独立 runtime;中性 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% 重传;这些本地结果不会反向覆盖云端终态。 diff --git a/software-contracts/freecad.model.author.v1.json b/software-contracts/freecad.model.author.v1.json new file mode 100644 index 0000000..a45ca7b --- /dev/null +++ b/software-contracts/freecad.model.author.v1.json @@ -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"} + } + } + } + } +} diff --git a/tests/test_freecad_adapter.py b/tests/test_freecad_adapter.py new file mode 100644 index 0000000..1cba553 --- /dev/null +++ b/tests/test_freecad_adapter.py @@ -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() diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index f9f7c60..72275f2 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -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)) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 7b75cc9..138bade 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -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" diff --git a/windows-node/README.md b/windows-node/README.md index 9b4519f..c47ee96 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -20,7 +20,7 @@ Origin adapter 1.1.0 起随包提供独立 CLI 真机 QA harness;1.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 位于 `\runtimes\\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 安装到当前数据根目录,后续更新不会写回默认目录。 diff --git a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs index 1258697..99750d1 100644 --- a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs +++ b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs @@ -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); diff --git a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs index 36d3945..dd77b96 100644 --- a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs +++ b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs @@ -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", diff --git a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareLocationService.cs b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareLocationService.cs index 158e7b6..56661c6 100644 --- a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareLocationService.cs +++ b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareLocationService.cs @@ -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) diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index b059db1..b8754a8 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -53,5 +53,10 @@ PreserveNewest PreserveNewest + + adapters\freecad.model.author@v1\freecad.model.author.v1.json + PreserveNewest + PreserveNewest + diff --git a/windows-node/adapters/freecad.model.author@v1/acceptance.py b/windows-node/adapters/freecad.model.author@v1/acceptance.py new file mode 100644 index 0000000..7505f5c --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/acceptance.py @@ -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()) diff --git a/windows-node/adapters/freecad.model.author@v1/adapter.json b/windows-node/adapters/freecad.model.author@v1/adapter.json new file mode 100644 index 0000000..21a6a80 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/adapter.json @@ -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 正在重建并验证受控模型" +} diff --git a/windows-node/adapters/freecad.model.author@v1/freecad_worker.py b/windows-node/adapters/freecad.model.author@v1/freecad_worker.py new file mode 100644 index 0000000..ef61c40 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/freecad_worker.py @@ -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() diff --git a/windows-node/adapters/freecad.model.author@v1/preview_renderer.py b/windows-node/adapters/freecad.model.author@v1/preview_renderer.py new file mode 100644 index 0000000..8cedd47 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/preview_renderer.py @@ -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) diff --git a/windows-node/adapters/freecad.model.author@v1/recipe.py b/windows-node/adapters/freecad.model.author@v1/recipe.py new file mode 100644 index 0000000..54c75d0 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/recipe.py @@ -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"))) diff --git a/windows-node/adapters/freecad.model.author@v1/requirements.txt b/windows-node/adapters/freecad.model.author@v1/requirements.txt new file mode 100644 index 0000000..c519fe2 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/requirements.txt @@ -0,0 +1 @@ +pillow==11.3.0 diff --git a/windows-node/adapters/freecad.model.author@v1/worker.py b/windows-node/adapters/freecad.model.author@v1/worker.py new file mode 100644 index 0000000..be53e45 --- /dev/null +++ b/windows-node/adapters/freecad.model.author@v1/worker.py @@ -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 ", 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()) diff --git a/windows-node/package-freecad-adapter.bat b/windows-node/package-freecad-adapter.bat new file mode 100644 index 0000000..cdf9fe9 --- /dev/null +++ b/windows-node/package-freecad-adapter.bat @@ -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