feat(freecad): 接入受控建模 Windows Node
This commit is contained in:
parent
4af4816aa2
commit
09cb7d327b
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。
|
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。
|
||||||
|
|
||||||
|
- 新增 FreeCAD 受控建模:可通过参数化 Recipe 组合基础体、布尔运算、变换和 STEP 导入,交付可继续调整的 FCStd 工程、STEP、模型清单与无显卡预览,并支持版本续作和失败回滚。
|
||||||
|
|
||||||
- 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;工具异常统一按结构化事件统计,代理主动保护与真正故障分开展示,多人同时查看或自动刷新时不再反复扫描历史消息。
|
- 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;工具异常统一按结构化事件统计,代理主动保护与真正故障分开展示,多人同时查看或自动刷新时不再反复扫描历史消息。
|
||||||
|
|
||||||
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。
|
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,10 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
|
||||||
|
|
||||||
首版不输出 GLB:当前稳定事实源和跨求解器交换面是 STEP,现有 GLB 链路依赖 Blender/装配导出,未在独立 CAD runtime 上形成同等稳定的 roundtrip 与打包验证;后续只有在 CAD runtime 内可独立验证时再作为新增可选产物。
|
首版不输出 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/旧节点协议不受影响。
|
`ansys.geometry.inspect@v1` 继续保留原合同和 adapter,专门验证 Mechanical 真实导入后的 GeoData、单位与 Named Selection;它至少保留一个发布周期,但生命周期标为 deprecated/diagnostic-only,并指向中性 CAD 入口。普通几何准备不再推荐占用 ANSYS 许可证,显式诊断请求与历史 Job/旧节点协议不受影响。
|
||||||
|
|
||||||
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL,也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象:geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管,受控 modifier 覆盖 boolean/bevel/mirror/array/solidify,并统一声明材质、变换、灯光、世界背景和 1–8 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量;Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源,可再选择最多七张 PNG 与 GLB;预览用 `preview_01`–`preview_08` 稳定槽位,视图业务名称记录在 manifest。
|
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL,也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象:geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管,受控 modifier 覆盖 boolean/bevel/mirror/array/solidify,并统一声明材质、变换、灯光、世界背景和 1–8 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量;Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源,可再选择最多七张 PNG 与 GLB;预览用 `preview_01`–`preview_08` 稳定槽位,视图业务名称记录在 manifest。
|
||||||
|
|
@ -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、串行回到同一节点,节点离线时等待而不复制工程或静默改派。
|
专业软件的连续加工以独立 `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 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,避免暴露看似可配但不能稳定复现的参数。
|
科研统计图继续以独立 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,避免暴露看似可配但不能稳定复现的参数。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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 / 中性 CAD 几何准备**:新增无状态 `cad.geometry.prepare@v1`,以独立 `cad` runtime 和固定 CadQuery 2.8.0/OCP 7.9.3.1 生成供 ANSYS/Abaqus 复用的 STEP;首版含 inspect、八类基础体和受控 Recipe,覆盖显式二维截面、拉伸/旋转/扫掠/放样、布尔、圆角/倒角/抽壳、变换、阵列、STEP import 与多实体组合。固定交付 STEP、规范化 Recipe、几何 manifest、无 GUI 预览和 provenance,加入单位、请求/输入/步骤/点/实体/区域/超时与有限数防线,区域选择与 ANSYS v2 语义一致且支持 `expected_count`。Windows Node 新增 runtime-only 软件卡和基本体/STEP roundtrip probe;通用合同生命周期让旧 `ansys.geometry.inspect@v1` 停止普通推荐、保留显式诊断与历史兼容。本地数值 oracle、STEP 重开、确定性 manifest、区域与固定打包已验证;ANSYS/Abaqus 真实导入仍待目标机验收,未伪造通过。
|
||||||
|
|
||||||
- **09-02 / Unreleased / 管理后台运行总览分层**:Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
|
- **09-02 / Unreleased / 管理后台运行总览分层**:Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
|
||||||
|
|
@ -489,8 +491,8 @@ web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/
|
||||||
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
|
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
|
||||||
web/broker.py / sinks.py / pptx_render.py / admin.py
|
web/broker.py / sinks.py / pptx_render.py / admin.py
|
||||||
web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端
|
web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端
|
||||||
software-contracts/ ← 语言无关专业软件合同(含中性 CAD 与生命周期)
|
software-contracts/ ← 语言无关专业软件合同(含 FreeCAD、中性 CAD 与生命周期)
|
||||||
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、runtime-only CAD 与发布脚本
|
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD runtime 与发布脚本
|
||||||
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
|
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
|
||||||
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
|
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
|
||||||
─────────────────────────────────
|
─────────────────────────────────
|
||||||
|
|
|
||||||
12
RUN.md
12
RUN.md
|
|
@ -1119,18 +1119,22 @@ Zcbot.WindowsNode.exe
|
||||||
|
|
||||||
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service,也不要求管理员创建计划任务。
|
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service,也不要求管理员创建计划任务。
|
||||||
|
|
||||||
专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。既有桌面软件依赖默认显式使用清华 PyPI 镜像;中性 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 始终跟随当前数据根目录。
|
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node,并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。
|
||||||
|
|
||||||
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
|
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
|
||||||
|
|
||||||
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender 和中性 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/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`。
|
Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`。
|
||||||
|
|
||||||
Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status` 的 `editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source,节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `<data_root>\workspaces\<workspace-id>\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。
|
Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status` 的 `editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source,节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `<data_root>\workspaces\<workspace-id>\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。
|
||||||
|
|
||||||
|
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`。
|
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` 输入,不新增数据库关系。
|
普通几何先提交 `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` 时应修复镜像缓存,不能添加忽略哈希参数。
|
中性 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。目标机用 `<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 adapter 1.1.0 起随包提供固定真机 QA harness,但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band,也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。
|
||||||
|
|
||||||
`origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact;除 `data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X,积分输出累计值及可选 `from/to` 区间值,线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差,并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。
|
`origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact;除 `data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X,积分输出累计值及可选 `from/to` 区间值,线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差,并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。
|
||||||
|
|
@ -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。
|
注册配置写入 `<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% 重传;这些本地结果不会反向覆盖云端终态。
|
若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -89,6 +89,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
|
||||||
"ansys.mechanical.static_structural@v2",
|
"ansys.mechanical.static_structural@v2",
|
||||||
"blender.scene.author@v3",
|
"blender.scene.author@v3",
|
||||||
"cad.geometry.prepare@v1",
|
"cad.geometry.prepare@v1",
|
||||||
|
"freecad.model.author@v1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities))
|
self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities))
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
||||||
self.assertIn('IsReadOnly="True"', xaml)
|
self.assertIn('IsReadOnly="True"', xaml)
|
||||||
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
|
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
|
||||||
self.assertIn("item.HasActiveJobs", service)
|
self.assertIn("item.HasActiveJobs", service)
|
||||||
for runtime_id in ('"origin"', '"ansys"', '"blender"', '"cad"'):
|
for runtime_id in ('"origin"', '"ansys"', '"blender"', '"freecad"', '"cad"'):
|
||||||
self.assertIn(runtime_id, catalog)
|
self.assertIn(runtime_id, catalog)
|
||||||
self.assertIn("SoftwarePathKind.None", catalog)
|
self.assertIn("SoftwarePathKind.None", catalog)
|
||||||
self.assertIn('"https://pypi.org/simple/"', catalog)
|
self.assertIn('"https://pypi.org/simple/"', catalog)
|
||||||
|
|
@ -339,6 +339,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
||||||
self.assertIn("RegistryHive.LocalMachine", manager)
|
self.assertIn("RegistryHive.LocalMachine", manager)
|
||||||
self.assertIn("RegistryHive.CurrentUser", manager)
|
self.assertIn("RegistryHive.CurrentUser", manager)
|
||||||
self.assertIn('"ZCBOT_BLENDER_EXE"', catalog)
|
self.assertIn('"ZCBOT_BLENDER_EXE"', catalog)
|
||||||
|
self.assertIn('"ZCBOT_FREECAD_CMD"', catalog)
|
||||||
|
self.assertIn('"FreeCADCmd.exe"', catalog)
|
||||||
self.assertIn('"AWP_ROOT242"', catalog)
|
self.assertIn('"AWP_ROOT242"', catalog)
|
||||||
self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog)
|
self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog)
|
||||||
self.assertIn('"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",
|
"adapters\\cad.geometry.prepare@v1\\cad.geometry.prepare.v1.json",
|
||||||
project,
|
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.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
|
||||||
self.assertFalse((ROOT / "install-windows-node.ps1").exists())
|
self.assertFalse((ROOT / "install-windows-node.ps1").exists())
|
||||||
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
|
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
|
||||||
|
|
@ -805,6 +815,14 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
||||||
for forbidden in ("subprocess", "eval(", "exec(", "os.system", "requests."):
|
for forbidden in ("subprocess", "eval(", "exec(", "os.system", "requests."):
|
||||||
self.assertNotIn(forbidden, worker)
|
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:
|
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
|
||||||
monitor = (
|
monitor = (
|
||||||
PROJECT / "Application" / "JobMonitorService.cs"
|
PROJECT / "Application" / "JobMonitorService.cs"
|
||||||
|
|
|
||||||
|
|
@ -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 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
|
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 安装到当前数据根目录,后续更新不会写回默认目录。
|
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ public sealed class AdapterArchitectureTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers()
|
public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers()
|
||||||
{
|
{
|
||||||
Assert.Equal(["origin", "ansys", "blender", "cad"],
|
Assert.Equal(["origin", "ansys", "blender", "freecad", "cad"],
|
||||||
SoftwareCatalog.Definitions.Select(item => item.RuntimeId));
|
SoftwareCatalog.Definitions.Select(item => item.RuntimeId));
|
||||||
Assert.Equal("ZCBOT_ORIGIN_EXE",
|
Assert.Equal("ZCBOT_ORIGIN_EXE",
|
||||||
SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable);
|
SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable);
|
||||||
|
|
@ -84,6 +84,10 @@ public sealed class AdapterArchitectureTests
|
||||||
SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable);
|
SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable);
|
||||||
Assert.Equal("ZCBOT_BLENDER_EXE",
|
Assert.Equal("ZCBOT_BLENDER_EXE",
|
||||||
SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable);
|
SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable);
|
||||||
|
var 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");
|
var cad = SoftwareCatalog.ByRuntimeId("cad");
|
||||||
Assert.Equal(SoftwarePathKind.None, cad.PathKind);
|
Assert.Equal(SoftwarePathKind.None, cad.PathKind);
|
||||||
Assert.Null(cad.EnvironmentVariable);
|
Assert.Null(cad.EnvironmentVariable);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ internal static class SoftwareCatalog
|
||||||
"ZCBOT_BLENDER_EXE",
|
"ZCBOT_BLENDER_EXE",
|
||||||
"blender.exe",
|
"blender.exe",
|
||||||
@"adapters\blender.scene.author@v3\requirements.txt"),
|
@"adapters\blender.scene.author@v3\requirements.txt"),
|
||||||
|
new(
|
||||||
|
"freecad",
|
||||||
|
"FreeCAD",
|
||||||
|
"freecad",
|
||||||
|
SoftwarePathKind.Executable,
|
||||||
|
"ZCBOT_FREECAD_CMD",
|
||||||
|
"FreeCADCmd.exe",
|
||||||
|
@"adapters\freecad.model.author@v1\requirements.txt"),
|
||||||
new(
|
new(
|
||||||
"cad",
|
"cad",
|
||||||
"中性 CAD",
|
"中性 CAD",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ internal static class SoftwareLocationService
|
||||||
"origin" => DetectOrigin(definition),
|
"origin" => DetectOrigin(definition),
|
||||||
"ansys" => DetectAnsys(definition),
|
"ansys" => DetectAnsys(definition),
|
||||||
"blender" => DetectBlender(definition),
|
"blender" => DetectBlender(definition),
|
||||||
|
"freecad" => DetectFreeCad(definition),
|
||||||
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
|
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +191,62 @@ internal static class SoftwareLocationService
|
||||||
return FirstLocation(definition, candidates);
|
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(
|
private static SoftwareLocation FirstLocation(
|
||||||
SoftwareDefinition definition,
|
SoftwareDefinition definition,
|
||||||
IEnumerable<(string Path, string Source)> candidates)
|
IEnumerable<(string Path, string Source)> candidates)
|
||||||
|
|
|
||||||
|
|
@ -53,5 +53,10 @@
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
</Content>
|
</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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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 正在重建并验证受控模型"
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")))
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
pillow==11.3.0
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue