feat(cad): 增加二维工程制图能力

This commit is contained in:
caoqianming 2026-09-04 10:54:59 +08:00
parent 8d60e000ce
commit 27fc0136fd
23 changed files with 1103 additions and 22 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 新增二维 CAD 工程制图:可创建并连续修改户型图、实验室和车间设备布局,交付可编辑 DXF 及 SVG、PNG、PDF并自动检查门窗、区域、图框和常见碰撞问题。
- 修复深度思考或事件流重连期间点击“停止”偶尔没有反应的问题;停止信号会在状态落库前立即送达,重复停止也会可靠补发。 - 修复深度思考或事件流重连期间点击“停止”偶尔没有反应的问题;停止信号会在状态落库前立即送达,重复停止也会可靠补发。
## 0.71.0 — 2026-09-03 ## 0.71.0 — 2026-09-03
@ -22,7 +24,7 @@
- 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据并查看来源、脱敏尾号和可用状态数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。 - 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据并查看来源、脱敏尾号和可用状态数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。
- 新增中性 CAD 几何准备:可生成或检查 STEP组合基础体、截面、布尔、圆角、阵列等受控建模步骤并交付几何清单和预览结果可继续用于 ANSYS、Abaqus 等分析任务。 - 新增仿真几何准备STEP:可生成或检查 STEP组合基础体、截面、布尔、圆角、阵列等受控建模步骤并交付几何清单和预览结果可继续用于 ANSYS、Abaqus 等分析任务。
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。

View File

@ -472,7 +472,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。 云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。契约可附带 `active/deprecated` 生命周期、`standard/diagnostic_only` 可见性和替代 capability普通能力枚举隐藏诊断入口显式诊断查询与历史 Job 读取仍保留。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡桌面软件允许管理员自动检测或选择预定义应用位置runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 或内核 roundtrip 健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS、Blender 和中性 CAD adapter 使用 Python只是实现选择。 专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。契约可附带 `active/deprecated` 生命周期、`standard/diagnostic_only` 可见性和替代 capability普通能力枚举隐藏诊断入口显式诊断查询与历史 Job 读取仍保留。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡桌面软件允许管理员自动检测或选择预定义应用位置runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 或内核 roundtrip 健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS、Blender、仿真几何准备STEP和二维 CAD 工程制图 adapter 使用 Python 只是实现选择。
Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`供本机任务列表统一显示标题Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。 Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`供本机任务列表统一显示标题Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。
@ -494,13 +494,17 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
`cad.geometry.prepare@v1` 是独立、无状态、runtime-only 的中性几何入口,以固定 CadQuery 2.8.0 / OCP 7.9.3.1 生成 STEP它只负责检查、基础体和受控有序 Recipe不承载材料、网格、接触、边界条件或求解。Recipe 覆盖显式坐标闭合截面、拉伸/旋转/扫掠/放样、布尔、圆角/倒角/抽壳、变换、线性/圆周阵列、STEP import 与多实体组合,所有引用、轮廓平面性、形状有效性、实体上限和选择数量都机械校验。输出固定为 STEP、规范化 Recipe、确定性几何 manifest、无 GUI PNG 预览和 provenancemanifest 只保存单位、几何量、拓扑/表面摘要和已解析区域,不把 OCCT、ANSYS 或 Abaqus 的临时实体 ID 作为跨软件事实。区域选择器以几何语义描述 face/edge/solid其中 `extreme_face``planar_faces` 和 ANSYS 静力 v2 保持同构并由合同测试守护。输入首版只接受显式声明单位的 STEP/STP统一转换到请求必填的 `canonical_unit`,缺失或歧义单位直接失败。 `cad.geometry.prepare@v1` 是独立、无状态、runtime-only 的中性几何入口,以固定 CadQuery 2.8.0 / OCP 7.9.3.1 生成 STEP它只负责检查、基础体和受控有序 Recipe不承载材料、网格、接触、边界条件或求解。Recipe 覆盖显式坐标闭合截面、拉伸/旋转/扫掠/放样、布尔、圆角/倒角/抽壳、变换、线性/圆周阵列、STEP import 与多实体组合,所有引用、轮廓平面性、形状有效性、实体上限和选择数量都机械校验。输出固定为 STEP、规范化 Recipe、确定性几何 manifest、无 GUI PNG 预览和 provenancemanifest 只保存单位、几何量、拓扑/表面摘要和已解析区域,不把 OCCT、ANSYS 或 Abaqus 的临时实体 ID 作为跨软件事实。区域选择器以几何语义描述 face/edge/solid其中 `extreme_face``planar_faces` 和 ANSYS 静力 v2 保持同构并由合同测试守护。输入首版只接受显式声明单位的 STEP/STP统一转换到请求必填的 `canonical_unit`,缺失或歧义单位直接失败。
`cad.drawing.author@v1` 是独立的 runtime-only 二维工程制图入口,与上述 STEP 内核及 `runtime_id=cad` 完全隔离。它用受控完整 Recipe 表达直墙、门窗洞口、闭合区域、固定设备/家具块、受控自定义矩形或圆、机械计算尺寸和普通文字不接受输入文件、脚本、宏、插件、图层、字体、URL 或本机路径。输出固定为可编辑 DXF R2013、SVG、PNG、PDF、规范化 Recipe、manifest、validation report 与隐藏 provenance固定图层、中文字体选择、DXF 重开、引用/范围/相交/图框/计数/包围盒和产物摘要均由 Worker 机械验证,碰撞、面积、标签、尺寸文字与信息密度只作为警告,不能表述为建筑、消防或工艺规范审查。
二维 CAD 使用通用 Workspace 语义支持连续修改:首次提交完整 Recipe 创建 Workspace后续从当前 head 的 `editable_request` 修改并通过 `software_job_revise` 提交完整下一版。每一版都是新的 software Job 和 Artifact 版本历史不覆盖Worker 每次从完整 Recipe 确定性重建,不把上一版 DXF 解析为新的事实源。这样既保持连续编辑体验,也不增加 CAD 专用 patch/revise 协议或改变通用 Workspace 语义。
首版不输出 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.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`,不新增协议或数据库状态。 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并指向仿真几何准备STEP入口。普通几何准备不再推荐占用 ANSYS 许可证,显式诊断请求与历史 Job/旧节点协议不受影响。
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。 Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-09-04Unreleased简易 auto 推理强度模式 最后更新:2026-09-04Unreleased二维 CAD 工程制图
--- ---
@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **09-04 / Unreleased / 二维 CAD 工程制图**:新增非默认 `cad.drawing.author@v1` / adapter 1.0.0 与独立 `cad2d` runtime固定完整 Recipe 覆盖直墙、门窗、区域、家具/设备/安全块、受控矩形/圆、机械尺寸和中文文字,交付 DXF R2013、SVG、PNG、PDF、Recipe、manifest、validation report 与隐藏 provenance。DXF 重开、固定 12 图层、引用/范围/相交/图框/计数/bbox/区域面积和产物摘要作为阻断门,碰撞与可读性只报 warning通过通用 Workspace head/revise 以完整 Recipe 连续修改,每版创建新 Job/Artifact不覆盖历史。Windows Node 增加 runtime-only 软件卡、10 分钟 timeout、固定哈希依赖、probe、完整/独立包和离线 acceptance小三居、两室一厅、实验室/车间、中文、A4/A3/A2、密集尺寸、失败/警告、中文与空格路径、三次确定性及取消清理自动门通过AutoCAD/中望/浩辰真机兼容仍待人工验证。
- **09-04 / Unreleased / 简易 auto 推理强度模式**:模型档案可保留平台值 `auto`DeepSeek V4 Flash 首次调用和失败工具步后使用 high、成功工具步后使用 lowPro 的 medium 与其他固定档保持原样;纯 reasoning 超过 90 秒会清理直播推理并以 low 和请求内临时约束重试一次,第二次仍超时明确停止,取消优先且半截 assistant 不入库。正常 chat usage 留存配置值、实际档位、决策原因和保护重试标记,熔断复用 `agent_guard`;无 schema、migration、API、前端或版本变化。 - **09-04 / Unreleased / 简易 auto 推理强度模式**:模型档案可保留平台值 `auto`DeepSeek V4 Flash 首次调用和失败工具步后使用 high、成功工具步后使用 lowPro 的 medium 与其他固定档保持原样;纯 reasoning 超过 90 秒会清理直播推理并以 low 和请求内临时约束重试一次,第二次仍超时明确停止,取消优先且半截 assistant 不入库。正常 chat usage 留存配置值、实际档位、决策原因和保护重试标记,熔断复用 `agent_guard`;无 schema、migration、API、前端或版本变化。
- **09-03 / 0.71.0 / 工具链可靠性收敛**tool arguments salvage 新增“至少两个完整且完全一致副本 + 尾部截断副本”的保守恢复,语义不一致仍拒绝。修复 artifacts 部分唯一索引谓词被参数化后 PostgreSQL 无法匹配 `ON CONFLICT` 的问题,并让输出中任意行首 `[GATE FAIL]` 均按质量门记录,避免 SVG/PPT 质检占用真实故障大数。无 schema/migration/API 变化,生产失败历史未改写。 - **09-03 / 0.71.0 / 工具链可靠性收敛**tool arguments salvage 新增“至少两个完整且完全一致副本 + 尾部截断副本”的保守恢复,语义不一致仍拒绝。修复 artifacts 部分唯一索引谓词被参数化后 PostgreSQL 无法匹配 `ON CONFLICT` 的问题,并让输出中任意行首 `[GATE FAIL]` 均按质量门记录,避免 SVG/PPT 质检占用真实故障大数。无 schema/migration/API 变化,生产失败历史未改写。
@ -36,7 +38,7 @@
- **09-03 / 0.71.0 / FreeCAD Windows Node 接入**:新增 `freecad.model.author@v1` / adapter 1.0.0 与独立 `freecad` runtime目标仅为正式版 FreeCAD `>=1.1.3,<1.2.0`。两层 Worker 由受管 Python 负责 `FreeCADCmd.exe` 发现、版本门禁、safe mode/独立配置与 Pillow 等轴测渲染,由 FreeCAD 自带 Python 固定调用 PartRecipe 每次从空文档完整重建,参数引用先解析为数值,不写 Spreadsheet/Expression。首版覆盖五类基础体、STEP import、三类布尔、平移/旋转/组合,固定交付 FCStd、STEP、Recipe、manifest、预览和 provenance并执行 recompute、Shape 有效性、FCStd/STEP 重开及实体数/包围盒/体积容差校验。Windows Node 增加 FreeCAD 软件卡、UI/环境变量/PATH/卸载注册表/标准目录检测与独立/完整包集成;纯 Recipe、假版本 probe、合同、源码和打包自动测试已落地FreeCAD 1.1.3 真机 acceptance 待目标机执行,未伪造通过。 - **09-03 / 0.71.0 / FreeCAD Windows Node 接入**:新增 `freecad.model.author@v1` / adapter 1.0.0 与独立 `freecad` runtime目标仅为正式版 FreeCAD `>=1.1.3,<1.2.0`。两层 Worker 由受管 Python 负责 `FreeCADCmd.exe` 发现、版本门禁、safe mode/独立配置与 Pillow 等轴测渲染,由 FreeCAD 自带 Python 固定调用 PartRecipe 每次从空文档完整重建,参数引用先解析为数值,不写 Spreadsheet/Expression。首版覆盖五类基础体、STEP import、三类布尔、平移/旋转/组合,固定交付 FCStd、STEP、Recipe、manifest、预览和 provenance并执行 recompute、Shape 有效性、FCStd/STEP 重开及实体数/包围盒/体积容差校验。Windows Node 增加 FreeCAD 软件卡、UI/环境变量/PATH/卸载注册表/标准目录检测与独立/完整包集成;纯 Recipe、假版本 probe、合同、源码和打包自动测试已落地FreeCAD 1.1.3 真机 acceptance 待目标机执行,未伪造通过。
- **09-02 / 0.71.0 / 中性 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 / 0.71.0 / 仿真几何准备STEP**:新增无状态 `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 / 0.71.0 / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。 - **09-02 / 0.71.0 / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
@ -493,8 +495,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/ ← 语言无关专业软件合同(含 FreeCAD、中性 CAD 与生命周期) software-contracts/ ← 语言无关专业软件合同(含 FreeCAD、二维 CAD、仿真几何与生命周期)
windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD runtime 与发布脚本 windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD/CAD2D 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 门控)
───────────────────────────────── ─────────────────────────────────

