feat(cad): 增加二维工程制图能力
This commit is contained in:
parent
8d60e000ce
commit
27fc0136fd
|
|
@ -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 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
10
PROGRESS.md
10
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 门控)
|
||||
─────────────────────────────────
|
||||
|
|
|
|||
10
RUN.md
10
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 哈希校验。解释器位于 `<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 始终跟随当前数据根目录。
|
||||
|
||||
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/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,旧版不覆盖。目标机可运行 `<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 进程释放;报告通过且人工检查预览后才能记录目标机通过。
|
||||
|
||||
|
|
@ -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。
|
||||
|
||||
直接双击 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% 重传;这些本地结果不会反向覆盖云端终态。
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Origin adapter 1.1.0 起随包提供独立 CLI 真机 QA harness;1.3.0 的固
|
|||
|
||||
Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
|
||||
|
||||
推荐在 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 安装到当前数据根目录,后续更新不会写回默认目录。
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@
|
|||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</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">
|
||||
<Link>adapters\freecad.model.author@v1\freecad.model.author.v1.json</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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 与预览"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
pillow==12.0.0 \
|
||||
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a
|
||||
|
|
@ -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'<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())
|
||||
|
|
@ -6,5 +6,5 @@
|
|||
"entrypoint": "worker.py",
|
||||
"contract": "cad.geometry.prepare.v1.json",
|
||||
"worker_timeout_minutes": 15,
|
||||
"running_detail": "中性 CAD 正在准备 STEP 几何"
|
||||
"running_detail": "仿真几何准备(STEP)正在生成并验证三维几何"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue