From 27fc0136fdf0157d65ffa1e1966b82f2c4224088 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 4 Sep 2026 10:54:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(cad):=20=E5=A2=9E=E5=8A=A0=E4=BA=8C?= =?UTF-8?q?=E7=BB=B4=E5=B7=A5=E7=A8=8B=E5=88=B6=E5=9B=BE=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- DESIGN.md | 8 +- PROGRESS.md | 10 +- RUN.md | 10 +- .../ansys.geometry.inspect.v1.json | 2 +- software-contracts/cad.drawing.author.v1.json | 141 +++++ .../cad.geometry.prepare.v1.json | 4 +- tests/test_cad2d_adapter.py | 111 ++++ tests/test_software_job_tools.py | 6 + tests/test_software_nodes.py | 1 + tests/test_windows_node_source.py | 29 +- tools/software_jobs.py | 11 +- windows-node/README.md | 2 +- .../AdapterArchitectureTests.cs | 6 +- .../SoftwarePageViewModelTests.cs | 2 +- .../Runtime/SoftwareCatalog.cs | 11 +- .../Zcbot.WindowsNode.csproj | 5 + .../cad.drawing.author@v1/acceptance.py | 165 ++++++ .../cad.drawing.author@v1/adapter.json | 10 + .../cad.drawing.author@v1/requirements.txt | 2 + .../adapters/cad.drawing.author@v1/worker.py | 536 ++++++++++++++++++ .../cad.geometry.prepare@v1/adapter.json | 2 +- windows-node/package-cad2d-adapter.bat | 47 ++ 23 files changed, 1103 insertions(+), 22 deletions(-) create mode 100644 software-contracts/cad.drawing.author.v1.json create mode 100644 tests/test_cad2d_adapter.py create mode 100644 windows-node/adapters/cad.drawing.author@v1/acceptance.py create mode 100644 windows-node/adapters/cad.drawing.author@v1/adapter.json create mode 100644 windows-node/adapters/cad.drawing.author@v1/requirements.txt create mode 100644 windows-node/adapters/cad.drawing.author@v1/worker.py create mode 100644 windows-node/package-cad2d-adapter.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea9723b..2a9391d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 新增二维 CAD 工程制图:可创建并连续修改户型图、实验室和车间设备布局,交付可编辑 DXF 及 SVG、PNG、PDF,并自动检查门窗、区域、图框和常见碰撞问题。 + - 修复深度思考或事件流重连期间点击“停止”偶尔没有反应的问题;停止信号会在状态落库前立即送达,重复停止也会可靠补发。 ## 0.71.0 — 2026-09-03 @@ -22,7 +24,7 @@ - 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据,并查看来源、脱敏尾号和可用状态;数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。 -- 新增中性 CAD 几何准备:可生成或检查 STEP,组合基础体、截面、布尔、圆角、阵列等受控建模步骤,并交付几何清单和预览,结果可继续用于 ANSYS、Abaqus 等分析任务。 +- 新增仿真几何准备(STEP):可生成或检查 STEP,组合基础体、截面、布尔、圆角、阵列等受控建模步骤,并交付几何清单和预览,结果可继续用于 ANSYS、Abaqus 等分析任务。 - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 diff --git a/DESIGN.md b/DESIGN.md index 7782a2aa..6861bc2b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -472,7 +472,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) 云端控制面使用独立的 `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 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,桌面软件允许管理员自动检测或选择预定义应用位置,runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--probe` 继续承担最终软件版本、SDK/COM 或内核 roundtrip 健康校验。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS、Blender 和中性 CAD adapter 使用 Python只是实现选择。 +专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。契约可附带 `active/deprecated` 生命周期、`standard/diagnostic_only` 可见性和替代 capability;普通能力枚举隐藏诊断入口,显式诊断查询与历史 Job 读取仍保留。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;发布过程把对应源码合同复制进各 adapter 目录,Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元,manifest、合同、Worker 和依赖必须整体更新,避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,桌面软件允许管理员自动检测或选择预定义应用位置,runtime-only 软件使用 `SoftwarePathKind.None` 且不接受路径;两类软件都只按内置清单及固定 requirements 单独构建受管 runtime。UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--probe` 继续承担最终软件版本、SDK/COM 或内核 roundtrip 健康校验。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS、Blender、仿真几何准备(STEP)和二维 CAD 工程制图 adapter 使用 Python 只是实现选择。 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 预览和 provenance;manifest 只保存单位、几何量、拓扑/表面摘要和已解析区域,不把 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 内可独立验证时再作为新增可选产物。 `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,并指向仿真几何准备(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,并统一声明材质、变换、灯光、世界背景和 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。 diff --git a/PROGRESS.md b/PROGRESS.md index aba5a15e..1c24117e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-09-04(Unreleased:简易 auto 推理强度模式) +最后更新:2026-09-04(Unreleased:二维 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、成功工具步后使用 low,Pro 的 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 变化,生产失败历史未改写。 @@ -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 固定调用 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 / 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 或运行方式变化。 @@ -493,8 +495,8 @@ web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/ web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/broker.py / sinks.py / pptx_render.py / admin.py web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端 -software-contracts/ ← 语言无关专业软件合同(含 FreeCAD、中性 CAD 与生命周期) -windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD runtime 与发布脚本 +software-contracts/ ← 语言无关专业软件合同(含 FreeCAD、二维 CAD、仿真几何与生命周期) +windows-node/ ← .NET 10 WPF/托盘节点、固定 adapter、FreeCAD/CAD/CAD2D runtime 与发布脚本 web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) ───────────────────────────────── diff --git a/RUN.md b/RUN.md index ba7fdb19..3567cfc2 100644 --- a/RUN.md +++ b/RUN.md @@ -1119,13 +1119,13 @@ Zcbot.WindowsNode.exe Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service,也不要求管理员创建计划任务。 -专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。既有桌面软件依赖默认显式使用清华 PyPI 镜像;中性 CAD 因二进制 wheel 完整性要求默认使用官方 PyPI。统一覆盖源或切换院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`,但不得绕过 pip 哈希校验。解释器位于 `\runtimes\origin|ansys|blender|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 哈希校验。解释器位于 `\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 始终跟随当前数据根目录。 Workspace 目录固定为 `\workspaces\\`,不使用 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/`;有对应活动任务时拒绝替换。安装要求本机存在 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/`;有对应活动任务时拒绝替换。安装要求本机存在 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/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`。 @@ -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 版本,版本不一致时该能力保持不可用。 -中性 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` 时应修复镜像缓存,不能添加忽略哈希参数。 +仿真几何准备(STEP)adapter 可用 `windows-node\package-cad-adapter.bat` 独立打成 `dist\cad.geometry.prepare@v1-adapter.zip`。退出 Node 后整体替换 `adapters\cad.geometry.prepare@v1\`,再从“仿真几何准备(STEP)”卡安装/更新固定 `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-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,旧版不覆盖。目标机可运行 `\runtimes\cad2d\Scripts\python.exe \adapters\cad.drawing.author@v1\acceptance.py --work-root \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。目标机用 `\runtimes\freecad\Scripts\python.exe \adapters\freecad.model.author@v1\acceptance.py --work-root \freecad-acceptance-YYYYMMDD --repeat 3` 执行固定验收;目录必须不存在。报告 `acceptance-report.json` 覆盖基础体、参数、布尔、变换、STEP import、FCStd/STEP roundtrip、模拟 Workspace revise/rollback、取消、空格/中文路径、连续运行及 FreeCAD 进程释放;报告通过且人工检查预览后才能记录目标机通过。 @@ -1200,7 +1202,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打 注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 -直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender、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% 重传;这些本地结果不会反向覆盖云端终态。 diff --git a/software-contracts/ansys.geometry.inspect.v1.json b/software-contracts/ansys.geometry.inspect.v1.json index 40629126..a3d56c89 100644 --- a/software-contracts/ansys.geometry.inspect.v1.json +++ b/software-contracts/ansys.geometry.inspect.v1.json @@ -6,7 +6,7 @@ "status": "deprecated", "visibility": "diagnostic_only", "replacement": "cad.geometry.prepare@v1", - "detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通几何准备请使用中性 CAD 能力。" + "detail": "保留用于 ANSYS Mechanical 真实导入专项诊断;普通三维仿真几何准备请使用仿真几何准备(STEP)能力。" }, "output_namespace": "ansys", "input_policy": { diff --git a/software-contracts/cad.drawing.author.v1.json b/software-contracts/cad.drawing.author.v1.json new file mode 100644 index 00000000..741b136e --- /dev/null +++ b/software-contracts/cad.drawing.author.v1.json @@ -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、三维实体及仿真前处理请使用仿真几何准备(STEP),FCStd 请使用 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"}} + } + } + } + } +} diff --git a/software-contracts/cad.geometry.prepare.v1.json b/software-contracts/cad.geometry.prepare.v1.json index 286e2c26..aab4b155 100644 --- a/software-contracts/cad.geometry.prepare.v1.json +++ b/software-contracts/cad.geometry.prepare.v1.json @@ -1,12 +1,12 @@ { "capability": "cad.geometry.prepare@v1", - "display_name": "中性 CAD 几何准备", + "display_name": "仿真几何准备(STEP)", "default_enrollment": false, "lifecycle": { "status": "active", "visibility": "standard", "replacement": null, - "detail": "生成供 ANSYS、Abaqus 等后续任务复用的中性 STEP。" + "detail": "用于 STEP、三维实体及 ANSYS/Abaqus 仿真前处理几何;平面图、户型图和设备布局请使用二维 CAD 工程制图。" }, "output_namespace": "cad", "input_policy": { diff --git a/tests/test_cad2d_adapter.py b/tests/test_cad2d_adapter.py new file mode 100644 index 00000000..487094e1 --- /dev/null +++ b/tests/test_cad2d_adapter.py @@ -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() diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index c3f2ab26..3849482d 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -44,6 +44,12 @@ class SoftwareJobToolTests(unittest.TestCase): self.assertEqual(result["capabilities"][0]["available_nodes"], 1) capabilities = {item["capability"] for item in result["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.assertNotIn("ansys.geometry.inspect@v1", capabilities) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 72275f28..cd6c93cd 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -88,6 +88,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase): "ansys.geometry.inspect@v1", "ansys.mechanical.static_structural@v2", "blender.scene.author@v3", + "cad.drawing.author@v1", "cad.geometry.prepare@v1", "freecad.model.author@v1", }, diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 138badeb..fd268bb2 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import unittest import xml.etree.ElementTree as ET from pathlib import Path @@ -329,7 +330,7 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('IsReadOnly="True"', xaml) self.assertIn("ManagedRuntimeInstaller.InstallAsync", service) self.assertIn("item.HasActiveJobs", service) - for runtime_id in ('"origin"', '"ansys"', '"blender"', '"freecad"', '"cad"'): + for runtime_id in ('"origin"', '"ansys"', '"blender"', '"freecad"', '"cad"', '"cad2d"'): self.assertIn(runtime_id, catalog) self.assertIn("SoftwarePathKind.None", 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", 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( "..\\..\\software-contracts\\freecad.model.author.v1.json", project, @@ -823,6 +832,24 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("tar.exe -a -c -f", script) 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: monitor = ( PROJECT / "Application" / "JobMonitorService.cs" diff --git a/tools/software_jobs.py b/tools/software_jobs.py index 6c5b3c3e..1dec3549 100644 --- a/tools/software_jobs.py +++ b/tools/software_jobs.py @@ -46,6 +46,10 @@ class SoftwareCapabilityListTool(_SoftwareJobTool): name = "software_capability_list" description = ( "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 " "not submit a software job." ) @@ -68,6 +72,7 @@ class SoftwareCapabilityListTool(_SoftwareJobTool): "lifecycle": get_contract(item).lifecycle.status, "visibility": get_contract(item).lifecycle.visibility, "replacement": get_contract(item).lifecycle.replacement, + "detail": get_contract(item).lifecycle.detail, "available_nodes": sum( 1 for node in nodes @@ -85,8 +90,10 @@ class SoftwareJobSubmitTool(_SoftwareJobTool): name = "software_job_submit" description = ( "Submit a managed professional-software job using registered artifacts and a " - "capability contract only when the user requested actual software execution; never " - "submit a job merely to check whether software is available. Call register_artifact " + "capability contract only when the user requested actual software execution. Do not " + "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 " "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 " diff --git a/windows-node/README.md b/windows-node/README.md index c47ee962..d694d0aa 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -20,7 +20,7 @@ Origin adapter 1.1.0 起随包提供独立 CLI 真机 QA harness;1.3.0 的固 Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `\runtimes\\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。 -推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender、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 安装到当前数据根目录,后续更新不会写回默认目录。 diff --git a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs index 99750d17..176f5759 100644 --- a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs +++ b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs @@ -76,7 +76,7 @@ public sealed class AdapterArchitectureTests [Fact] public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers() { - Assert.Equal(["origin", "ansys", "blender", "freecad", "cad"], + Assert.Equal(["origin", "ansys", "blender", "freecad", "cad", "cad2d"], SoftwareCatalog.Definitions.Select(item => item.RuntimeId)); Assert.Equal("ZCBOT_ORIGIN_EXE", SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable); @@ -92,6 +92,10 @@ public sealed class AdapterArchitectureTests Assert.Equal(SoftwarePathKind.None, cad.PathKind); Assert.Null(cad.EnvironmentVariable); 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] diff --git a/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs index 2b160435..ad56639f 100644 --- a/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs +++ b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs @@ -63,7 +63,7 @@ public sealed class SoftwarePageViewModelTests { var service = new FakeSoftwareManagementService( new SoftwareManagementSnapshot( - "cad", "中性 CAD", SoftwarePathKind.None, "", true, + "cad2d", "二维 CAD 工程制图", SoftwarePathKind.None, "", true, "无需桌面应用位置", "运行环境尚未安装", false, false)); using var viewModel = new SoftwarePageViewModel(service); viewModel.ConfirmInstallRequested += _ => true; diff --git a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs index dd77b96a..ff33ee5d 100644 --- a/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs +++ b/windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs @@ -38,13 +38,22 @@ internal static class SoftwareCatalog @"adapters\freecad.model.author@v1\requirements.txt"), new( "cad", - "中性 CAD", + "仿真几何准备(STEP)", "cad", SoftwarePathKind.None, null, null, @"adapters\cad.geometry.prepare@v1\requirements.txt", "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) => diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index b8754a89..f0d22d8f 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -53,6 +53,11 @@ PreserveNewest PreserveNewest + + adapters\cad.drawing.author@v1\cad.drawing.author.v1.json + PreserveNewest + PreserveNewest + adapters\freecad.model.author@v1\freecad.model.author.v1.json PreserveNewest diff --git a/windows-node/adapters/cad.drawing.author@v1/acceptance.py b/windows-node/adapters/cad.drawing.author@v1/acceptance.py new file mode 100644 index 00000000..b6e1e54c --- /dev/null +++ b/windows-node/adapters/cad.drawing.author@v1/acceptance.py @@ -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()) diff --git a/windows-node/adapters/cad.drawing.author@v1/adapter.json b/windows-node/adapters/cad.drawing.author@v1/adapter.json new file mode 100644 index 00000000..99941860 --- /dev/null +++ b/windows-node/adapters/cad.drawing.author@v1/adapter.json @@ -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 与预览" +} diff --git a/windows-node/adapters/cad.drawing.author@v1/requirements.txt b/windows-node/adapters/cad.drawing.author@v1/requirements.txt new file mode 100644 index 00000000..4c4afb5d --- /dev/null +++ b/windows-node/adapters/cad.drawing.author@v1/requirements.txt @@ -0,0 +1,2 @@ +pillow==12.0.0 \ + --hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a diff --git a/windows-node/adapters/cad.drawing.author@v1/worker.py b/windows-node/adapters/cad.drawing.author@v1/worker.py new file mode 100644 index 00000000..ab0e3be4 --- /dev/null +++ b/windows-node/adapters/cad.drawing.author@v1/worker.py @@ -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} m²" 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("&","&").replace("<","<").replace(">",">").replace('"',""") + + +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'', '', ''%stroke] + for e in entities: + if e["kind"]=="line": parts.append(f'') + elif e["kind"]=="polyline": parts.append(''%' '.join(f'{p[0]},{-p[1]}' for p in e["points"])) + elif e["kind"]=="circle": parts.append(f'') + elif e["kind"]=="arc": parts.append(f'') + parts.append('') + for e in entities: + if e["kind"]=="text": parts.append(f'{_xml(str(e["text"]))}') + parts.append('') + _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 ",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()) diff --git a/windows-node/adapters/cad.geometry.prepare@v1/adapter.json b/windows-node/adapters/cad.geometry.prepare@v1/adapter.json index 16e8a6d9..72bb8b85 100644 --- a/windows-node/adapters/cad.geometry.prepare@v1/adapter.json +++ b/windows-node/adapters/cad.geometry.prepare@v1/adapter.json @@ -6,5 +6,5 @@ "entrypoint": "worker.py", "contract": "cad.geometry.prepare.v1.json", "worker_timeout_minutes": 15, - "running_detail": "中性 CAD 正在准备 STEP 几何" + "running_detail": "仿真几何准备(STEP)正在生成并验证三维几何" } diff --git a/windows-node/package-cad2d-adapter.bat b/windows-node/package-cad2d-adapter.bat new file mode 100644 index 00000000..127048e8 --- /dev/null +++ b/windows-node/package-cad2d-adapter.bat @@ -0,0 +1,47 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +cd /d "%~dp0" + +set "OUTPUT_ROOT=%~f1" +if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist" +set "ADAPTER_DIR_NAME=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