10
RUN.md
View File

@ -1119,13 +1119,13 @@ 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|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 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。 专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。既有桌面软件依赖默认显式使用清华 PyPI 镜像;仿真几何准备STEP与二维 CAD 工程制图因二进制 wheel 完整性要求默认使用官方 PyPI。统一覆盖源或切换院内镜像时在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`,但不得绕过 pip 哈希校验。解释器位于 `<data_root>\runtimes\origin|ansys|blender|freecad|cad|cad2d\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON`、`ZCBOT_ADAPTER_BLENDER_PYTHON`、`ZCBOT_ADAPTER_FREECAD_PYTHON`、`ZCBOT_ADAPTER_CAD_PYTHON``ZCBOT_ADAPTER_CAD2D_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、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/心跳会刷新能力状态。 主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2、Blender、FreeCAD、仿真几何准备STEP和二维 CAD 工程制图分别管理隔离 runtime前四者同时管理桌面应用位置后两者是 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 通过;STEP probe 会验证固定依赖、基本体和 STEP roundtrip,二维 CAD probe 会验证 Pillow、确定性中文字体和 DXF R2013 重开器。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。
Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb` Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`
@ -1184,7 +1184,9 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。 只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
中性 CAD adapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`,再从“中性 CAD”卡安装/更新固定 `cad` runtimeprobe 必须显示 CadQuery 2.8.0 / OCP 7.9.3.1 且基本体与 STEP roundtrip 可用。若默认官方 PyPI 下载失败,可通过 `ZCBOT_PIP_INDEX_URL` 指向已同步并校验 wheel 的院内源;出现 `THESE PACKAGES DO NOT MATCH THE HASHES` 时应修复镜像缓存,不能添加忽略哈希参数。 仿真几何准备STEPadapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`再从“仿真几何准备STEP”卡安装/更新固定 `cad` runtimeprobe 必须显示 CadQuery 2.8.0 / OCP 7.9.3.1 且基本体与 STEP roundtrip 可用。若默认官方 PyPI 下载失败,可通过 `ZCBOT_PIP_INDEX_URL` 指向已同步并校验 wheel 的院内源;出现 `THESE PACKAGES DO NOT MATCH THE HASHES` 时应修复镜像缓存,不能添加忽略哈希参数。
二维 CAD 工程制图 adapter 可用 `windows-node\package-cad2d-adapter.bat` 独立打成 `dist\cad.drawing.author@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.drawing.author@v1\`,在“二维 CAD 工程制图”卡安装/更新固定 `cad2d` runtime 并确认 probe 为 ready。能力不接收输入文件`inputs=[]`、`outputs=[]`;固定输出 `drawing.dxf`、`drawing.svg`、`drawing.png`、`drawing.pdf`、`drawing-recipe.json`、`drawing-manifest.json`、`validation-report.json` 和隐藏 `.meta/provenance.json`,其中 PNG 自动作为 Workspace 预览回传。首次提交完整 Recipe连续修改时从 `software_job_status(job_id)``editable_request` 形成完整下一版并调用 `software_job_revise`,必须以当前 Workspace head 为 source每版生成新 Job/Artifact旧版不覆盖。目标机可运行 `<data_root>\runtimes\cad2d\Scripts\python.exe <node_program>\adapters\cad.drawing.author@v1\acceptance.py --work-root <new_empty_parent>\cad2d-acceptance-YYYYMMDD --repeat 3`;报告通过只表示固定 Worker、机械质量门、中文路径和确定性自动验收通过AutoCAD、中望 CAD、浩辰 CAD 的打开/编辑/打印兼容仍需人工验收。
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 进程释放;报告通过且人工检查预览后才能记录目标机通过。 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 进程释放;报告通过且人工检查预览后才能记录目标机通过。
@ -1200,7 +1202,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、FreeCAD 位置及五套独立 runtime中性 CAD 无应用位置配置。该页还可运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless` 直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender、FreeCAD 位置及六套独立 runtime仿真几何准备STEP和二维 CAD 工程制图无应用位置配置。该页还可运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。 若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。

View File

@ -6,7 +6,7 @@
"status": "deprecated", "status": "deprecated",
"visibility": "diagnostic_only", "visibility": "diagnostic_only",
"replacement": "cad.geometry.prepare@v1", "replacement": "cad.geometry.prepare@v1",
"detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通几何准备请使用中性 CAD 能力。" "detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通三维仿真几何准备请使用仿真几何准备STEP能力。"
}, },
"output_namespace": "ansys", "output_namespace": "ansys",
"input_policy": { "input_policy": {

View File

@ -0,0 +1,141 @@
{
"capability": "cad.drawing.author@v1",
"display_name": "二维 CAD 工程制图",
"default_enrollment": false,
"lifecycle": {
"status": "active",
"visibility": "standard",
"replacement": null,
"detail": "用于平面 CAD、户型图、布局图、实验室或车间设备布置、DXF 和门窗尺寸图STEP、三维实体及仿真前处理请使用仿真几何准备STEPFCStd 请使用 FreeCAD。"
},
"output_namespace": "cad2d",
"input_policy": {
"suffixes": [],
"max_count": 0,
"max_bytes": 0,
"max_total_bytes": 0
},
"outputs": {
"drawing_dxf": {"filename": "drawing.dxf", "media_type": "image/vnd.dxf", "relative_path": "drawing.dxf", "publish": true, "required": true},
"drawing_svg": {"filename": "drawing.svg", "media_type": "image/svg+xml", "relative_path": "drawing.svg", "publish": true, "required": true},
"drawing_png": {"filename": "drawing.png", "media_type": "image/png", "relative_path": "drawing.png", "publish": true, "required": true},
"drawing_pdf": {"filename": "drawing.pdf", "media_type": "application/pdf", "relative_path": "drawing.pdf", "publish": true, "required": true},
"drawing_recipe": {"filename": "drawing-recipe.json", "media_type": "application/json", "relative_path": "drawing-recipe.json", "publish": true, "required": true},
"drawing_manifest": {"filename": "drawing-manifest.json", "media_type": "application/json", "relative_path": "drawing-manifest.json", "publish": true, "required": true},
"validation_report": {"filename": "validation-report.json", "media_type": "application/json", "relative_path": "validation-report.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", "drawing", "type"],
"features": {"floor_plan": "1.0.0", "equipment_layout": "1.0.0"},
"summary": {"title_path": ["operation", "drawing", "title"]},
"legacy_runtime": null,
"workspace": {
"state_output": "drawing_dxf",
"preview_outputs": ["drawing_png"],
"required_adapter_version": "1.0.0"
},
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"x-maxBytes": 524288,
"required": ["schema_version", "inputs", "operation", "outputs"],
"additionalProperties": false,
"properties": {
"schema_version": {"const": 1},
"inputs": {"type": "array", "maxItems": 0},
"operation": {
"type": "object",
"required": ["drawing"],
"additionalProperties": false,
"properties": {"drawing": {"$ref": "#/$defs/drawing"}}
},
"outputs": {"type": "array", "maxItems": 0}
},
"$defs": {
"identifier": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$"},
"text": {"type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[^\\u0000-\\u001F\\u007F]*$"},
"coordinate": {"type": "number", "minimum": -1000000, "maximum": 1000000},
"positive": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000},
"point": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"$ref": "#/$defs/coordinate"}},
"size": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"$ref": "#/$defs/positive"}},
"sheet": {
"type": "object",
"required": ["size", "orientation", "scale"],
"additionalProperties": false,
"properties": {
"size": {"enum": ["A4", "A3", "A2", "A1", "A0"]},
"orientation": {"enum": ["portrait", "landscape"]},
"scale": {"type": "integer", "enum": [10, 20, 25, 50, 100, 200, 500]}
}
},
"wall": {
"type": "object",
"required": ["id", "start", "end", "thickness", "kind"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/identifier"}, "start": {"$ref": "#/$defs/point"}, "end": {"$ref": "#/$defs/point"},
"thickness": {"type": "number", "minimum": 50, "maximum": 2000}, "kind": {"enum": ["exterior", "interior"]}
}
},
"opening": {
"type": "object",
"required": ["id", "wall_id", "offset", "width", "type"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/identifier"}, "wall_id": {"$ref": "#/$defs/identifier"},
"offset": {"type": "number", "minimum": 0, "maximum": 1000000}, "width": {"$ref": "#/$defs/positive"},
"type": {"enum": ["single_door", "double_door", "sliding_door", "window", "high_window", "opening"]},
"hinge": {"enum": ["start", "end"]}, "swing": {"enum": ["left", "right", "in", "out"]}
}
},
"area": {
"type": "object",
"required": ["id", "boundary", "name", "label_position", "show_area"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/identifier"}, "boundary": {"type": "array", "minItems": 4, "maxItems": 256, "items": {"$ref": "#/$defs/point"}},
"name": {"$ref": "#/$defs/text"}, "label_position": {"$ref": "#/$defs/point"}, "show_area": {"type": "boolean"}
}
},
"object": {
"type": "object",
"required": ["id", "type", "position"],
"additionalProperties": false,
"properties": {
"id": {"$ref": "#/$defs/identifier"},
"type": {"enum": ["desk", "chair", "cabinet", "lab_bench", "fume_hood", "machine", "tank", "safety_exit", "extinguisher", "custom_rectangle", "custom_circle"]},
"position": {"$ref": "#/$defs/point"}, "size": {"$ref": "#/$defs/size"},
"radius": {"$ref": "#/$defs/positive"}, "rotation": {"type": "number", "minimum": -360, "maximum": 360}, "label": {"$ref": "#/$defs/text"}
}
},
"linear_dimension": {
"type": "object", "required": ["id", "type", "start", "end", "offset"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "type": {"enum": ["horizontal", "vertical", "aligned"]}, "start": {"$ref": "#/$defs/point"}, "end": {"$ref": "#/$defs/point"}, "offset": {"$ref": "#/$defs/coordinate"}}
},
"multi_dimension": {
"type": "object", "required": ["id", "type", "points", "offset"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "type": {"enum": ["chain", "overall"]}, "points": {"type": "array", "minItems": 2, "maxItems": 64, "items": {"$ref": "#/$defs/point"}}, "offset": {"$ref": "#/$defs/coordinate"}}
},
"dimension": {"oneOf": [{"$ref": "#/$defs/linear_dimension"}, {"$ref": "#/$defs/multi_dimension"}]},
"note": {
"type": "object", "required": ["id", "position", "text"], "additionalProperties": false,
"properties": {"id": {"$ref": "#/$defs/identifier"}, "position": {"$ref": "#/$defs/point"}, "text": {"$ref": "#/$defs/text"}, "rotation": {"type": "number", "minimum": -360, "maximum": 360}}
},
"drawing": {
"type": "object",
"required": ["type", "recipe_version", "title", "unit", "sheet", "walls", "openings", "areas", "objects", "dimensions", "notes"],
"additionalProperties": false,
"properties": {
"type": {"enum": ["floor_plan", "equipment_layout"]}, "recipe_version": {"const": 1},
"title": {"$ref": "#/$defs/text"}, "unit": {"const": "mm"}, "sheet": {"$ref": "#/$defs/sheet"},
"walls": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/wall"}},
"openings": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/opening"}},
"areas": {"type": "array", "maxItems": 128, "items": {"$ref": "#/$defs/area"}},
"objects": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/object"}},
"dimensions": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/dimension"}},
"notes": {"type": "array", "maxItems": 128, "items": {"$ref": "#/$defs/note"}}
}
}
}
}
}

View File

