diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b42baa..7076f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。 + - 软件作业列表不再因历史任务使用的旧能力版本已经下线而整体消失;旧任务仍可查看,列表请求失败时页面也会明确提示刷新重试。 - ANSYS 新增几何检查能力,可在求解前返回实体、面、尺寸、法向、面积、已有选择集和预览;静力分析现在可按坐标轴端面或指定平面自动建立固定与加载区域,并在数量不符时停止,结果中同时保留实际选区及预览。 diff --git a/DESIGN.md b/DESIGN.md index 787a394..8f3aa00 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -462,7 +462,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 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。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;具体软件探测和二次语义校验属于 Worker。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用协议。当前 Origin 和 ANSYS adapter 使用 Python 只是实现选择。 +专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。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;具体软件探测和二次语义校验属于 Worker。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。 扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。 @@ -476,6 +476,8 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe,不建设跨软件万能 DSL;API/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。 +Blender 首个入口为 `blender.scene.author@v1`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作。adapter 0.1.0 的首个 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 创建模型、相机、灯光和材质;输出 `.blend`、PNG 预览、场景清单、溯源及可选 GLB。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行;请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state,续作先验证并打开上一版工程,再以完整目标状态生成新版本;后续修改器、节点、动画和高质量渲染继续作为同一业务方向的版本化 feature 增量开放,不把首个设备构造器固化成 Blender 的能力上限。 + 专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `%ProgramData%/Zcbot/WindowsNode/workspaces//`,不再按 user 建上层目录。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。 Node 对每个 Workspace 只保留 `current` 和 `rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`,Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程,物理空间上限约为当前工程加一份回滚工程;Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace state;ANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。 diff --git a/PROGRESS.md b/PROGRESS.md index 695e838..0329ce3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-19(ANSYS Mechanical 2024 R2 真实求解待真机验收,未发版) +最后更新:2026-08-20(Blender 回转窑 adapter 已完成本机烟测,未发版) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-20 / Unreleased / Blender 参数化回转窑 adapter**:新增 `blender.scene.author@v1` 与 `rotary_kiln` 声明式场景,可按长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、罩体、平台和物料参数生成可续作 `.blend` 工程、PNG 预览、场景清单、溯源和可选 GLB;固定启动器以后台、factory startup、禁止自动脚本模式调用 Blender,自身不接收 Python/命令行/路径。已在 Blender 3.6.12 上完成 60 m × 4 m、3.5% 斜度、三档生成及三档到四档 Workspace 续作真机烟测;专项 93 项 unittest、.NET build/publish 和独立 adapter 打包通过,全量 693 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。 + - **08-20 / Unreleased / ANSYS 几何检查 + 静力选区 v2**:新增 `ansys.geometry.inspect@v1`,由 Mechanical 真实导入后输出 assembly/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection、预览和溯源;`ansys.mechanical.static_structural@v2` 以已有命名选择、全局轴极值面或平面法向/偏置/容差解析边界区域,支持 `expected_count` 硬断言,按实体 ID 创建项目内选择集,并把最终面和选区预览纳入结果。按明确授权直接移除 v1 调度能力;90 项 ANSYS/合同/Job/Node 专项 unittest、.NET build 与发布目录检查通过;全量 684 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或生产数据库,v2 真实许可证、选区和连续释放验收仍须在目标节点重跑。 - **08-19 / Unreleased / Windows Node adapter 合同单入口**:仓库根合同继续作为源码事实源,完整 Node 包与独立 adapter 包均在发布时把合同放入对应 adapter 目录;Node 移除共享 `software-contracts` fallback,心跳与诊断新增实际合同摘要和 Workspace 协议状态,服务端只向明确支持协议的节点派发 Workspace Job,避免遗留合同覆盖与确定性拒绝循环。 diff --git a/RUN.md b/RUN.md index 1423053..d4c8901 100644 --- a/RUN.md +++ b/RUN.md @@ -1098,10 +1098,12 @@ cd /d D:\ZcbotNode install-windows-node.bat ``` -若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe` 与 `%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON` 或 `ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。 +若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin|ansys|blender\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。 Workspace 目录固定为 `%ProgramData%\Zcbot\WindowsNode\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 +Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;`.blend` 留在 Workspace 作为下一次修改的事实源。安装器会从 `ZCBOT_BLENDER_EXE`、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender;非标准安装位置应先设置机器级 `ZCBOT_BLENDER_EXE`,再重新启动 Node。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。 + ANSYS 能力为 `ansys.geometry.inspect@v1` 和 `ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2(revision 242)。adapter 0.3.0 使用 PyMechanical 0.11.3:前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区,再执行真实静力求解。v2 直接替换旧 v1,旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。 普通几何先提交 `ansys.geometry.inspect@v1`,`outputs` 传空数组;成功后以 `geometry-manifest.json` 的单位、包围盒、面质心/法向/面积和 `geometry-preview.png` 判断边界区域。规则板件可在静力 v2 中使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`,其中偏置和容差沿用 `analysis.unit_system` 的长度单位;已有 CAD Named Selection 则使用 `{"type":"named_selection","name":"fixed_mount"}`。每个 boundary/load 还必须提供唯一的 ASCII `name`,用于 Mechanical 树、反力结果和溯源。`expected_count` 可省略,但自动化生产请求应尽量提供;数量不符、没有平面、查询为空或名称冲突都会在网格和求解前失败。复杂装配或歧义区域不得只凭预览猜测,应回到 CAD 建立 Named Selection 后再提交。 @@ -1145,6 +1147,8 @@ 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` 发生变化,需重新运行统一安装器更新受管 Python runtime;仅 `adapter.json`、契约或 `worker.py` 变化时无需。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。 +Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时仍需运行统一安装器创建轻量启动 runtime,`bpy` 不安装到该 runtime。 + 注册配置写入 `%ProgramData%\Zcbot\WindowsNode\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。 直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 diff --git a/software-contracts/blender.scene.author.v1.json b/software-contracts/blender.scene.author.v1.json new file mode 100644 index 0000000..239502a --- /dev/null +++ b/software-contracts/blender.scene.author.v1.json @@ -0,0 +1,163 @@ +{ + "capability": "blender.scene.author@v1", + "display_name": "Blender 三维场景创作", + "default_enrollment": false, + "output_namespace": "blender", + "input_policy": { + "suffixes": [], + "max_count": 0, + "max_bytes": 0, + "max_total_bytes": 0 + }, + "outputs": { + "project": { + "filename": "scene.blend", + "media_type": "application/x-blender", + "relative_path": "scene.blend", + "publish": true, + "required": true + }, + "preview": { + "filename": "preview.png", + "media_type": "image/png", + "relative_path": "preview.png", + "publish": true, + "required": true + }, + "scene_manifest": { + "filename": "scene-manifest.json", + "media_type": "application/json", + "relative_path": "scene-manifest.json", + "publish": true, + "required": true + }, + "scene_glb": { + "filename": "scene.glb", + "media_type": "model/gltf-binary", + "relative_path": "scene.glb", + "publish": true, + "required": false + }, + "provenance": { + "filename": "provenance.json", + "media_type": "application/json", + "relative_path": ".meta/provenance.json", + "publish": false, + "required": true + } + }, + "feature_path": ["operation", "scene", "type"], + "features": { + "rotary_kiln": "0.1.0" + }, + "summary": { + "title_path": ["operation", "scene", "title"] + }, + "legacy_runtime": null, + "workspace": { + "state_output": "project", + "preview_outputs": ["preview"], + "required_adapter_version": "0.1.0" + }, + "request_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-maxBytes": 65536, + "required": ["schema_version", "inputs", "operation", "outputs"], + "additionalProperties": false, + "properties": { + "schema_version": {"const": 1}, + "inputs": { + "type": "array", + "maxItems": 0 + }, + "operation": { + "type": "object", + "required": ["scene"], + "additionalProperties": false, + "properties": { + "scene": {"$ref": "#/$defs/rotaryKilnScene"} + } + }, + "outputs": { + "type": "array", + "maxItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "required": ["key", "type", "format"], + "additionalProperties": false, + "properties": { + "key": {"const": "scene_glb"}, + "type": {"const": "model"}, + "format": {"const": "glb"} + } + } + } + }, + "$defs": { + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "rotaryKilnScene": { + "type": "object", + "required": ["type", "title", "kiln"], + "additionalProperties": false, + "properties": { + "type": {"const": "rotary_kiln"}, + "title": {"type": "string", "minLength": 1, "maxLength": 160}, + "kiln": { + "type": "object", + "required": [ + "length_m", "outer_diameter_m", "shell_thickness_m", + "lining_thickness_m", "slope_percent", "support_stations" + ], + "additionalProperties": false, + "properties": { + "length_m": {"type": "number", "minimum": 5, "maximum": 200}, + "outer_diameter_m": {"type": "number", "minimum": 0.5, "maximum": 15}, + "shell_thickness_m": {"type": "number", "minimum": 0.005, "maximum": 0.5}, + "lining_thickness_m": {"type": "number", "minimum": 0.01, "maximum": 1.5}, + "slope_percent": {"type": "number", "minimum": 0, "maximum": 10}, + "support_stations": {"type": "integer", "minimum": 2, "maximum": 8}, + "rotational_speed_rpm": {"type": "number", "exclusiveMinimum": 0, "maximum": 10} + } + }, + "components": { + "type": "object", + "additionalProperties": false, + "properties": { + "drive_system": {"type": "boolean"}, + "burner": {"type": "boolean"}, + "hoods": {"type": "boolean"}, + "inspection_platform": {"type": "boolean"}, + "material_bed": {"type": "boolean"} + } + }, + "appearance": { + "type": "object", + "additionalProperties": false, + "properties": { + "shell_color": {"$ref": "#/$defs/color"}, + "metal_color": {"$ref": "#/$defs/color"}, + "lining_color": {"$ref": "#/$defs/color"}, + "material_color": {"$ref": "#/$defs/color"}, + "background_color": {"$ref": "#/$defs/color"} + } + }, + "render": { + "type": "object", + "additionalProperties": false, + "properties": { + "width": {"type": "integer", "minimum": 320, "maximum": 3840}, + "height": {"type": "integer", "minimum": 240, "maximum": 2160}, + "transparent_background": {"type": "boolean"}, + "view": {"enum": ["isometric", "side", "kiln_head", "kiln_tail"]} + } + } + } + } + } + } +} diff --git a/tests/fixtures/blender-rotary-kiln/request/request.json b/tests/fixtures/blender-rotary-kiln/request/request.json new file mode 100644 index 0000000..51dde96 --- /dev/null +++ b/tests/fixtures/blender-rotary-kiln/request/request.json @@ -0,0 +1,47 @@ +{ + "job_id": "11111111-1111-4111-8111-111111111111", + "lease_id": "22222222-2222-4222-8222-222222222222", + "request_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "request": { + "schema_version": 1, + "inputs": [], + "operation": { + "scene": { + "type": "rotary_kiln", + "title": "60 m 水泥回转窑", + "kiln": { + "length_m": 60, + "outer_diameter_m": 4, + "shell_thickness_m": 0.04, + "lining_thickness_m": 0.2, + "slope_percent": 3.5, + "support_stations": 3, + "rotational_speed_rpm": 3 + }, + "components": { + "drive_system": true, + "burner": true, + "hoods": true, + "inspection_platform": true, + "material_bed": true + }, + "appearance": { + "shell_color": "#707780", + "metal_color": "#303840", + "lining_color": "#B56A3B", + "material_color": "#E35D20", + "background_color": "#20252B" + }, + "render": { + "width": 960, + "height": 540, + "transparent_background": false, + "view": "isometric" + } + } + }, + "outputs": [ + {"key": "scene_glb", "type": "model", "format": "glb"} + ] + } +} diff --git a/tests/fixtures/blender-rotary-kiln/workspace.json b/tests/fixtures/blender-rotary-kiln/workspace.json new file mode 100644 index 0000000..9b0c96e --- /dev/null +++ b/tests/fixtures/blender-rotary-kiln/workspace.json @@ -0,0 +1,6 @@ +{ + "workspace_id": "33333333-3333-4333-8333-333333333333", + "source_job_id": null, + "mode": "new", + "local_base_path": null +} diff --git a/tests/test_blender_adapter.py b/tests/test_blender_adapter.py new file mode 100644 index 0000000..d11e2b4 --- /dev/null +++ b/tests/test_blender_adapter.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from core.software_contracts import SoftwareContractError, get_contract + +ROOT = Path(__file__).resolve().parents[1] +ADAPTER = ROOT / "windows-node" / "adapters" / "blender.scene.author@v1" +LAUNCHER_PATH = ADAPTER / "worker.py" + +spec = importlib.util.spec_from_file_location("blender_adapter_launcher", LAUNCHER_PATH) +assert spec is not None and spec.loader is not None +launcher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(launcher) + + +def request() -> dict[str, object]: + return { + "schema_version": 1, + "inputs": [], + "operation": { + "scene": { + "type": "rotary_kiln", + "title": "水泥回转窑", + "kiln": { + "length_m": 60, + "outer_diameter_m": 4, + "shell_thickness_m": 0.04, + "lining_thickness_m": 0.2, + "slope_percent": 3.5, + "support_stations": 3, + "rotational_speed_rpm": 3, + }, + "components": { + "drive_system": True, + "burner": True, + "hoods": True, + "inspection_platform": True, + "material_bed": True, + }, + "appearance": { + "shell_color": "#707780", + "metal_color": "#303840", + "lining_color": "#B56A3B", + "material_color": "#E35D20", + "background_color": "#20252B", + }, + "render": { + "width": 1280, + "height": 720, + "transparent_background": False, + "view": "isometric", + }, + } + }, + "outputs": [{"key": "scene_glb", "type": "model", "format": "glb"}], + } + + +class BlenderContractTests(unittest.TestCase): + def test_rotary_kiln_contract_is_workspace_backed(self) -> None: + contract = get_contract("blender.scene.author@v1") + normalized, digest = contract.normalize_request(request()) + self.assertEqual(normalized, request()) + self.assertEqual(len(digest), 64) + self.assertEqual(contract.workspace.state_output, "project") + self.assertEqual(contract.workspace.preview_outputs, ("preview",)) + self.assertEqual(contract.required_adapter_version(normalized), "0.1.0") + self.assertEqual( + set(contract.expected_outputs(normalized)), + {"project", "preview", "scene_manifest", "scene_glb", "provenance"}, + ) + + def test_contract_rejects_arbitrary_execution_and_unsafe_scale(self) -> None: + contract = get_contract("blender.scene.author@v1") + malicious = request() + malicious["operation"]["scene"]["python"] = "import os" # type: ignore[index] + with self.assertRaises(SoftwareContractError): + contract.normalize_request(malicious) + + oversized = request() + oversized["operation"]["scene"]["render"]["width"] = 8192 # type: ignore[index] + with self.assertRaises(SoftwareContractError): + contract.normalize_request(oversized) + + invalid_geometry = request() + invalid_geometry["operation"]["scene"]["kiln"]["support_stations"] = 1 # type: ignore[index] + with self.assertRaises(SoftwareContractError): + contract.normalize_request(invalid_geometry) + + +class BlenderLauncherTests(unittest.TestCase): + def test_probe_reports_detected_blender(self) -> None: + with patch.object( + launcher, + "_find_blender", + return_value=(Path(r"C:\Program Files\Blender\blender.exe"), "4.5.3"), + ): + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual(launcher.probe(), 0) + payload = json.loads(output.getvalue()) + self.assertEqual(payload["health"], "ready") + self.assertEqual(payload["software_version"], "4.5.3") + + def test_launcher_uses_fixed_background_and_autoexec_flags(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + job_dir = Path(temporary) + (job_dir / "request").mkdir() + record = { + "job_id": str(uuid4()), + "lease_id": str(uuid4()), + "request_digest": "a" * 64, + "request": request(), + } + (job_dir / "request" / "request.json").write_text( + json.dumps(record), encoding="utf-8" + ) + executable = Path(r"C:\Program Files\Blender\blender.exe") + with ( + patch.object(launcher, "_find_blender", return_value=(executable, "4.5.3")), + patch.object( + launcher.subprocess, + "run", + return_value=SimpleNamespace(returncode=9), + ) as process, + ): + self.assertEqual(launcher.run(job_dir), 9) + arguments = process.call_args.args[0] + self.assertEqual(arguments[0], str(executable)) + self.assertIn("--background", arguments) + self.assertIn("--factory-startup", arguments) + self.assertIn("--disable-autoexec", arguments) + self.assertIn("--python", arguments) + terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8")) + self.assertEqual(terminal["status"], "failed") + self.assertEqual(terminal["error"]["code"], "BLENDER_NO_TERMINAL") + + def test_bpy_worker_contains_rotary_kiln_outputs_without_dynamic_code(self) -> None: + source = (ADAPTER / "blender_worker.py").read_text(encoding="utf-8") + for marker in ( + '"KilnShell"', + '"GirthGear"', + '"SupportRoller_', + '"scene.blend"', + '"preview.png"', + '"scene.glb"', + "bpy.ops.wm.open_mainfile", + ): + self.assertIn(marker, source) + self.assertNotIn("exec(", source) + self.assertNotIn("eval(", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 1d4e854..3a6674b 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -85,6 +85,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase): "origin.plot@v2", "ansys.geometry.inspect@v1", "ansys.mechanical.static_structural@v2", + "blender.scene.author@v1", }, ) self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities)) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 9d61aae..170e960 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -198,6 +198,19 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('python.exe -m venv "%~1"', script) self.assertNotIn('"%PYTHON_EXE%"', script) + def test_unified_installer_detects_blender_and_creates_launcher_runtime(self) -> None: + script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8") + self.assertIn(":detect_blender", script) + self.assertIn("ZCBOT_BLENDER_EXE", script) + self.assertIn("Blender Foundation\\Blender *", script) + self.assertIn( + 'set "BLENDER_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\blender"', + script, + ) + self.assertIn( + 'call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1"', script + ) + def test_unified_installer_uses_configurable_domestic_pypi(self) -> None: script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8") self.assertIn( @@ -230,6 +243,14 @@ class WindowsNodeSourceTests(unittest.TestCase): "ansys.mechanical.static_structural.v2.json", project, ) + self.assertIn( + "..\\..\\software-contracts\\blender.scene.author.v1.json", + project, + ) + self.assertIn( + "adapters\\blender.scene.author@v1\\blender.scene.author.v1.json", + project, + ) self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project) self.assertFalse((ROOT / "install-windows-node.ps1").exists()) self.assertFalse((ROOT / "install-origin-runtime.ps1").exists()) @@ -434,6 +455,15 @@ class WindowsNodeSourceTests(unittest.TestCase): tray, ) + def test_blender_adapter_can_be_packaged_without_building_node(self) -> None: + script = (ROOT / "package-blender-adapter.bat").read_text(encoding="utf-8") + self.assertIn("adapters\\blender.scene.author@v1", script) + self.assertIn("\\worker.py", script) + self.assertIn("\\blender_worker.py", script) + self.assertIn("..\\software-contracts\\blender.scene.author.v1.json", script) + self.assertIn("tar.exe -a -c -f", script) + self.assertNotIn("dotnet", script.lower()) + def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None: inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8") self.assertIn("state.UpdatedAt > acceptedAt", inbox) diff --git a/windows-node/README.md b/windows-node/README.md index 2a6231a..b431588 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -2,7 +2,7 @@ 内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch;客户端只使用 .NET Windows Desktop Framework,不依赖第三方 NuGet 包。 -当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2`、`ansys.geometry.inspect@v1` 和受真机验收门保护的 `ansys.mechanical.static_structural@v2` Python Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin 工程保存在 `workspaces//current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 +当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v1` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces//current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 在仓库根目录执行一条命令生成可分发 ZIP: @@ -20,7 +20,9 @@ windows-node\package-windows-node.bat install-windows-node.bat ``` -安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro 和 ANSYS Mechanical 2024 R2,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys` runtime;未检测到的软件会明确跳过。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\\Scripts\python.exe`。 +安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro、ANSYS Mechanical 2024 R2 和 Blender,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys`/`blender` runtime;未检测到的软件会明确跳过。Blender runtime 只是标准库启动器,`bpy` 始终在 Blender 自带 Python 内运行。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\\Scripts\python.exe`。 + +Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`,再运行统一安装器。 ANSYS adapter 0.2.0 已实现 Mechanical 2024 R2 / PyMechanical 0.11.3 的真实线性静力流程,可从 STEP/Parasolid/IGES 导入 Named Selection,应用固定材料/网格/支撑/力或压力,并导出 MECHDAT、汇总、结果表、结果图、solver log 和溯源。契约不接收 Python、APDL、Journal、工程文件或本机路径。Node 主窗口提供“复制诊断信息”和“ANSYS Mechanical 真机验收”卡片;验收使用内置 NIST FTC-11 STEP 并自动建立两个端面 Named Selection,用户只需选择报告目录。该入口不受执行门影响,但只有固定的取消与连续 20 次求解全部通过后,界面才允许写入机器级执行门。完成专用节点的许可证、取消、残留进程和许可证释放验收前,能力仍保持不可调度,具体步骤见根目录 `RUN.md`。 diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index 2efa138..fbba6e7 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -35,6 +35,11 @@ PreserveNewest PreserveNewest + + adapters\blender.scene.author@v1\blender.scene.author.v1.json + PreserveNewest + PreserveNewest + install-windows-node.bat PreserveNewest diff --git a/windows-node/adapters/blender.scene.author@v1/adapter.json b/windows-node/adapters/blender.scene.author@v1/adapter.json new file mode 100644 index 0000000..7df4a10 --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v1/adapter.json @@ -0,0 +1,10 @@ +{ + "capability": "blender.scene.author@v1", + "adapter_version": "0.1.0", + "runtime": "python", + "runtime_id": "blender", + "entrypoint": "worker.py", + "contract": "blender.scene.author.v1.json", + "worker_timeout_minutes": 120, + "running_detail": "Blender 正在生成三维场景" +} diff --git a/windows-node/adapters/blender.scene.author@v1/blender_worker.py b/windows-node/adapters/blender.scene.author@v1/blender_worker.py new file mode 100644 index 0000000..d3ad84e --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v1/blender_worker.py @@ -0,0 +1,421 @@ +"""Blender-owned bpy worker for declarative scene authoring.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import bpy +from mathutils import Vector + +ADAPTER_VERSION = "0.1.0" + + +def atomic_json(path: Path, value: Any) -> None: + temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def terminal( + record: dict[str, Any], + status: str, + artifacts: list[dict[str, Any]], + error: dict[str, 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, + "error": error, + "artifact_manifest": artifacts, + "terminal_at": datetime.now(timezone.utc).isoformat(), + } + + +def rgba(value: str) -> tuple[float, float, float, float]: + return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) + (1.0,) + + +def material(name: str, color: str, metallic: float, roughness: float) -> Any: + result = bpy.data.materials.new(name) + result.diffuse_color = rgba(color) + result.use_nodes = True + shader = result.node_tree.nodes.get("Principled BSDF") + if shader is not None: + shader.inputs["Base Color"].default_value = rgba(color) + shader.inputs["Metallic"].default_value = metallic + shader.inputs["Roughness"].default_value = roughness + return result + + +def assign(obj: Any, value: Any) -> None: + if getattr(obj.data, "materials", None) is not None: + obj.data.materials.append(value) + + +def cube(name: str, location: Vector, dimensions: tuple[float, float, float], mat: Any) -> Any: + bpy.ops.mesh.primitive_cube_add(location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = dimensions + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + assign(obj, mat) + return obj + + +def cylinder( + name: str, + location: Vector, + direction: Vector, + radius: float, + length: float, + mat: Any, + vertices: int = 64, +) -> Any: + bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=length, location=location) + obj = bpy.context.object + obj.name = name + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = direction.normalized().to_track_quat("Z", "Y") + assign(obj, mat) + return obj + + +def torus(name: str, location: Vector, direction: Vector, major: float, minor: float, mat: Any) -> Any: + bpy.ops.mesh.primitive_torus_add( + major_radius=major, + minor_radius=minor, + major_segments=64, + minor_segments=12, + location=location, + ) + obj = bpy.context.object + obj.name = name + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = direction.normalized().to_track_quat("Z", "Y") + assign(obj, mat) + return obj + + +def look_at(obj: Any, target: Vector) -> None: + obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler() + + +def clear_scene(base: Path | None) -> None: + if base is not None: + source = base / "scene.blend" + if not source.is_file(): + raise FileNotFoundError("WORKSPACE_SCENE_MISSING") + bpy.ops.wm.open_mainfile(filepath=str(source), load_ui=False) + else: + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for datablocks in (bpy.data.materials, bpy.data.cameras, bpy.data.lights): + for datablock in list(datablocks): + if datablock.users == 0: + datablocks.remove(datablock) + + +def build_rotary_kiln(spec: dict[str, Any]) -> dict[str, Any]: + kiln = spec["kiln"] + components = spec.get("components") or {} + appearance = spec.get("appearance") or {} + length = float(kiln["length_m"]) + diameter = float(kiln["outer_diameter_m"]) + radius = diameter / 2 + slope = math.atan(float(kiln["slope_percent"]) / 100) + stations = int(kiln["support_stations"]) + direction = Vector((math.cos(slope), 0, -math.sin(slope))) + low_height = radius + max(1.2, radius * 0.55) + center = Vector((0, 0, low_height + length * math.sin(slope) / 2)) + + def axis_at(distance: float) -> Vector: + return center + direction * distance + + shell_mat = material("ShellMaterial", appearance.get("shell_color", "#707780"), 0.65, 0.3) + metal_mat = material("EquipmentMetal", appearance.get("metal_color", "#303840"), 0.75, 0.25) + lining_mat = material("RefractoryLining", appearance.get("lining_color", "#B56A3B"), 0.05, 0.85) + hot_mat = material("HotMaterial", appearance.get("material_color", "#E35D20"), 0.0, 0.55) + concrete_mat = material("Concrete", "#77736B", 0.0, 0.9) + + shell = cylinder("KilnShell", center, direction, radius, length, shell_mat, 96) + bevel = shell.modifiers.new("ShellEdgeBevel", "BEVEL") + bevel.width = min(0.08, diameter * 0.015) + bevel.segments = 3 + + inner_radius = max(0.05, radius - float(kiln["shell_thickness_m"]) - float(kiln["lining_thickness_m"])) + for side, distance in (("Tail", -length / 2 - 0.015), ("Head", length / 2 + 0.015)): + cylinder(f"{side}LiningFace", axis_at(distance), direction, inner_radius, 0.04, lining_mat, 96) + if components.get("material_bed", True): + cylinder("KilnMaterial", center, direction, inner_radius * 0.72, length * 0.985, hot_mat, 64) + + station_positions: list[float] = [] + for index in range(stations): + distance = -length * 0.38 + index * (length * 0.76 / (stations - 1)) + station_positions.append(distance) + point = axis_at(distance) + torus(f"Tyre_{index + 1:02d}", point, direction, radius + 0.12, max(0.1, diameter * 0.045), metal_mat) + roller_radius = max(0.22, diameter * 0.09) + roller_length = max(0.7, diameter * 0.32) + for side_index, y_sign in enumerate((-1, 1), start=1): + roller_point = point + Vector((0, y_sign * radius * 0.72, -radius * 0.78)) + cylinder( + f"SupportRoller_{index + 1:02d}_{side_index}", + roller_point, + Vector((0, 1, 0)), + roller_radius, + roller_length, + metal_mat, + 48, + ) + foundation_top = max(0.3, point.z - radius * 0.78 - roller_radius + 0.05) + base_point = Vector((point.x, 0, foundation_top / 2)) + cube( + f"Foundation_{index + 1:02d}", + base_point, + (diameter * 0.7, diameter * 2.0, foundation_top), + concrete_mat, + ) + + if components.get("drive_system", True): + drive_distance = station_positions[min(1, len(station_positions) - 1)] + length * 0.06 + drive_point = axis_at(drive_distance) + torus("GirthGear", drive_point, direction, radius + 0.22, max(0.12, diameter * 0.06), metal_mat) + motor_center = drive_point + Vector((0, -radius * 1.35, -radius * 0.55)) + cube("Gearbox", motor_center, (diameter * 0.45, diameter * 0.5, diameter * 0.42), metal_mat) + cube("DriveMotor", motor_center + Vector((-diameter * 0.48, 0, 0)), (diameter * 0.5, diameter * 0.32, diameter * 0.32), shell_mat) + + if components.get("hoods", True): + tail = axis_at(-length / 2 - radius * 0.35) + head = axis_at(length / 2 + radius * 0.35) + cube("KilnTailHood", tail, (diameter * 0.75, diameter * 1.35, diameter * 1.5), metal_mat) + cube("KilnHeadHood", head, (diameter * 0.85, diameter * 1.55, diameter * 1.65), metal_mat) + + if components.get("burner", True): + head = axis_at(length / 2) + burner_center = head + direction * radius * 1.0 + cylinder("Burner", burner_center, direction, max(0.08, radius * 0.1), radius * 2.4, metal_mat, 32) + + if components.get("inspection_platform", True): + head = axis_at(length / 2 - radius) + platform = head + Vector((0, -radius * 1.2, -radius * 0.15)) + cube("InspectionPlatform", platform, (diameter * 1.5, diameter * 0.85, 0.12), metal_mat) + for offset_x in (-diameter * 0.65, diameter * 0.65): + cube("PlatformLeg", platform + Vector((offset_x, 0, -radius)), (0.12, 0.12, radius * 2), metal_mat) + + cube( + "Ground", + Vector((0, 0, -0.1)), + (length * 1.25, max(length * 0.38, diameter * 5), 0.2), + concrete_mat, + ) + return { + "length_m": length, + "outer_diameter_m": diameter, + "slope_percent": float(kiln["slope_percent"]), + "support_stations": stations, + "rotational_speed_rpm": float(kiln.get("rotational_speed_rpm", 3.0)), + "center": list(center), + } + + +def configure_scene(spec: dict[str, Any], dimensions: dict[str, Any]) -> None: + scene = bpy.context.scene + scene.unit_settings.system = "METRIC" + scene.unit_settings.length_unit = "METERS" + scene.unit_settings.scale_length = 1.0 + render = spec.get("render") or {} + scene.render.resolution_x = int(render.get("width", 1280)) + scene.render.resolution_y = int(render.get("height", 720)) + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.film_transparent = bool(render.get("transparent_background", False)) + for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"): + try: + scene.render.engine = engine + break + except TypeError: + continue + background = (spec.get("appearance") or {}).get("background_color", "#20252B") + if scene.world is None: + scene.world = bpy.data.worlds.new("SceneWorld") + scene.world.use_nodes = True + scene.world.color = rgba(background)[:3] + node = scene.world.node_tree.nodes.get("Background") + if node is not None: + node.inputs["Color"].default_value = rgba(background) + node.inputs["Strength"].default_value = 0.28 + + length = dimensions["length_m"] + diameter = dimensions["outer_diameter_m"] + target = Vector(dimensions["center"]) + view = render.get("view", "isometric") + offsets = { + "isometric": Vector((length * 0.62, -length * 0.68, length * 0.34)), + "side": Vector((0, -length * 0.82, length * 0.12)), + "kiln_head": Vector((length * 0.68, -length * 0.18, length * 0.18)), + "kiln_tail": Vector((-length * 0.68, -length * 0.18, length * 0.18)), + } + bpy.ops.object.camera_add(location=target + offsets[view]) + camera = bpy.context.object + camera.name = "SceneCamera" + camera.data.type = "ORTHO" + aspect = scene.render.resolution_x / scene.render.resolution_y + camera.data.ortho_scale = max(diameter * 5.5, length / aspect * 1.45) + camera.data.clip_end = max(1000, length * 10) + look_at(camera, target) + scene.camera = camera + + bpy.ops.object.light_add(type="SUN", location=target + Vector((-length, -length, length))) + sun = bpy.context.object + sun.name = "KeySun" + sun.data.energy = 2.2 + sun.rotation_euler = (math.radians(28), math.radians(-22), math.radians(-35)) + bpy.ops.object.light_add(type="AREA", location=target + Vector((0, -diameter * 5, diameter * 6))) + area = bpy.context.object + area.name = "FillArea" + area.data.energy = max(1200, length * 80) + area.data.shape = "DISK" + area.data.size = max(10, length * 0.45) + look_at(area, target) + + +def artifact(path: Path, artifact_id: str, media_type: str) -> dict[str, Any]: + if not path.is_file() or path.stat().st_size == 0: + raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}") + if path.suffix.lower() == ".png" and path.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n": + raise RuntimeError("PNG_EXPORT_INVALID") + return { + "artifact_id": artifact_id, + "filename": path.name, + "media_type": media_type, + "size_bytes": path.stat().st_size, + "sha256": sha256(path), + } + + +def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: + request = record["request"] + spec = request["operation"]["scene"] + if spec["type"] != "rotary_kiln" or request["inputs"]: + raise ValueError("BLENDER_REQUEST_SEMANTICS_INVALID") + workspace_path = job_dir / "workspace.json" + workspace = json.loads(workspace_path.read_text(encoding="utf-8")) + base_value = workspace.get("local_base_path") + base = Path(base_value).resolve(strict=True) if base_value else None + clear_scene(base) + dimensions = build_rotary_kiln(spec) + configure_scene(spec, dimensions) + + output = job_dir / "output" + output.mkdir(exist_ok=True) + project = output / "scene.blend" + preview = output / "preview.png" + manifest = output / "scene-manifest.json" + provenance = output / "provenance.json" + bpy.ops.wm.save_as_mainfile(filepath=str(project), check_existing=False) + bpy.context.scene.render.filepath = str(preview) + bpy.ops.render.render(write_still=True) + + meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] + atomic_json( + manifest, + { + "title": spec["title"], + "scene_type": "rotary_kiln", + "dimensions": dimensions, + "objects": [ + { + "name": obj.name, + "type": obj.type, + "vertices": len(obj.data.vertices), + "polygons": len(obj.data.polygons), + } + for obj in meshes + ], + "object_count": len(bpy.context.scene.objects), + "mesh_count": len(meshes), + "vertex_count": sum(len(obj.data.vertices) for obj in meshes), + "polygon_count": sum(len(obj.data.polygons) for obj in meshes), + }, + ) + atomic_json( + provenance, + { + "adapter_version": ADAPTER_VERSION, + "blender_version": bpy.app.version_string, + "request_digest": record["request_digest"], + "workspace_mode": workspace.get("mode"), + "generator": "rotary_kiln@v1", + }, + ) + + artifacts = [ + artifact(project, "project", "application/x-blender"), + artifact(preview, "preview", "image/png"), + artifact(manifest, "scene_manifest", "application/json"), + ] + if any(item.get("key") == "scene_glb" for item in request["outputs"]): + glb = output / "scene.glb" + bpy.ops.export_scene.gltf(filepath=str(glb), export_format="GLB", export_apply=True) + artifacts.append(artifact(glb, "scene_glb", "model/gltf-binary")) + artifacts.append(artifact(provenance, "provenance", "application/json")) + return artifacts + + +def job_directory() -> Path: + arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + if len(arguments) != 1: + raise ValueError("BLENDER_JOB_DIRECTORY_MISSING") + return Path(arguments[0]).resolve(strict=True) + + +def main() -> int: + job_dir = job_directory() + record: dict[str, Any] = {} + try: + record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8")) + artifacts = execute(job_dir, record) + atomic_json(job_dir / "artifacts.json", artifacts) + atomic_json(job_dir / "terminal.json", terminal(record, "succeeded", artifacts, {})) + print("[OK] Blender rotary kiln scene completed.") + return 0 + except Exception as exc: # terminal.json must capture Blender-side failures + atomic_json( + job_dir / "terminal.json", + terminal( + record, + "failed", + [], + {"code": type(exc).__name__, "detail": str(exc)[:500]}, + ), + ) + print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows-node/adapters/blender.scene.author@v1/requirements.txt b/windows-node/adapters/blender.scene.author@v1/requirements.txt new file mode 100644 index 0000000..bfc9441 --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v1/requirements.txt @@ -0,0 +1 @@ +# The launcher uses only Python's standard library. bpy runs inside Blender. diff --git a/windows-node/adapters/blender.scene.author@v1/worker.py b/windows-node/adapters/blender.scene.author@v1/worker.py new file mode 100644 index 0000000..2070678 --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v1/worker.py @@ -0,0 +1,181 @@ +"""Fixed launcher for the Blender scene-authoring adapter.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ADAPTER_VERSION = "0.1.0" +BLENDER_ENV = "ZCBOT_BLENDER_EXE" + + +def _atomic_json(path: Path, value: Any) -> None: + temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _candidate_paths() -> list[Path]: + candidates: list[Path] = [] + configured = os.environ.get(BLENDER_ENV) + if configured: + candidates.append(Path(configured)) + discovered = shutil.which("blender.exe") or shutil.which("blender") + if discovered: + candidates.append(Path(discovered)) + for variable in ("ProgramFiles", "ProgramW6432"): + root = os.environ.get(variable) + if root: + candidates.extend( + sorted( + Path(root).glob("Blender Foundation/Blender */blender.exe"), + reverse=True, + ) + ) + result: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + try: + resolved = candidate.expanduser().resolve(strict=True) + except (OSError, RuntimeError): + continue + key = str(resolved).casefold() + if key not in seen and resolved.is_file(): + seen.add(key) + result.append(resolved) + return result + + +def _version(executable: Path) -> str: + result = subprocess.run( + [str(executable), "--version"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=7, + ) + output = result.stdout + "\n" + result.stderr + match = re.search(r"Blender\s+([0-9]+(?:\.[0-9]+){1,2})", output) + if result.returncode != 0 or match is None: + raise RuntimeError("Blender version probe failed") + return match.group(1) + + +def _find_blender() -> tuple[Path, str]: + failures: list[str] = [] + for candidate in _candidate_paths(): + try: + return candidate, _version(candidate) + except (OSError, subprocess.SubprocessError, RuntimeError) as exc: + failures.append(f"{candidate.name}: {exc}") + detail = "; ".join(failures) if failures else "Blender installation was not found" + raise FileNotFoundError(detail) + + +def _record(job_dir: Path) -> dict[str, Any]: + try: + value = json.loads( + (job_dir / "request" / "request.json").read_text(encoding="utf-8") + ) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _failure(job_dir: Path, code: str, detail: str) -> None: + record = _record(job_dir) + _atomic_json( + job_dir / "terminal.json", + { + "job_id": record.get("job_id", ""), + "lease_id": record.get("lease_id", ""), + "request_digest": record.get("request_digest", ""), + "status": "failed", + "error": {"code": code, "detail": detail[:500]}, + "artifact_manifest": [], + "terminal_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + +def probe() -> int: + try: + executable, software_version = _find_blender() + health = "ready" + detail = f"Blender background executable detected: {executable}" + except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc: + software_version = None + health = "unavailable" + detail = str(exc) + print( + json.dumps( + { + "adapter_version": ADAPTER_VERSION, + "software": "Blender", + "software_version": software_version, + "health": health, + "detail": detail, + }, + ensure_ascii=False, + ) + ) + return 0 + + +def run(job_dir: Path) -> int: + job_dir = job_dir.resolve(strict=True) + script = Path(__file__).with_name("blender_worker.py").resolve(strict=True) + try: + executable, _ = _find_blender() + result = subprocess.run( + [ + str(executable), + "--background", + "--factory-startup", + "--disable-autoexec", + "--python", + str(script), + "--", + str(job_dir), + ], + check=False, + ) + if not (job_dir / "terminal.json").is_file(): + _failure( + job_dir, + "BLENDER_NO_TERMINAL", + f"Blender exited with code {result.returncode} without terminal.json", + ) + return result.returncode + except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc: + _failure(job_dir, "BLENDER_START_FAILED", str(exc)) + print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + +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 + return run(Path(sys.argv[1])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows-node/install-windows-node.bat b/windows-node/install-windows-node.bat index bed2f08..f8d84d0 100644 --- a/windows-node/install-windows-node.bat +++ b/windows-node/install-windows-node.bat @@ -9,6 +9,9 @@ set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe" set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v2\requirements.txt" set "ANSYS_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\ansys" set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe" +set "BLENDER_REQUIREMENTS=%~dp0adapters\blender.scene.author@v1\requirements.txt" +set "BLENDER_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\blender" +set "BLENDER_RUNTIME_PYTHON=%BLENDER_RUNTIME_DIR%\Scripts\python.exe" if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/" echo [INFO] Installing zcbot Windows Node... @@ -66,7 +69,7 @@ echo [OK] Origin runtime installed: %ORIGIN_RUNTIME_PYTHON% call :detect_ansys_242 if errorlevel 1 ( echo [WARN] ANSYS Mechanical 2024 R2 was not detected; skipped the ANSYS runtime. - goto :install_startup + goto :install_blender ) echo [INFO] ANSYS Mechanical 2024 R2 detected. @@ -104,6 +107,37 @@ if errorlevel 1 ( ) echo [OK] ANSYS runtime installed: %ANSYS_RUNTIME_PYTHON% +:install_blender +call :detect_blender +if errorlevel 1 ( + echo [WARN] Blender was not detected; skipped the Blender launcher runtime. + goto :install_startup +) + +echo [INFO] Blender detected. +if not exist "%BLENDER_REQUIREMENTS%" ( + echo [ERR] Blender adapter requirements marker is missing: %BLENDER_REQUIREMENTS% + goto :failed +) +if not exist "%BLENDER_RUNTIME_PYTHON%" ( + echo [INFO] Creating Blender launcher runtime... + call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1" + if errorlevel 1 ( + echo [ERR] Failed to create the Blender launcher runtime. + goto :failed + ) +) +if not exist "%BLENDER_RUNTIME_PYTHON%" ( + echo [ERR] Python venv did not create its interpreter: %BLENDER_RUNTIME_PYTHON% + goto :failed +) +"%BLENDER_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" +if errorlevel 1 ( + echo [ERR] Existing Blender launcher runtime is not Python 3.12: %BLENDER_RUNTIME_PYTHON% + goto :failed +) +echo [OK] Blender launcher runtime installed: %BLENDER_RUNTIME_PYTHON% + :install_startup echo [INFO] Registering startup task for %USERDOMAIN%\%USERNAME%... schtasks.exe /Create /TN "Zcbot Windows Node" /SC ONLOGON /RU "%USERDOMAIN%\%USERNAME%" /RL LIMITED /IT /TR "\"%NODE_EXE%\"" /F @@ -132,6 +166,15 @@ if defined AWP_ROOT242 if exist "%AWP_ROOT242%\aisol\bin\winx64\AnsysWBU.exe" ex if exist "C:\Program Files\ANSYS Inc\v242\aisol\bin\winx64\AnsysWBU.exe" exit /b 0 exit /b 1 +:detect_blender +if defined ZCBOT_BLENDER_EXE if exist "%ZCBOT_BLENDER_EXE%" exit /b 0 +where.exe blender.exe >nul 2>&1 +if not errorlevel 1 exit /b 0 +for /d %%D in ("%ProgramFiles%\Blender Foundation\Blender *") do ( + if exist "%%~fD\blender.exe" exit /b 0 +) +exit /b 1 + :create_python_runtime if not "%~2"=="" ( if not exist "%~2" ( diff --git a/windows-node/package-blender-adapter.bat b/windows-node/package-blender-adapter.bat new file mode 100644 index 0000000..140b1be --- /dev/null +++ b/windows-node/package-blender-adapter.bat @@ -0,0 +1,38 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +cd /d "%~dp0" + +set "OUTPUT_ROOT=%~f1" +if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist" +set "SOURCE_DIR=%~dp0adapters\blender.scene.author@v1" +set "CONTRACT=%~dp0..\software-contracts\blender.scene.author.v1.json" +set "PACKAGE_DIR=!OUTPUT_ROOT!\blender.scene.author@v1" +set "ARCHIVE_PATH=!OUTPUT_ROOT!\blender.scene.author@v1-adapter.zip" + +if not exist "%SOURCE_DIR%\adapter.json" goto :failed +if not exist "%SOURCE_DIR%\worker.py" goto :failed +if not exist "%SOURCE_DIR%\blender_worker.py" goto :failed +if not exist "%SOURCE_DIR%\requirements.txt" goto :failed +if not exist "%CONTRACT%" goto :failed +where.exe tar.exe >nul 2>&1 +if errorlevel 1 goto :failed + +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!" +mkdir "!PACKAGE_DIR!" +copy /y "%SOURCE_DIR%\adapter.json" "!PACKAGE_DIR!\adapter.json" >nul +copy /y "%SOURCE_DIR%\worker.py" "!PACKAGE_DIR!\worker.py" >nul +copy /y "%SOURCE_DIR%\blender_worker.py" "!PACKAGE_DIR!\blender_worker.py" >nul +copy /y "%SOURCE_DIR%\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul +copy /y "%CONTRACT%" "!PACKAGE_DIR!\blender.scene.author.v1.json" >nul +if errorlevel 1 goto :failed + +tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "blender.scene.author@v1" +if errorlevel 1 goto :failed +echo [OK] Blender adapter package: !ARCHIVE_PATH! +exit /b 0 + +:failed +echo [ERR] Blender adapter packaging failed. +exit /b 1