@ -1,12 +1,12 @@
{ {
"capability": "cad.geometry.prepare@v1", "capability": "cad.geometry.prepare@v1",
"display_name": "中性 CAD 几何准备", "display_name": "仿真几何准备STEP",
"default_enrollment": false, "default_enrollment": false,
"lifecycle": { "lifecycle": {
"status": "active", "status": "active",
"visibility": "standard", "visibility": "standard",
"replacement": null, "replacement": null,
"detail": "生成供 ANSYS、Abaqus 等后续任务复用的中性 STEP。" "detail": "用于 STEP、三维实体及 ANSYS/Abaqus 仿真前处理几何;平面图、户型图和设备布局请使用二维 CAD 工程制图。"
}, },
"output_namespace": "cad", "output_namespace": "cad",
"input_policy": { "input_policy": {

111
tests/test_cad2d_adapter.py Normal file
View File

@ -0,0 +1,111 @@
from __future__ import annotations
import importlib.util
import json
import math
import tempfile
import unittest
from pathlib import Path
from uuid import uuid4
from core.software_contracts import SoftwareContractError, get_contract
ROOT = Path(__file__).resolve().parents[1]
WORKER_PATH = ROOT / "windows-node" / "adapters" / "cad.drawing.author@v1" / "worker.py"
SPEC = importlib.util.spec_from_file_location("zcbot_cad2d_worker", WORKER_PATH)
assert SPEC and SPEC.loader
worker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(worker)
def request() -> dict:
return {
"schema_version": 1, "inputs": [], "outputs": [],
"operation": {"drawing": {
"type": "floor_plan", "recipe_version": 1, "title": "中文平面图", "unit": "mm",
"sheet": {"size": "A3", "orientation": "landscape", "scale": 50},
"walls": [
{"id":"wall1","start":[0,0],"end":[10000,0],"thickness":240,"kind":"exterior"},
{"id":"wall2","start":[10000,0],"end":[10000,7000],"thickness":240,"kind":"exterior"},
{"id":"wall3","start":[10000,7000],"end":[0,7000],"thickness":240,"kind":"exterior"},
{"id":"wall4","start":[0,7000],"end":[0,0],"thickness":240,"kind":"exterior"},
],
"openings": [{"id":"door1","wall_id":"wall1","offset":800,"width":900,"type":"single_door","hinge":"start","swing":"in"}],
"areas": [{"id":"area1","boundary":[[0,0],[10000,0],[10000,7000],[0,7000],[0,0]],"name":"实验室","label_position":[5000,3500],"show_area":True}],
"objects": [{"id":"bench1","type":"lab_bench","position":[5000,1800],"label":"试验台"}],
"dimensions": [{"id":"dim1","type":"horizontal","start":[0,0],"end":[10000,0],"offset":-600}],
"notes": [{"id":"note1","position":[7000,6000],"text":"尺寸单位mm"}],
}},
}
class Cad2dContractTests(unittest.TestCase):
def test_contract_is_runtime_only_fixed_output_workspace(self) -> None:
contract = get_contract("cad.drawing.author@v1")
normalized, digest = contract.normalize_request(request())
self.assertEqual(contract.display_name, "二维 CAD 工程制图")
self.assertEqual(contract.output_namespace, "cad2d")
self.assertFalse(contract.default_enrollment)
self.assertEqual(contract.input_policy["max_count"], 0)
self.assertEqual(contract.workspace.state_output, "drawing_dxf")
self.assertEqual(contract.workspace.preview_outputs, ("drawing_png",))
self.assertEqual(contract.feature(normalized), "floor_plan")
self.assertEqual(len(digest), 64)
self.assertEqual(set(contract.expected_outputs(normalized)), {
"drawing_dxf", "drawing_svg", "drawing_png", "drawing_pdf", "drawing_recipe",
"drawing_manifest", "validation_report", "provenance",
})
def test_contract_rejects_inputs_scripts_paths_fonts_layers_and_nonfinite(self) -> None:
contract = get_contract("cad.drawing.author@v1")
mutations = [("script", "x"), ("path", "C:/secret"), ("font", "Arial"), ("layer", "custom")]
for key, value in mutations:
value_request = request(); value_request["operation"]["drawing"][key] = value
with self.subTest(key), self.assertRaises(SoftwareContractError):
contract.normalize_request(value_request)
value_request = request(); value_request["operation"]["drawing"]["walls"][0]["start"][0] = math.inf
with self.assertRaises(SoftwareContractError):
contract.normalize_request(value_request)
value_request = request(); value_request["inputs"] = [{"key":"x","artifact_id":str(uuid4())}]
with self.assertRaises(SoftwareContractError):
contract.normalize_request(value_request)
def test_geometry_gates_and_warnings(self) -> None:
value = request()["operation"]["drawing"]
duplicate = json.loads(json.dumps(value)); duplicate["notes"][0]["id"] = "wall1"
with self.assertRaisesRegex(worker.DrawingError, "CAD2D_DUPLICATE_ID"):
worker._validate_and_normalize(duplicate)
outside = json.loads(json.dumps(value)); outside["openings"][0]["offset"] = 9900
with self.assertRaisesRegex(worker.DrawingError, "CAD2D_OPENING_OUTSIDE_WALL"):
worker._validate_and_normalize(outside)
overlap = json.loads(json.dumps(value)); overlap["openings"].append({"id":"door2","wall_id":"wall1","offset":1000,"width":900,"type":"opening"})
with self.assertRaisesRegex(worker.DrawingError, "CAD2D_OPENINGS_OVERLAP"):
worker._validate_and_normalize(overlap)
unclosed = json.loads(json.dumps(value)); unclosed["areas"][0]["boundary"][-1] = [1,0]
with self.assertRaisesRegex(worker.DrawingError, "CAD2D_AREA_NOT_CLOSED"):
worker._validate_and_normalize(unclosed)
collision = json.loads(json.dumps(value)); collision["objects"][0]["position"] = [1000,400]
_, warnings, _ = worker._validate_and_normalize(collision)
self.assertIn("CAD2D_DOOR_SWING_OBJECT_COLLISION", {item["code"] for item in warnings})
def test_worker_writes_reopenable_fixed_package_deterministically(self) -> None:
manifests = []
for _ in range(3):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory); (root / "request").mkdir()
(root / "request" / "request.json").write_text(json.dumps({"job_id":str(uuid4()),"lease_id":str(uuid4()),"request_digest":"a"*64,"request":request()}, ensure_ascii=False), encoding="utf-8")
artifacts = worker.run(root)
self.assertEqual({item["filename"] for item in artifacts}, set(worker.OUTPUT_MEDIA))
reopened = worker._reopen_dxf(root / "output" / "drawing.dxf")
self.assertEqual(reopened["version"], "R2013")
self.assertEqual(reopened["layers"], sorted(worker.LAYERS))
report = json.loads((root / "output" / "validation-report.json").read_text(encoding="utf-8"))
self.assertTrue(report["passed"])
self.assertGreater((root / "output" / "drawing.png").stat().st_size, 1000)
manifests.append(json.loads((root / "output" / "drawing-manifest.json").read_text(encoding="utf-8")))
self.assertEqual(manifests[0], manifests[1])
self.assertEqual(manifests[1], manifests[2])
if __name__ == "__main__":
unittest.main()

View File

@ -44,6 +44,12 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertEqual(result["capabilities"][0]["available_nodes"], 1) self.assertEqual(result["capabilities"][0]["available_nodes"], 1)
capabilities = {item["capability"] for item in result["capabilities"]} capabilities = {item["capability"] for item in result["capabilities"]}
self.assertIn("cad.geometry.prepare@v1", capabilities) self.assertIn("cad.geometry.prepare@v1", capabilities)
self.assertIn("cad.drawing.author@v1", capabilities)
drawing = next(
item for item in result["capabilities"]
if item["capability"] == "cad.drawing.author@v1"
)
self.assertIn("平面 CAD", drawing["detail"])
self.assertIn("ansys.mechanical.static_structural@v2", capabilities) self.assertIn("ansys.mechanical.static_structural@v2", capabilities)
self.assertNotIn("ansys.geometry.inspect@v1", capabilities) self.assertNotIn("ansys.geometry.inspect@v1", capabilities)

View File

@ -88,6 +88,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
"ansys.geometry.inspect@v1", "ansys.geometry.inspect@v1",
"ansys.mechanical.static_structural@v2", "ansys.mechanical.static_structural@v2",
"blender.scene.author@v3", "blender.scene.author@v3",
"cad.drawing.author@v1",
"cad.geometry.prepare@v1", "cad.geometry.prepare@v1",
"freecad.model.author@v1", "freecad.model.author@v1",
}, },

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import unittest import unittest
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
@ -329,7 +330,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"', '"freecad"', '"cad"'): for runtime_id in ('"origin"', '"ansys"', '"blender"', '"freecad"', '"cad"', '"cad2d"'):
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)
@ -505,6 +506,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\\cad.drawing.author.v1.json",
project,
)
self.assertIn(
"adapters\\cad.drawing.author@v1\\cad.drawing.author.v1.json",
project,
)
self.assertIn( self.assertIn(
"..\\..\\software-contracts\\freecad.model.author.v1.json", "..\\..\\software-contracts\\freecad.model.author.v1.json",
project, project,
@ -823,6 +832,24 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("tar.exe -a -c -f", script) self.assertIn("tar.exe -a -c -f", script)
self.assertNotIn("dotnet", script.lower()) self.assertNotIn("dotnet", script.lower())
def test_cad2d_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-cad2d-adapter.bat").read_text(encoding="utf-8")
adapter_root = ROOT / "adapters" / "cad.drawing.author@v1"
manifest = json.loads((adapter_root / "adapter.json").read_text(encoding="utf-8"))
requirements = (adapter_root / "requirements.txt").read_text(encoding="utf-8")
worker = (adapter_root / "worker.py").read_text(encoding="utf-8")
for filename in ("adapter.json", "worker.py", "requirements.txt", "acceptance.py"):
self.assertIn(filename, script)
self.assertIn("..\\software-contracts\\cad.drawing.author.v1.json", script)
self.assertIn("tar.exe -a -c -f", script)
self.assertNotIn("dotnet", script.lower())
self.assertEqual(manifest["runtime_id"], "cad2d")
self.assertEqual(manifest["worker_timeout_minutes"], 10)
self.assertIn("pillow==12.0.0", requirements)
self.assertIn("--hash=sha256:", requirements)
for forbidden in ("subprocess", "eval(", "exec(", "os.system", "requests."):
self.assertNotIn(forbidden, worker)
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None: def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
monitor = ( monitor = (
PROJECT / "Application" / "JobMonitorService.cs" PROJECT / "Application" / "JobMonitorService.cs"

View File

@ -46,6 +46,10 @@ class SoftwareCapabilityListTool(_SoftwareJobTool):
name = "software_capability_list" name = "software_capability_list"
description = ( description = (
"List professional software capabilities available through managed Windows nodes. " "List professional software capabilities available through managed Windows nodes. "
"Route floor plans, 2D CAD, laboratory/workshop layouts, DXF, doors, windows, and "
"dimensions to cad.drawing.author@v1; route STEP, 3D solids, simulation geometry, "
"ANSYS/Abaqus preprocessing to cad.geometry.prepare@v1; route FCStd to "
"freecad.model.author@v1. "
"Use this tool to answer whether software is available; checking availability must " "Use this tool to answer whether software is available; checking availability must "
"not submit a software job." "not submit a software job."
) )
@ -68,6 +72,7 @@ class SoftwareCapabilityListTool(_SoftwareJobTool):
"lifecycle": get_contract(item).lifecycle.status, "lifecycle": get_contract(item).lifecycle.status,
"visibility": get_contract(item).lifecycle.visibility, "visibility": get_contract(item).lifecycle.visibility,
"replacement": get_contract(item).lifecycle.replacement, "replacement": get_contract(item).lifecycle.replacement,
"detail": get_contract(item).lifecycle.detail,
"available_nodes": sum( "available_nodes": sum(
1 1
for node in nodes for node in nodes
@ -85,8 +90,10 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
name = "software_job_submit" name = "software_job_submit"
description = ( description = (
"Submit a managed professional-software job using registered artifacts and a " "Submit a managed professional-software job using registered artifacts and a "
"capability contract only when the user requested actual software execution; never " "capability contract only when the user requested actual software execution. Do not "
"submit a job merely to check whether software is available. Call register_artifact " "treat the word CAD alone as a reason to choose STEP: use cad.drawing.author@v1 for "
"2D plans/layouts/DXF and cad.geometry.prepare@v1 for STEP/3D simulation geometry. "
"never submit a job merely to check whether software is available. Call register_artifact "
"first for workspace files. A successful " "first for workspace files. A successful "
"submission is the terminal action for the current run: report the queued job_id once " "submission is the terminal action for the current run: report the queued job_id once "
"and end the turn. Completion delivery and artifact publication happen automatically " "and end the turn. Completion delivery and artifact publication happen automatically "

View File

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

View File

@ -76,7 +76,7 @@ public sealed class AdapterArchitectureTests
[Fact] [Fact]
public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers() public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers()
{ {
Assert.Equal(["origin", "ansys", "blender", "freecad", "cad"], Assert.Equal(["origin", "ansys", "blender", "freecad", "cad", "cad2d"],
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);
@ -92,6 +92,10 @@ public sealed class AdapterArchitectureTests
Assert.Equal(SoftwarePathKind.None, cad.PathKind); Assert.Equal(SoftwarePathKind.None, cad.PathKind);
Assert.Null(cad.EnvironmentVariable); Assert.Null(cad.EnvironmentVariable);
Assert.Equal("https://pypi.org/simple/", cad.DefaultIndexUrl); Assert.Equal("https://pypi.org/simple/", cad.DefaultIndexUrl);
var cad2d = SoftwareCatalog.ByRuntimeId("cad2d");
Assert.Equal(SoftwarePathKind.None, cad2d.PathKind);
Assert.Null(cad2d.EnvironmentVariable);
Assert.Equal("https://pypi.org/simple/", cad2d.DefaultIndexUrl);
} }
[Fact] [Fact]

View File

@ -63,7 +63,7 @@ public sealed class SoftwarePageViewModelTests
{ {
var service = new FakeSoftwareManagementService( var service = new FakeSoftwareManagementService(
new SoftwareManagementSnapshot( new SoftwareManagementSnapshot(
"cad", "中性 CAD", SoftwarePathKind.None, "", true, "cad2d", "二维 CAD 工程制图", SoftwarePathKind.None, "", true,
"无需桌面应用位置", "运行环境尚未安装", false, false)); "无需桌面应用位置", "运行环境尚未安装", false, false));
using var viewModel = new SoftwarePageViewModel(service); using var viewModel = new SoftwarePageViewModel(service);
viewModel.ConfirmInstallRequested += _ => true; viewModel.ConfirmInstallRequested += _ => true;

View File

@ -38,13 +38,22 @@ internal static class SoftwareCatalog
@"adapters\freecad.model.author@v1\requirements.txt"), @"adapters\freecad.model.author@v1\requirements.txt"),
new( new(
"cad", "cad",
"中性 CAD", "仿真几何准备STEP",
"cad", "cad",
SoftwarePathKind.None, SoftwarePathKind.None,
null, null,
null, null,
@"adapters\cad.geometry.prepare@v1\requirements.txt", @"adapters\cad.geometry.prepare@v1\requirements.txt",
"https://pypi.org/simple/"), "https://pypi.org/simple/"),
new(
"cad2d",
"二维 CAD 工程制图",
"cad2d",
SoftwarePathKind.None,
null,
null,
@"adapters\cad.drawing.author@v1\requirements.txt",
"https://pypi.org/simple/"),
]; ];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) => internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>

View File

@ -53,6 +53,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>
<Content Include="..\..\software-contracts\cad.drawing.author.v1.json">
<Link>adapters\cad.drawing.author@v1\cad.drawing.author.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\freecad.model.author.v1.json"> <Content Include="..\..\software-contracts\freecad.model.author.v1.json">
<Link>adapters\freecad.model.author@v1\freecad.model.author.v1.json</Link> <Link>adapters\freecad.model.author@v1\freecad.model.author.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View File

@ -0,0 +1,165 @@
"""Fixed offline acceptance harness for the 2D CAD adapter.
This validates the production worker and deterministic outputs. It does not claim
AutoCAD, ZWCAD, or GstarCAD interoperability; those remain manual target-machine gates.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import shutil
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
WORKER_PATH = Path(__file__).with_name("worker.py")
SPEC = importlib.util.spec_from_file_location("zcbot_cad2d_worker", WORKER_PATH)
assert SPEC and SPEC.loader
worker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(worker)
def _base(title: str, sheet: str = "A3", drawing_type: str = "floor_plan") -> dict:
walls = [
{"id": "south", "start": [0, 0], "end": [12000, 0], "thickness": 240, "kind": "exterior"},
{"id": "east", "start": [12000, 0], "end": [12000, 9000], "thickness": 240, "kind": "exterior"},
{"id": "north", "start": [12000, 9000], "end": [0, 9000], "thickness": 240, "kind": "exterior"},
{"id": "west", "start": [0, 9000], "end": [0, 0], "thickness": 240, "kind": "exterior"},
{"id": "partition", "start": [5000, 0], "end": [5000, 9000], "thickness": 120, "kind": "interior"},
]
return {
"schema_version": 1, "inputs": [], "outputs": [],
"operation": {"drawing": {
"type": drawing_type, "recipe_version": 1, "title": title, "unit": "mm",
"sheet": {"size": sheet, "orientation": "landscape", "scale": 50},
"walls": walls,
"openings": [
{"id": "entry", "wall_id": "south", "offset": 900, "width": 1000, "type": "single_door", "hinge": "start", "swing": "in"},
{"id": "window1", "wall_id": "north", "offset": 1200, "width": 1800, "type": "window"},
],
"areas": [{"id": "room", "boundary": [[0,0],[12000,0],[12000,9000],[0,9000],[0,0]], "name": "房间", "label_position": [6000,4500], "show_area": True}],
"objects": [{"id": "desk1", "type": "desk", "position": [2500,2500], "rotation": 0, "label": "试验台"}],
"dimensions": [{"id": "overall1", "type": "overall", "points": [[0,0],[5000,0],[12000,0]], "offset": -700}],
"notes": [{"id": "note1", "position": [8000,8000], "text": "尺寸单位mm"}],
}},
}
def _cases() -> dict[str, tuple[dict, str | None]]:
small = _base("中文小三居", "A3")
small["operation"]["drawing"]["walls"] += [
{"id":"left_split","start":[0,4500],"end":[5000,4500],"thickness":120,"kind":"interior"},
{"id":"right_split","start":[5000,5200],"end":[12000,5200],"thickness":120,"kind":"interior"},
]
small["operation"]["drawing"]["areas"] = [
{"id":"bed1","boundary":[[0,0],[5000,0],[5000,4500],[0,4500],[0,0]],"name":"卧室一","label_position":[2500,3200],"show_area":True},
{"id":"bed2","boundary":[[0,4500],[5000,4500],[5000,9000],[0,9000],[0,4500]],"name":"卧室二","label_position":[2500,6800],"show_area":True},
{"id":"bed3","boundary":[[5000,5200],[12000,5200],[12000,9000],[5000,9000],[5000,5200]],"name":"卧室三","label_position":[8500,7200],"show_area":True},
{"id":"living","boundary":[[5000,0],[12000,0],[12000,5200],[5000,5200],[5000,0]],"name":"客餐厅","label_position":[8500,3000],"show_area":True},
]
two_room = _base("两室一厅", "A4")
two_room["operation"]["drawing"]["sheet"]["scale"] = 100
lab = _base("实验室设备布置", "A2", "equipment_layout")
lab["operation"]["drawing"]["objects"] += [
{"id": "hood", "type": "fume_hood", "position": [8000,2500], "label": "通风柜"},
{"id": "exit", "type": "safety_exit", "position": [10500,7000], "label": "安全出口"},
]
workshop = _base("车间布置", "A2", "equipment_layout")
workshop["operation"]["drawing"]["objects"] = [
{"id": "machine1", "type": "machine", "position": [3000,3000], "size": [2200,1600], "label": "成型机"},
{"id": "tank1", "type": "tank", "position": [9000,5000], "size": [1800,1800], "label": "储罐"},
]
dense = _base("密集门窗尺寸", "A2")
dense["operation"]["drawing"]["dimensions"] = [{"id":"chain1","type":"chain","points":[[x,0] for x in range(0,12001,1000)],"offset":-900}]
outside = _base("门窗越界")
outside["operation"]["drawing"]["openings"][0]["offset"] = 11900
overlap = _base("洞口重叠")
overlap["operation"]["drawing"]["openings"].append({"id":"entry2","wall_id":"south","offset":1500,"width":1200,"type":"opening"})
unclosed = _base("区域不闭合")
unclosed["operation"]["drawing"]["areas"][0]["boundary"][-1] = [1, 0]
collision = _base("对象碰撞警告")
collision["operation"]["drawing"]["objects"][0]["position"] = [1000, 500]
return {
"small_three_bedroom": (small, None), "two_bedroom_living": (two_room, None),
"laboratory_layout": (lab, None), "workshop_layout": (workshop, None),
"dense_openings_dimensions": (dense, None),
"opening_outside_wall": (outside, "CAD2D_OPENING_OUTSIDE_WALL"),
"overlapping_openings": (overlap, "CAD2D_OPENINGS_OVERLAP"),
"unclosed_area": (unclosed, "CAD2D_AREA_NOT_CLOSED"),
"object_collision_warning": (collision, None),
}
def _digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _execute(root: Path, name: str, request: dict, expected_error: str | None, repeat: int) -> dict:
digests: list[dict[str, str]] = []
result: dict = {"name": name, "passed": False, "expected_error": expected_error}
for index in range(repeat):
job = root / f"验证 输出 {name}-{index + 1}"
(job / "request").mkdir(parents=True)
record = {"job_id": str(uuid4()), "lease_id": str(uuid4()), "request_digest": "a" * 64, "request": request}
(job / "request" / "request.json").write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8")
try:
artifacts = worker.run(job)
if expected_error:
result["detail"] = "expected failure but worker succeeded"
return result
current = {item["filename"]: _digest(job / "output" / (".meta/provenance.json" if item["filename"] == "provenance.json" else item["filename"])) for item in artifacts if item["filename"] not in {"provenance.json", "drawing.pdf"}}
digests.append(current)
report = json.loads((job / "output" / "validation-report.json").read_text(encoding="utf-8"))
if not report["passed"] or report["checks"]["version"] != "R2013":
result["detail"] = "mechanical validation failed"
return result
except worker.DrawingError as exc:
if exc.code != expected_error:
result["detail"] = f"unexpected error: {exc.code}"
return result
result.update({"passed": True, "observed_error": exc.code})
return result
result["passed"] = all(item == digests[0] for item in digests[1:])
result["deterministic_repeats"] = len(digests)
result["warnings"] = report["warnings"]
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--repeat", type=int, default=3, choices=range(1, 6))
parser.add_argument("--case", action="append", choices=sorted(_cases()))
args = parser.parse_args()
if args.work_root.exists():
print("[ERR] --work-root must not already exist", file=sys.stderr)
return 2
args.work_root.mkdir(parents=True)
selected = args.case or list(_cases())
results = [_execute(args.work_root, name, *_cases()[name], args.repeat) for name in selected]
# Cancellation/cleanup gate: terminate a child before it can touch a job directory.
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
child.terminate()
try:
child.wait(timeout=5)
cancellation = {"passed": True, "returncode": child.returncode}
except subprocess.TimeoutExpired:
child.kill(); child.wait(); cancellation = {"passed": False, "returncode": child.returncode}
report = {
"passed": all(item["passed"] for item in results) and cancellation["passed"],
"adapter_version": worker.ADAPTER_VERSION, "cases": results,
"cancellation_and_cleanup": cancellation,
"path_coverage": "work root supports spaces and Chinese characters",
"manual_compatibility_pending": ["AutoCAD", "ZWCAD", "GstarCAD"],
}
(args.work_root / "acceptance-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print("[OK] 2D CAD acceptance passed." if report["passed"] else "[ERR] 2D CAD acceptance failed.")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,10 @@
{
"capability": "cad.drawing.author@v1",
"adapter_version": "1.0.0",
"runtime": "python",
"runtime_id": "cad2d",
"entrypoint": "worker.py",
"contract": "cad.drawing.author.v1.json",
"worker_timeout_minutes": 10,
"running_detail": "二维 CAD 工程制图正在生成并校验 DXF 与预览"
}

View File

@ -0,0 +1,2 @@
pillow==12.0.0 \
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a

View File

@ -0,0 +1,536 @@
"""Deterministic runtime-only 2D CAD adapter with a closed declarative recipe."""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
from collections import Counter
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
ADAPTER_VERSION = "1.0.0"
PILLOW_VERSION = "12.0.0"
MAX_REQUEST_BYTES = 524_288
MAX_ENTITIES = 1024
LAYERS = (
"A-WALL-EXT", "A-WALL-INT", "A-DOOR", "A-WINDOW", "A-AREA",
"A-FURN", "A-EQUIP", "A-SAFE", "A-DIMS", "A-TEXT", "A-HATCH", "A-FRAME",
)
SHEET_MM = {"A4": (210, 297), "A3": (297, 420), "A2": (420, 594), "A1": (594, 841), "A0": (841, 1189)}
OBJECT_DEFAULTS = {
"desk": (1400, 700), "chair": (500, 500), "cabinet": (900, 450),
"lab_bench": (1800, 750), "fume_hood": (1500, 850), "machine": (1800, 1200),
"tank": (1200, 1200), "safety_exit": (900, 300), "extinguisher": (300, 300),
}
OUTPUT_MEDIA = {
"drawing.dxf": ("drawing_dxf", "image/vnd.dxf"),
"drawing.svg": ("drawing_svg", "image/svg+xml"),
"drawing.png": ("drawing_png", "image/png"),
"drawing.pdf": ("drawing_pdf", "application/pdf"),
"drawing-recipe.json": ("drawing_recipe", "application/json"),
"drawing-manifest.json": ("drawing_manifest", "application/json"),
"validation-report.json": ("validation_report", "application/json"),
"provenance.json": ("provenance", "application/json"),
}
class DrawingError(ValueError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(code if not detail else f"{code}: {detail}")
self.code = code
def _atomic_bytes(path: Path, data: bytes) -> None:
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _atomic_json(path: Path, value: Any) -> None:
_atomic_bytes(path, (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n").encode())
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _artifact(path: Path) -> dict[str, Any]:
artifact_id, media_type = OUTPUT_MEDIA[path.name]
return {"artifact_id": artifact_id, "filename": path.name, "media_type": media_type,
"size_bytes": path.stat().st_size, "sha256": _sha256(path)}
def _terminal(record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], 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 _point(value: Iterable[float]) -> tuple[float, float]:
x, y = value
result = (float(x), float(y))
if not all(math.isfinite(item) and abs(item) <= 1_000_000 for item in result):
raise DrawingError("CAD2D_COORDINATE_INVALID")
return result
def _distance(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def _cross(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float]) -> float:
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool:
def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> int:
value = _cross(p, q, r)
return 1 if value > 1e-8 else -1 if value < -1e-8 else 0
def on_segment(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> bool:
return min(p[0], r[0]) - 1e-8 <= q[0] <= max(p[0], r[0]) + 1e-8 and min(p[1], r[1]) - 1e-8 <= q[1] <= max(p[1], r[1]) + 1e-8
first, second = orientation(a, b, c), orientation(a, b, d)
third, fourth = orientation(c, d, a), orientation(c, d, b)
if first != second and third != fourth:
return True
return ((first == 0 and on_segment(a, c, b)) or (second == 0 and on_segment(a, d, b))
or (third == 0 and on_segment(c, a, d)) or (fourth == 0 and on_segment(c, b, d)))
def _polygon_area(points: list[tuple[float, float]]) -> float:
return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(points, points[1:]))) / 2
def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool:
inside = False
x, y = point
for a, b in zip(polygon, polygon[1:]):
if (a[1] > y) != (b[1] > y):
intersection = (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]
if x < intersection:
inside = not inside
return inside
def _rotated_rectangle(position: tuple[float, float], size: tuple[float, float], rotation: float) -> list[tuple[float, float]]:
x, y = position
width, height = size
angle = math.radians(rotation)
cos_a, sin_a = math.cos(angle), math.sin(angle)
points = []
for dx, dy in ((-width / 2, -height / 2), (width / 2, -height / 2), (width / 2, height / 2), (-width / 2, height / 2)):
points.append((x + dx * cos_a - dy * sin_a, y + dx * sin_a + dy * cos_a))
return points + [points[0]]
def _bbox(points: Iterable[tuple[float, float]]) -> list[float]:
values = list(points)
return [min(p[0] for p in values), min(p[1] for p in values), max(p[0] for p in values), max(p[1] for p in values)]
def _bbox_overlap(a: list[float], b: list[float]) -> bool:
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def _validate_and_normalize(drawing: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]], dict[str, Any]]:
value = deepcopy(drawing)
arrays = [value[name] for name in ("walls", "openings", "areas", "objects", "dimensions", "notes")]
if sum(len(items) for items in arrays) > MAX_ENTITIES:
raise DrawingError("CAD2D_ENTITY_LIMIT_EXCEEDED")
identifiers = [item["id"] for items in arrays for item in items]
if len(identifiers) != len(set(identifiers)):
raise DrawingError("CAD2D_DUPLICATE_ID")
walls: dict[str, dict[str, Any]] = {}
wall_boxes: list[list[float]] = []
for wall in value["walls"]:
start, end = _point(wall["start"]), _point(wall["end"])
length = _distance(start, end)
if length <= 1e-6:
raise DrawingError("CAD2D_ZERO_LENGTH_WALL", wall["id"])
if not 50 <= float(wall["thickness"]) <= 2000:
raise DrawingError("CAD2D_WALL_THICKNESS_INVALID", wall["id"])
wall["start"], wall["end"] = list(start), list(end)
wall["length"] = round(length, 6)
walls[wall["id"]] = wall
half = float(wall["thickness"]) / 2
wall_boxes.append([min(start[0], end[0]) - half, min(start[1], end[1]) - half,
max(start[0], end[0]) + half, max(start[1], end[1]) + half])
openings_by_wall: dict[str, list[tuple[float, float, str]]] = {}
for opening in value["openings"]:
wall = walls.get(opening["wall_id"])
if wall is None:
raise DrawingError("CAD2D_WALL_REFERENCE_MISSING", opening["id"])
start = float(opening["offset"])
end = start + float(opening["width"])
if start < 0 or end > wall["length"] + 1e-6:
raise DrawingError("CAD2D_OPENING_OUTSIDE_WALL", opening["id"])
intervals = openings_by_wall.setdefault(opening["wall_id"], [])
if any(start < old_end - 1e-6 and end > old_start + 1e-6 for old_start, old_end, _ in intervals):
raise DrawingError("CAD2D_OPENINGS_OVERLAP", opening["id"])
intervals.append((start, end, opening["id"]))
area_values: dict[str, float] = {}
all_points: list[tuple[float, float]] = []
warnings: list[dict[str, str]] = []
for area in value["areas"]:
points = [_point(point) for point in area["boundary"]]
if _distance(points[0], points[-1]) > 1e-6:
raise DrawingError("CAD2D_AREA_NOT_CLOSED", area["id"])
for index, (a, b) in enumerate(zip(points, points[1:])):
for other, (c, d) in enumerate(zip(points, points[1:])):
if abs(index - other) <= 1 or {index, other} == {0, len(points) - 2}:
continue
if _segments_intersect(a, b, c, d):
raise DrawingError("CAD2D_AREA_SELF_INTERSECTION", area["id"])
size = _polygon_area(points)
if size <= 1e-6:
raise DrawingError("CAD2D_AREA_ZERO", area["id"])
if size < 500_000 or size > 1_000_000_000:
warnings.append({"code": "CAD2D_AREA_SIZE_UNUSUAL", "entity_id": area["id"], "detail": "区域面积异常,请复核 Recipe。"})
area_values[area["id"]] = round(size, 3)
area["boundary"] = [list(point) for point in points]
all_points.extend(points)
label_position = _point(area["label_position"])
all_points.append(label_position)
if not _point_in_polygon(label_position, points):
warnings.append({"code": "CAD2D_LABEL_OUTSIDE_AREA", "entity_id": area["id"], "detail": "区域标签位于边界之外。"})
object_boxes: dict[str, list[float]] = {}
for item in value["objects"]:
position = _point(item["position"])
kind = item["type"]
if kind == "custom_circle":
radius = float(item.get("radius", 0))
if radius <= 0 or "size" in item:
raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"])
box = [position[0] - radius, position[1] - radius, position[0] + radius, position[1] + radius]
else:
if kind == "custom_rectangle" and "size" not in item:
raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"])
size = tuple(float(v) for v in item.get("size", OBJECT_DEFAULTS.get(kind, (0, 0))))
if len(size) != 2 or min(size) <= 0 or "radius" in item:
raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"])
item["size"] = list(size)
box = _bbox(_rotated_rectangle(position, size, float(item.get("rotation", 0))))
item["position"] = list(position)
object_boxes[item["id"]] = box
all_points.extend(((box[0], box[1]), (box[2], box[3])))
if any(_bbox_overlap(box, wall_box) for wall_box in wall_boxes):
warnings.append({"code": "CAD2D_OBJECT_OVERLAPS_WALL", "entity_id": item["id"], "detail": "对象与墙体包围范围相交。"})
for opening in value["openings"]:
if "door" not in opening["type"]:
continue
wall = walls[opening["wall_id"]]
a, b = _point(wall["start"]), _point(wall["end"])
ratio = float(opening["offset"]) / wall["length"]
hinge = (a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio)
radius = float(opening["width"])
swing_box = [hinge[0] - radius, hinge[1] - radius, hinge[0] + radius, hinge[1] + radius]
for object_id, box in object_boxes.items():
if _bbox_overlap(swing_box, box):
warnings.append({"code": "CAD2D_DOOR_SWING_OBJECT_COLLISION", "entity_id": opening["id"], "detail": f"门扇可能与对象 {object_id} 碰撞。"})
for wall in walls.values():
all_points.extend((_point(wall["start"]), _point(wall["end"])))
for note in value["notes"]:
all_points.append(_point(note["position"]))
if not all_points:
raise DrawingError("CAD2D_EMPTY_DRAWING")
bounds = _bbox(all_points)
sheet = value["sheet"]
paper = SHEET_MM[sheet["size"]]
if sheet["orientation"] == "landscape":
paper = (paper[1], paper[0])
usable = ((paper[0] - 20) * sheet["scale"], (paper[1] - 20) * sheet["scale"])
if bounds[2] - bounds[0] > usable[0] + 1e-6 or bounds[3] - bounds[1] > usable[1] + 1e-6:
raise DrawingError("CAD2D_DRAWING_OUTSIDE_SHEET")
if len(identifiers) > 700:
warnings.append({"code": "CAD2D_INFORMATION_DENSE", "entity_id": "drawing", "detail": "图纸信息密度较高,建议人工检查可读性。"})
for dimension in value["dimensions"]:
source_points = dimension["points"] if "points" in dimension else [dimension["start"], dimension["end"]]
points = [_point(p) for p in source_points]
offset = float(dimension["offset"])
all_points.extend(points)
if dimension["type"] == "vertical":
all_points.extend((point[0] + offset, point[1]) for point in points)
elif dimension["type"] in {"horizontal", "chain", "overall"}:
all_points.extend((point[0], point[1] + offset) for point in points)
else:
all_points.extend((point[0] - abs(offset), point[1] - abs(offset)) for point in points)
all_points.extend((point[0] + abs(offset), point[1] + abs(offset)) for point in points)
if len(points) > 12:
warnings.append({"code": "CAD2D_DIMENSION_TEXT_MAY_OVERLAP", "entity_id": dimension["id"], "detail": "连续尺寸较密,文字可能重叠。"})
return value, warnings, {"bbox": [round(v, 3) for v in bounds], "areas_mm2": area_values}
def _add(entities: list[dict[str, Any]], kind: str, layer: str, **values: Any) -> None:
entities.append({"kind": kind, "layer": layer, **values})
def _entities(drawing: dict[str, Any], metrics: dict[str, Any]) -> list[dict[str, Any]]:
entities: list[dict[str, Any]] = []
for wall in drawing["walls"]:
layer = "A-WALL-EXT" if wall["kind"] == "exterior" else "A-WALL-INT"
a, b = _point(wall["start"]), _point(wall["end"])
length, half = wall["length"], float(wall["thickness"]) / 2
nx, ny = -(b[1] - a[1]) / length * half, (b[0] - a[0]) / length * half
_add(entities, "polyline", layer, points=[(a[0]+nx,a[1]+ny),(b[0]+nx,b[1]+ny),(b[0]-nx,b[1]-ny),(a[0]-nx,a[1]-ny)], closed=True)
for opening in drawing["openings"]:
wall = next(item for item in drawing["walls"] if item["id"] == opening["wall_id"])
a, b = _point(wall["start"]), _point(wall["end"])
ux, uy = (b[0]-a[0])/wall["length"], (b[1]-a[1])/wall["length"]
start = (a[0]+ux*opening["offset"], a[1]+uy*opening["offset"])
end = (start[0]+ux*opening["width"], start[1]+uy*opening["width"])
layer = "A-DOOR" if "door" in opening["type"] else "A-WINDOW"
_add(entities, "line", layer, start=start, end=end)
if "door" in opening["type"]:
_add(entities, "arc", layer, center=start, radius=opening["width"], start_angle=0, end_angle=90)
for area in drawing["areas"]:
_add(entities, "polyline", "A-AREA", points=[_point(p) for p in area["boundary"][:-1]], closed=True)
label = area["name"] + (f" {metrics['areas_mm2'][area['id']]/1_000_000:.2f}" if area["show_area"] else "")
_add(entities, "text", "A-TEXT", point=_point(area["label_position"]), text=label, height=250)
for item in drawing["objects"]:
layer = "A-SAFE" if item["type"] in {"safety_exit", "extinguisher"} else "A-FURN" if item["type"] in {"desk", "chair", "cabinet"} else "A-EQUIP"
position = _point(item["position"])
if item["type"] == "custom_circle":
_add(entities, "circle", layer, center=position, radius=item["radius"])
else:
_add(entities, "polyline", layer, points=_rotated_rectangle(position, tuple(item["size"]), float(item.get("rotation", 0)))[:-1], closed=True)
if item.get("label"):
_add(entities, "text", "A-TEXT", point=position, text=item["label"], height=220)
for dimension in drawing["dimensions"]:
source_points = dimension["points"] if "points" in dimension else [dimension["start"], dimension["end"]]
points = [_point(p) for p in source_points]
pairs = list(zip(points, points[1:]))
if dimension["type"] == "overall":
pairs = [(points[0], points[-1])]
for a, b in pairs:
offset = float(dimension["offset"])
if dimension["type"] == "vertical":
aa, bb = (a[0]+offset,a[1]), (b[0]+offset,b[1]); distance = abs(b[1]-a[1])
elif dimension["type"] == "horizontal":
aa, bb = (a[0],a[1]+offset), (b[0],b[1]+offset); distance = abs(b[0]-a[0])
else:
length = _distance(a, b); nx, ny = (-(b[1]-a[1])/length, (b[0]-a[0])/length)
aa, bb = (a[0]+nx*offset,a[1]+ny*offset), (b[0]+nx*offset,b[1]+ny*offset); distance = length
_add(entities, "line", "A-DIMS", start=aa, end=bb)
_add(entities, "text", "A-DIMS", point=((aa[0]+bb[0])/2,(aa[1]+bb[1])/2), text=f"{distance:.0f}", height=180)
for note in drawing["notes"]:
_add(entities, "text", "A-TEXT", point=_point(note["position"]), text=note["text"], height=220, rotation=float(note.get("rotation", 0)))
bounds = metrics["bbox"]
margin = max(500.0, max(bounds[2]-bounds[0], bounds[3]-bounds[1]) * 0.03)
_add(entities, "polyline", "A-FRAME", points=[(bounds[0]-margin,bounds[1]-margin),(bounds[2]+margin,bounds[1]-margin),(bounds[2]+margin,bounds[3]+margin),(bounds[0]-margin,bounds[3]+margin)], closed=True)
return entities
def _dxf_pair(code: int, value: Any) -> str:
if isinstance(value, float):
value = format(value, ".9f").rstrip("0").rstrip(".") or "0"
return f"{code}\r\n{value}\r\n"
def _write_dxf(path: Path, entities: list[dict[str, Any]]) -> None:
parts = [_dxf_pair(0,"SECTION"),_dxf_pair(2,"HEADER"),_dxf_pair(9,"$ACADVER"),_dxf_pair(1,"AC1027"),_dxf_pair(9,"$INSUNITS"),_dxf_pair(70,4),_dxf_pair(0,"ENDSEC"),
_dxf_pair(0,"SECTION"),_dxf_pair(2,"TABLES"),_dxf_pair(0,"TABLE"),_dxf_pair(2,"LAYER"),_dxf_pair(70,len(LAYERS))]
colors = [1, 8, 2, 4, 3, 6, 5, 1, 7, 7, 9, 7]
for layer, color in zip(LAYERS, colors):
parts += [_dxf_pair(0,"LAYER"),_dxf_pair(2,layer),_dxf_pair(70,0),_dxf_pair(62,color),_dxf_pair(6,"CONTINUOUS")]
parts += [_dxf_pair(0,"ENDTAB"),_dxf_pair(0,"ENDSEC"),_dxf_pair(0,"SECTION"),_dxf_pair(2,"ENTITIES")]
for entity in entities:
kind, layer = entity["kind"], entity["layer"]
if kind == "line":
a,b=entity["start"],entity["end"]; parts += [_dxf_pair(0,"LINE"),_dxf_pair(8,layer),_dxf_pair(10,a[0]),_dxf_pair(20,a[1]),_dxf_pair(11,b[0]),_dxf_pair(21,b[1])]
elif kind == "polyline":
parts += [_dxf_pair(0,"LWPOLYLINE"),_dxf_pair(8,layer),_dxf_pair(90,len(entity["points"])),_dxf_pair(70,1 if entity.get("closed") else 0)]
for point in entity["points"]: parts += [_dxf_pair(10,point[0]),_dxf_pair(20,point[1])]
elif kind == "circle":
c=entity["center"]; parts += [_dxf_pair(0,"CIRCLE"),_dxf_pair(8,layer),_dxf_pair(10,c[0]),_dxf_pair(20,c[1]),_dxf_pair(40,entity["radius"])]
elif kind == "arc":
c=entity["center"]; parts += [_dxf_pair(0,"ARC"),_dxf_pair(8,layer),_dxf_pair(10,c[0]),_dxf_pair(20,c[1]),_dxf_pair(40,entity["radius"]),_dxf_pair(50,entity["start_angle"]),_dxf_pair(51,entity["end_angle"])]
else:
p=entity["point"]; parts += [_dxf_pair(0,"TEXT"),_dxf_pair(8,layer),_dxf_pair(10,p[0]),_dxf_pair(20,p[1]),_dxf_pair(40,entity["height"]),_dxf_pair(1,entity["text"]),_dxf_pair(50,entity.get("rotation",0))]
parts += [_dxf_pair(0,"ENDSEC"),_dxf_pair(0,"EOF")]
_atomic_bytes(path, "".join(parts).encode("utf-8"))
def _reopen_dxf(path: Path) -> dict[str, Any]:
lines = path.read_text(encoding="utf-8").splitlines()
if len(lines) % 2 or "AC1027" not in lines or lines[-1] != "EOF":
raise DrawingError("CAD2D_DXF_REOPEN_FAILED")
pairs = [(lines[i].strip(), lines[i+1]) for i in range(0, len(lines), 2)]
layers = {value for index,(code,value) in enumerate(pairs) if code == "0" and value == "LAYER" for next_code,value in pairs[index+1:index+4] if next_code == "2"}
entity_names = {"LINE","LWPOLYLINE","CIRCLE","ARC","TEXT"}
kinds = Counter(value for code,value in pairs if code == "0" and value in entity_names)
if layers != set(LAYERS) or not kinds:
raise DrawingError("CAD2D_DXF_STRUCTURE_INVALID")
points: list[tuple[float, float]] = []
for index, (code, name) in enumerate(pairs):
if code != "0" or name not in entity_names:
continue
block: list[tuple[str, str]] = []
for pair in pairs[index + 1:]:
if pair[0] == "0":
break
block.append(pair)
def values(group: str) -> list[float]:
return [float(value) for item_code, value in block if item_code == group]
xs, ys = values("10"), values("20")
if name == "LINE":
xs += values("11"); ys += values("21")
if name in {"CIRCLE", "ARC"} and xs and ys:
radius = values("40")[0]
points.extend(((xs[0] - radius, ys[0] - radius), (xs[0] + radius, ys[0] + radius)))
else:
points.extend(zip(xs, ys))
if not points:
raise DrawingError("CAD2D_DXF_BOUNDS_INVALID")
return {"version": "R2013", "unit": "mm", "layers": sorted(layers), "entity_types": dict(sorted(kinds.items())), "entity_count": sum(kinds.values()), "bbox": [round(value, 3) for value in _bbox(points)]}
def _entity_bbox(entities: list[dict[str, Any]]) -> list[float]:
points: list[tuple[float, float]] = []
for entity in entities:
if entity["kind"] == "line":
points.extend((entity["start"], entity["end"]))
elif entity["kind"] == "polyline":
points.extend(entity["points"])
elif entity["kind"] in {"circle", "arc"}:
x, y = entity["center"]; radius = float(entity["radius"])
points.extend(((x - radius, y - radius), (x + radius, y + radius)))
else:
points.append(entity["point"])
return [round(value, 3) for value in _bbox(points)]
def _xml(value: str) -> str:
return value.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;")
def _svg(path: Path, entities: list[dict[str, Any]], bounds: list[float]) -> None:
width,height=max(1,bounds[2]-bounds[0]),max(1,bounds[3]-bounds[1]); stroke=max(width,height)/1200
parts=[f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{bounds[0]} {-bounds[3]} {width} {height}">', '<rect width="100%" height="100%" fill="white"/>', '<g fill="none" stroke="#111" stroke-width="%s">'%stroke]
for e in entities:
if e["kind"]=="line": parts.append(f'<line x1="{e["start"][0]}" y1="{-e["start"][1]}" x2="{e["end"][0]}" y2="{-e["end"][1]}"/>')
elif e["kind"]=="polyline": parts.append('<polygon points="%s"/>'%' '.join(f'{p[0]},{-p[1]}' for p in e["points"]))
elif e["kind"]=="circle": parts.append(f'<circle cx="{e["center"][0]}" cy="{-e["center"][1]}" r="{e["radius"]}"/>')
elif e["kind"]=="arc": parts.append(f'<circle cx="{e["center"][0]}" cy="{-e["center"][1]}" r="{e["radius"]}" stroke-dasharray="{e["radius"]*1.57} {e["radius"]*4.71}"/>')
parts.append('</g><g font-family="Microsoft YaHei,SimHei,sans-serif" fill="#111" text-anchor="middle">')
for e in entities:
if e["kind"]=="text": parts.append(f'<text x="{e["point"][0]}" y="{-e["point"][1]}" font-size="{e["height"]}">{_xml(str(e["text"]))}</text>')
parts.append('</g></svg>')
_atomic_bytes(path, ("\n".join(parts)+"\n").encode())
def _font_path() -> Path:
root = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
for name in ("msyh.ttc", "simhei.ttf"):
candidate = root / name
if candidate.is_file(): return candidate
raise DrawingError("CAD2D_CHINESE_FONT_UNAVAILABLE")
def _raster(path: Path, entities: list[dict[str, Any]], bounds: list[float]) -> None:
from PIL import Image, ImageDraw, ImageFont
width,height=1600,1100; margin=60; sx=(width-2*margin)/max(1,bounds[2]-bounds[0]); sy=(height-2*margin)/max(1,bounds[3]-bounds[1]); scale=min(sx,sy)
def pixel(point: tuple[float,float]) -> tuple[int,int]: return (round(margin+(point[0]-bounds[0])*scale),round(height-margin-(point[1]-bounds[1])*scale))
image=Image.new("RGB",(width,height),"white"); draw=ImageDraw.Draw(image); font=ImageFont.truetype(str(_font_path()),18)
for e in entities:
if e["kind"]=="line": draw.line([pixel(e["start"]),pixel(e["end"])],fill="#111111",width=2)
elif e["kind"]=="polyline":
points=[pixel(p) for p in e["points"]]; draw.line(points+[points[0]],fill="#111111",width=3 if "WALL" in e["layer"] else 1)
elif e["kind"]=="circle":
c=pixel(e["center"]); r=max(1,round(e["radius"]*scale)); draw.ellipse((c[0]-r,c[1]-r,c[0]+r,c[1]+r),outline="#111111",width=2)
elif e["kind"]=="arc":
c=pixel(e["center"]); r=max(1,round(e["radius"]*scale)); draw.arc((c[0]-r,c[1]-r,c[0]+r,c[1]+r),180,270,fill="#333333",width=2)
else:
p=pixel(e["point"]); draw.text(p,str(e["text"]),font=font,fill="#111111",anchor="mm")
image.save(path, format="PNG", optimize=False, compress_level=9)
def _pdf_from_png(png: Path, pdf: Path) -> None:
import time
from PIL import Image
with Image.open(png) as image:
fixed_time = time.gmtime(0)
image.convert("RGB").save(
pdf, format="PDF", resolution=150.0, title="zcbot 2D CAD drawing",
creationDate=fixed_time, modDate=fixed_time,
)
def _record(job_dir: Path) -> dict[str, Any]:
raw = (job_dir / "request" / "request.json").read_bytes()
if len(raw) > MAX_REQUEST_BYTES + 131_072: raise DrawingError("CAD2D_REQUEST_TOO_LARGE")
value = json.loads(raw)
if not isinstance(value, dict) or not isinstance(value.get("request"), dict): raise DrawingError("CAD2D_JOB_REQUEST_INVALID")
return value
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("inputs"): raise DrawingError("CAD2D_INPUTS_MUST_BE_EMPTY")
if request.get("outputs"): raise DrawingError("CAD2D_OUTPUTS_MUST_BE_EMPTY")
drawing,warnings,metrics=_validate_and_normalize(request["operation"]["drawing"])
output=(job_dir/"output").resolve(); output.mkdir(exist_ok=True)
if not output.is_relative_to(job_dir): raise DrawingError("CAD2D_OUTPUT_PATH_ESCAPES_JOB")
metadata=output/".meta"; metadata.mkdir(exist_ok=True)
entities=_entities(drawing,metrics)
dxf=output/"drawing.dxf"; svg=output/"drawing.svg"; png=output/"drawing.png"; pdf=output/"drawing.pdf"
_write_dxf(dxf,entities); reopened=_reopen_dxf(dxf)
if reopened["entity_count"] != len(entities): raise DrawingError("CAD2D_ENTITY_COUNT_MISMATCH")
if reopened["bbox"] != _entity_bbox(entities): raise DrawingError("CAD2D_DXF_BOUNDS_MISMATCH")
bounds=metrics["bbox"]; padding=max(500.0,max(bounds[2]-bounds[0],bounds[3]-bounds[1])*.05); render_bounds=[bounds[0]-padding,bounds[1]-padding,bounds[2]+padding,bounds[3]+padding]
_svg(svg,entities,render_bounds); _raster(png,entities,render_bounds); _pdf_from_png(png,pdf)
recipe_path=output/"drawing-recipe.json"; report_path=output/"validation-report.json"
_atomic_json(recipe_path,{"schema_version":1,"drawing":drawing})
observed_layer_counts=Counter(e["layer"] for e in entities)
layer_counts={layer: observed_layer_counts.get(layer, 0) for layer in LAYERS}
report={"passed":True,"errors":[],"warnings":warnings,"checks":{"dxf_reopened":True,"version":"R2013","unit":"mm","layers":list(LAYERS),"entity_count":len(entities),"bbox":bounds,"dxf_bbox":reopened["bbox"],"areas_mm2":metrics["areas_mm2"]}}
_atomic_json(report_path,report)
summaries=[]
for path in (dxf,svg,png,pdf,recipe_path,report_path): summaries.append({"filename":path.name,"size_bytes":path.stat().st_size,"sha256":_sha256(path)})
manifest={"adapter_version":ADAPTER_VERSION,"recipe_version":1,"drawing_type":drawing["type"],"unit":"mm","sheet":drawing["sheet"],"bbox":bounds,"areas_mm2":metrics["areas_mm2"],"layers":list(LAYERS),"layer_entity_counts":layer_counts,"entity_count":len(entities),"warnings":warnings,"artifacts":summaries}
manifest_path=output/"drawing-manifest.json"; _atomic_json(manifest_path,manifest)
provenance_path=metadata/"provenance.json"; _atomic_json(provenance_path,{"adapter":"cad.drawing.author@v1","adapter_version":ADAPTER_VERSION,"request_digest":record.get("request_digest"),"recipe_sha256":_sha256(recipe_path),"manifest_sha256":_sha256(manifest_path),"renderer":{"font":_font_path().name,"png":"Pillow","svg":"builtin","pdf":"Pillow","dxf":"builtin-r2013"}})
paths=[dxf,svg,png,pdf,recipe_path,manifest_path,report_path,provenance_path]
if any(not p.is_file() or p.stat().st_size==0 for p in paths): raise DrawingError("CAD2D_REQUIRED_OUTPUT_MISSING")
return [_artifact(path) for path in paths]
def probe() -> int:
payload={"adapter_version":ADAPTER_VERSION,"software":"二维 CAD 工程制图","software_version":None,"health":"unavailable","detail":""}
try:
from PIL import __version__ as pillow_version
if pillow_version != PILLOW_VERSION:
raise DrawingError("CAD2D_PILLOW_VERSION_UNSUPPORTED", f"expected {PILLOW_VERSION}, found {pillow_version}")
font=_font_path(); payload.update({"software_version":f"Pillow {pillow_version}","health":"ready","detail":f"Runtime-only renderer ready; Chinese font: {font.name}; DXF R2013 reopen validator ready"})
except (ImportError,OSError,DrawingError) as exc: payload["detail"]=str(exc)[:500]
print(json.dumps(payload,ensure_ascii=False)); return 0
def main() -> int:
if sys.argv[1:]==["--probe"]: return probe()
if len(sys.argv)!=2: print("[ERR] Usage: worker.py <job-directory>",file=sys.stderr); return 2
job_dir=Path(sys.argv[1]); record:dict[str,Any]={}
try:
record=_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] 2D CAD drawing authored and verified."); return 0
except Exception as exc:
code=exc.code if isinstance(exc,DrawingError) else "CAD2D_ADAPTER_FAILED"
try: _atomic_json(job_dir/"terminal.json",_terminal(record,"failed",[],code,str(exc)))
except OSError: pass
print(f"[ERR] {code}: {exc}",file=sys.stderr); return 1
if __name__ == "__main__": raise SystemExit(main())

View File

@ -6,5 +6,5 @@
"entrypoint": "worker.py", "entrypoint": "worker.py",
"contract": "cad.geometry.prepare.v1.json", "contract": "cad.geometry.prepare.v1.json",
"worker_timeout_minutes": 15, "worker_timeout_minutes": 15,
"running_detail": "中性 CAD 正在准备 STEP 几何" "running_detail": "仿真几何准备STEP正在生成并验证三维几何"
} }

View File

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